Skip to content
miblo

SDK

Miblo Status API v1 reference

The complete Miblo Status API v1 reference: authentication, routes, screen, apps, limits, errors, versioning and security, as the product's contract says.

The reference below is the Status API v1 contract, copied from the product repository (docs/status-api.md) every time the site is published, so it never falls behind. The guide pages explain each part with examples.

Your own programs can show their status on Miblo: a build script, a CI watcher, a data job, an AI tool Miblo has no hooks for. Each one shows up as a session on the gadget and the phone (working, needs you, idle, done), and can put a short message on the gadget or start a focus session or a timer, the same ones the miblo CLI starts.

It does status and nothing else. The owner's rule: Miblo never interferes with the AI. Approvals, replies and tasks are not part of this API, and nothing in it can turn them on.

  • Server: the Miblo bridge, plugin/lib/sdk-api.js (wired in plugin/bin/bridge.js).

  • Clients: sdk/ts (@miblo/status, Node 20+, no dependencies) and sdk/python (miblo-status, Python 3.9+, standard library only). Both follow this document. Any other language can too.

  • CLI: miblo sdk token [--rotate] | sdk off | sdk status | sdk start.

Quick start

Terminal
export MIBLO_TOKEN=$(miblo sdk token)    # turns the API on and prints its token
import { MibloStatus } from '@miblo/status';
const miblo = new MibloStatus();                       // reads MIBLO_TOKEN
const job = await miblo.session({ tool: 'deploy.mjs', title: 'Deploy' });
await job.needsYou('confirm the deploy');              // the gadget alerts
await job.done('deployed');

Authentication

Decision: a separate status-only token, never the bridge key

The CLI and the hooks authenticate with the bridge key, <data>/bridge.key (64 hex characters, mode 0600, owned by the user; <data> is Miblo's data folder: miblo sdk status prints the token file's path, and the key sits next to it). A third-party program must not read that key. It grants everything the bridge does:

  • POST /event: any hook event for any session, including Claude Code's own;

  • the Miblo+ routes (/plus/*): among them taking the phone replies queued for a Claude Code session, and the waiters that deliver them;

  • POST /shutdown, and GET /status (costs, limits, the phone link's state).

A program that only wants to show "my build needs you" should hold none of that. So the Status API has its own secret:

  • miblo sdk token makes <data>/sdk.token (miblo_sdk_ + 64 hex, 0600, owner checked as the key's) and prints it. The API is off until then: with no token, every request but hello is refused (sdk_off).

  • The token is accepted on /sdk/v1/* only, and the bridge key is not accepted there. Each has its own headers and its own MAC labels, so neither secret can answer for the other.

  • miblo sdk token --rotate replaces it: a program holding the old one is refused from then on, and the hooks, the CLI and the phone are not affected. miblo sdk off deletes it.

  • Programs get it from the person, explicitly (MIBLO_TOKEN=$(miblo sdk token) ./job), not by looking for a file. The clients read MIBLO_TOKEN or take the token as a parameter.

What this does not do: it does not protect against hostile code that already runs as the same user. That code can read bridge.key, run miblo, or read the AI tools' own files. The token limits what a credential handed to a program can do. If the token leaks (a CI log, a shared script, an environment dump), the leak is limited to status, and a rotation ends it.

Wire protocol

The challenge-response the hooks use (plugin/lib/bridge-auth.js), with the token as the key. Every request goes to 127.0.0.1:<port> (47821; MIBLO_PORT overrides it), with the Host header 127.0.0.1:<port> or localhost:<port>, without Origin. A POST body is application/json. This keeps browsers and DNS rebinding out.

  1. GET /sdk/v1/hello with x-miblo-nonce: <32 hex> (fresh, random). The answer is 200 {"ok":true,"app":"miblo-bridge","api":1,"sdk":"on","version":"<Miblo>"} with:

    • x-miblo-sdk-proof: HMAC-SHA256(token, "miblo-sdk-hello:" + nonce) (hex). Check it: a listener that cannot prove it knows the token is not Miblo. Send it nothing.

    • x-miblo-sdk-challenge: <32 hex>: single use, valid for 10 s. Missing when the bridge hands out too many: wait 250-500 ms and ask again (the clients retry twice).

    • With the API off: {"sdk":"off"} and no proof.

  2. The request carries x-miblo-sdk-auth: <challenge>:<mac>, where mac = HMAC-SHA256(token, "miblo-sdk-req:<challenge>:<METHOD>:<path>:<hex SHA-256 of the body>"). The body is the exact bytes sent (empty for GET). The challenge is spent whatever the outcome.

  3. Every answer to a request whose challenge was open carries x-miblo-sdk-resp: HMAC-SHA256(token, "miblo-sdk-resp:<challenge>:<status>:<hex SHA-256 of the body>"). Trust the answer only when this verifies (unverified_answer otherwise). One exception: a body over 4096 bytes (a declare: 512 KiB; a frame: 1 MiB) is answered 413 unsigned, before the request can be checked. The clients refuse such a body before sending it.

Endpoints

Method and pathBodyAnswer
GET /sdk/v1/hellosee above
POST /sdk/v1/sessions{tool, title?, cwd?, state?, detail?, pid?}201 {id, state}
POST /sdk/v1/sessions/<id>{state, detail?}200 {id, state}
POST /sdk/v1/sessions/<id>/end{}200 {id, ended: true}
POST /sdk/v1/say{text, minutes?} or {off: true}200 {ok, delivered, gadgets}
POST /sdk/v1/focus{focusMin?, breakMin?, rounds?} or {stop: true}200 {ok, delivered, gadgets}
POST /sdk/v1/timer{minutes} or {stop: true}200 {ok, delivered, gadgets}
GET /sdk/v1/snapshot200 {api, sessions, gadgets}
POST /sdk/v1/screen/carda card (at most 1024 bytes)200 {ok, delivered, gadgets, app}
PUT /sdk/v1/screen/frames/<0-3>an MFRM1 file (application/octet-stream), or {png} / {rgba, w, h} / {mfrm} in base64 JSON200 {ok, slot, bytes, colours, delivered, gadgets}
DELETE /sdk/v1/screen/frames/<0-3>200 {ok, slot, delivered, gadgets}
POST /sdk/v1/screen/show{on: true|false}200 {ok, shown, delivered, gadgets}
DELETE /sdk/v1/screen200 {ok, delivered, gadgets}
GET /sdk/v1/screen200 {card, frames: [{slot, bytes, at}], shown, owner}
POST /sdk/v1/screen/peek{seconds?: 1-10}200 {ok, seconds, delivered, gadgets}
POST /sdk/v1/screen/animate{source, options} (JSON, base64) or multipart/form-data200 {ok, plan, warnings, delivered, gadgets}
POST /sdk/v1/screen/mode7{fx, speed?, amplitude?, zoom?, period?, centre?, region?, dir?}200 {ok, mode7, warnings, delivered, gadgets}
POST /sdk/v1/screen/stop{}200 {ok, stopped, delivered, gadgets}
GET /sdk/v1/screen/preview[?step=N&ms=M]200 {png (base64), kind, step, steps, exact, gap}
POST /sdk/v1/apps/declare{id, name, settings?, category?, promise?, preview?}200 {ok, id}
GET /sdk/v1/apps/<id>/settings[?wait=1[&rev=<rev>]]200 {id, values, missing, rev}
POST /sdk/v1/apps/<id>/settings/request{keys?, reason?}200 {ok, needsSetup}
GET /sdk/v1/apps/<id>/suggest?key=<key>&q=<text>200 {id, options: [{value, label}]}
GET /sdk/v1/apps/<id>/suggest/next200 {id, asks: [{n, key, q}]} (the app's long poll)
POST /sdk/v1/apps/<id>/suggest{n, options: [{value, label?}]}200 {ok, late?} (the app's answer)
POST /sdk/v1/apps/<id>/connect/<provider>/start{}200 {provider, url}
GET /sdk/v1/apps/<id>/connect/<provider>/token200 {provider, accessToken, expiresAt, account}
DELETE /sdk/v1/apps/<id>/connect/<provider>200 {provider, ok}

The screen routes are described under Screen and Animations.

Sessions

  • tool (required): your program's name, 1-20 characters, one line. It cannot be a name the gadget reads as a Claude Code tool (Bash, Edit, Read, Task, Agent...), and it cannot start with _.

  • title: the session's name on the gadget (up to 64 characters; the gadget shows 20). Without it, the folder of cwd names the session; without either, tool does. Two sessions with the same name get " 2", " 3"... as Claude Code's do.

  • state: working (default) | needs_you | idle | done. needs_you and done alert on the gadget, once per change, as a Claude Code question or a finished turn does.

  • detail: one short line (up to 64 characters; longer is cut, control characters are removed). It is the activity while working, and the reason while it needs you.

  • pid: the process whose exit ends the session (the clients send their own by default). Without one, a session expires after 12 h without an update (2 h once idle or done), as a coding agent's does.

  • id: made by the bridge, sdk- + 32 hex. Only these ids are accepted, so a program can never name, change or end a coding agent's session (unknown_session).

The sessions survive a bridge update (the handover) but not a computer restart.

Gadget actions

They work like miblo say, miblo focus and miblo timer (plugin/lib/daily-cli.js), with the same limits the firmware applies: a message of at most 40 characters and 47 UTF-8 bytes on one line, minutes 1-480 (default 30); focus 5-120 min, break 1-60, rounds 1-12; timer 1-180 min. Each paired gadget answers for itself: gadgets: [{id, name, ok, error?}], where error is offline | unpaired | unsupported | rejected | busy. delivered counts the gadgets that took it. With no Miblo paired, it is 0, which is not an error: the gadget is optional. A message cannot start like one of Miblo's own notices ("Approved:", "Phone:", "Miblo:", "Allow?"... in English and Portuguese, any case). Nothing goes to the desktop notifications.

Snapshot (read-only)

sessions: [{id, name, state, tool, sdk, since}] and gadgets: [{id, name, online}]. A coding agent's session shows by its 8-character id and its agent's name ("tool": "Claude Code"). It never shows a command, a file, a prompt, a cost, a limit or anything of Miblo+. since is epoch seconds.

Screen

Miblo 1.25 adds an App screen to the gadget's rotation (next to Overview, Limits and Sessions) that your program designs. The design and the file format are in screen-sdk-architecture.md; this section is the API.

const screen = new MibloStatus().screen({ tool: 'steps' });
await screen.frame(0, fs.readFileSync('background.png'));     // once: a designed background
await screen.layer(0, { v: 1, title: 'Passos', items: [{ t: 'big', value: '8.432', label: 'hoje' }] });
await screen.show(true);                                       // pin it (or: miblo mode app)
  • Your program's name. Every screen write carries the header x-miblo-sdk-tool: <tool> (percent-encoded UTF-8; the same rules as a session's tool). The App screen always draws it, and it says who holds the screen. The clients send it for you (screen({ tool })).

  • Each program its own screen. A program's screen is its declared app's (see Apps below), else its tool name's. The App screen shows one at a time and the bridge moves to the next every 15 s (miblo apps rotation <seconds>; 0: only the pinned one). screen.clear() (DELETE /sdk/v1/screen) takes your screen off; miblo screen clear takes all of them. The 4 frame slots are shared: a slot is the program's that wrote it until it deletes it or its screen goes (409 slot_taken with owner for another program).

  • Card (POST /sdk/v1/screen/card): {v: 1, title?, items: [1-4], icon?, bg?}; items big (value up to 10 characters, label), ring and bar (value 0..1), text (40 characters), spark (values: 2-24 numbers), row (exactly 2 of the others). label is up to 16 characters, title 24. color: amber, blue, green, red, white or grey. bg: "frame:N" draws the card over frame slot N (a layer). Strings are cleaned (invisible, bidi and control characters removed) and cut; one that starts like Miblo's own notices is refused; an unknown item type is refused; unknown fields are dropped. The card lives in the gadget's RAM (a restart clears it; the bridge sends it again within 15 minutes) and is dropped after 24 h without a refresh.

  • Frame (PUT /sdk/v1/screen/frames/<slot>): a 240x240 image. Send a PNG (8-bit RGB or RGBA, not interlaced, any size up to 4096 a side: it is fitted into 240x240 keeping its proportions), raw RGBA pixels ({rgba, w, h}), or a ready MFRM1 file. The bridge converts it to at most 64 colours and checks it. The body may be up to 1 MiB (one frame upload is read at a time: 503 busy otherwise). Converting needs no other software; SVG or HTML you render yourself (a browser canvas, sharp, resvg...).

  • Flash wears, so frames are for what changes slowly: one write per slot every 10 s and 100 per slot per day (429 rate_limited with retryAfter, checked before anything is converted or sent). Change the number on a card instead: a layer redraws without writing flash.

  • Delivery. Like say, every paired gadget answers for itself in gadgets, where error can also be rate_limited (the gadget's own wear limit). A gadget that missed a change (offline, busy) is brought up to date by the bridge later on its own. A Miblo on a firmware before 1.25 answers unsupported.

  • Pin (POST /sdk/v1/screen/show {on}): your screen stays on the App screen (no rotation) and the gadget keeps the App screen, as miblo mode app; {on: false} unpins it. Once per 10 s.

  • Read (GET /sdk/v1/screen): your screen: its card (with tool), your frame slots (at in epoch seconds), shown (pinned), current (on the App screen now) and rotation.

  • The phone and the desktop app get the card and its background reference in their snapshot (screen: {card, bg, shown}); the desktop app also reads the frames, with the bridge key (GET /sdk/v1/screen/frames/<slot> is not open to programs).

  • Peek (POST /sdk/v1/screen/peek {seconds}): the App screen on top of the rotation for 1-10 seconds (default 10), unless the Miblo is alerting. For the moment that matters (bitcoin moved 5 %, the build failed): once per 10 minutes per program, and at most one every 10 s (bursts of

    1. for all programs together (429 with retryAfter). It needs a card (409 no_card). It brings your screen forward first, unless another one is pinned.

The screen's own errors:

StatuserrorWhen
400bad_request (field: tool, v, items[0].value...)a card or name the rules refuse
400bad_frame (+ message)not a PNG / MFRM1 the converter takes (e.g. an interlaced PNG)
409slot_taken (+ owner)that frame slot is another program's
409no_carda peek before any card
409too_many_screensthe bridge already holds 64 program screens: clear one (screen.clear(), miblo screen clear); a screen nothing wrote for a day goes by itself
413too_large (+ max)a card over 1024 bytes, a frame body over 1 MiB, an MFRM1 over 60000 bytes of image data
415unsupported_media_typea frame neither application/octet-stream nor JSON
422too_many_colours (+ bytes, max)the image is too detailed for a frame: over 60000 bytes after its reduction to 64 colours (a photo; flat colours and text fit)
429rate_limited (+ retryAfter)cards (2 a second, bursts of 5), frames (the wear limit), show (1 per 10 s)

Animations

Miblo 1.26 plays animations from its own flash: the program sends a file once, the gadget plays it on its own (nothing streams). Three levels, nothing else to learn:

  1. An effect on a card item: one word, no file, nothing written to flash. {t: 'ring', value: 0.7, fx: 'sweep'}. fx: sweep (ring, bar: fills to its value), count (big: ticks to its value), slide (spark: scrolls), orbit (ring: a dot goes round), ticker (text: scrolls once; orbit takes speed, 0-360 deg/s), and pulse, blink, rain (any item but a row). A 3D model is an item too: {t: 'mesh', mesh: 'cube' | 'icosphere' | 'miblo' | {v: [[x, y, z]], f?: [[a, b, c]], e?: [[a, b]]}, shading?: 'wire' | 'flat', fx?: 'spin' | 'tumble', speed?: 0-360, light?: [x, y, z]} (coordinates and light -1..1, at most 64 vertices, 128 faces and 128 edges, no repeated vertex in a face, flat needs faces, one mesh per card, and the card's 1 KB: about 16 vertices; the SDKs also take {vertices, faces}).

  2. screen.animate('rain.gif', { fps }): the bridge does everything.

  3. The same call with options: fps, loop (default true), region: [x, y, w, h] (where on the 240x240 screen; outside it the App screen's background), and for a single picture a Mode 7 effect: fx: 'rotate' | 'zoom' | 'perspective' | 'wave' | 'scroll' with speed (-720..720: deg/s, or px/s), amplitude (0..900; wave: px, 0..64), zoom (permille, 250..4000), period (wave: rows, 8..240), centre: [x, y], dir (scroll: left|right|up|down or degrees); out of range is brought in and said.

const screen = new MibloStatus().screen({ tool: 'rain' });
const r = await screen.animate('rain.gif', { fps: 10 });
// r.plan: { kind: 'tiles', fps: 10, frames: 8, colours: 7, tiles: 23, sprites: 1, ... }
// r.warnings: [{ code, en, pt }]   e.g. "20 quadros e 180 cores: ficou com 8 quadros e 64 cores"
await screen.mesh('cube', { shading: 'wire', speed: 90 });
await screen.animate('road.png', { fx: 'perspective', speed: 80, region: [0, 40, 240, 200] });
await screen.mode7({ fx: 'perspective', speed: 200 });   // new numbers, nothing uploaded again
await screen.stop();
fs.writeFileSync('now.png', (await screen.preview({ step: 0 })).png);

What source may be: an animated GIF (LZW, local palettes, transparency, interlacing, frame disposal and delays all read), an APNG or a PNG, a list of PNGs, a sprite sheet {sheet, cell: [w, h] | n, count}, or a mesh ({mesh}; a path to an .obj is read by the client and reduced by the bridge: centred and scaled to -1..1, the nearest vertices merged until it fits a card, with a warning). On the wire: JSON {source: {gif | apng | png | bytes: base64} | {pngs: [base64]} | {sheet: base64, cell, count} | {mesh}, options: {...}}, or multipart/form-data with a file part (several: a list of PNGs), sheet or obj, and an options part (the same JSON). The body may be up to 12 MiB (8 MiB of file); one is read at a time (503 busy). options.dryRun: true answers the plan and warnings without sending anything.

What the bridge chooses (the program never sees tiles, slots or steps):

  • every frame fitted into the region, repeated frames merged, one palette of at most 64 colours for all frames (median cut; the Miblo palette's colours kept exactly);

  • tiles when the animation needs at most 256 different 8x8 tiles: tiles deduplicated across frames and horizontal/vertical flips, at most 8 sub-palettes of 16 colours (a tile with more is drawn with the nearest), one map per distinct frame (at most 16), and small objects that move (changed regions of at most 32x32 seen in two frames or more) lifted out as sprites when that saves tiles; a few tiles over 256 are merged with their nearest. Up to 30 fps;

  • rectangle frames when the tiles do not fit but only a small part changes (at most 120x120 pixels): one full frame under the card, then the changed rectangle per frame. Up to 10 fps;

  • whole frames otherwise (a photo-like animation): at most 8 frames at 4 fps, said in the warnings. A frame too detailed even then (over 60000 bytes after its reduction) is refused.

  • the steps come from fps, else from the file's delays (a frame without one: 100 ms); a faster rate than the kind holds is lowered and said ("pesado demais para 20 fps: 4 fps ou uma região menor").

Plan and warnings. plan: {kind: 'tiles' | 'mode7' | 'rect' | 'frames' | 'mesh', fps, loop, region, sourceFrames, sourceColours, frames, colours, tiles?, sprites?, maps?, rect?, bytes, mode7?}. warnings: [{code, en, pt}], codes: reduced (frames and/or colours kept), fps_capped, whole_frames, rect_frames, tiles_merged, colours_near, zero_delays, cut_source (more than 256 frames in the file), heavy, slots_short, clamped (a Mode 7 value out of range), mesh_reduced.

One animation at a time on the bridge (the gadget keeps one tile set and one animation): it plays while its program's screen is the one on the App screen, and is replaced by the same program's next animate. Another program gets 409 anim_taken with owner. Its files go to free frame slots (4–7 first, never a slot a program wrote: a tile set and its animation take two, whole or rectangle frames one each); screen.stop() gives them back. The same file sent again writes nothing. Like a card, an animation nothing refreshed for a day goes.

Delivery is the frames' (chunked uploads, every gadget brought up to date later when it was away). A Miblo on 1.25 keeps getting your card and frames 0–3 without what it cannot draw (no fx, a mesh shown as the text "Miblo 1.26", no animation) and answers unsupported in gadgets.

Preview (GET /sdk/v1/screen/preview?step=N&ms=M, screen.preview(), miblo screen preview [file]): a 240x240 PNG of what the gadget would show of your animation at step N (Mode 7: at ms), drawn by the bridge's own renderer with the firmware's rules (tiles and sprites match the firmware's own sample pixel for pixel; Mode 7 mirrors its integer math, exact: false until a shared fixture proves it). Its gap until the firmware's screenweb WebAssembly exposes the 1.26 player: the card's items and header band are not drawn. miblo screen preview rain.gif --fps 8 -o rain.png converts a file on the computer without the bridge and prints the plan and the warnings.

The animations' own errors:

StatuserrorWhen
400bad_source (+ message)not a GIF, PNG or APNG the decoders read (a damaged LZW stream, a bad chunk)
400bad_region / bad_fx / bad_option (+ field)an option out of its range
409anim_taken (+ owner)another program's animation is playing
409no_free_slotsevery frame slot is held by programs
409no_animationmode7 before a picture was animated
404no_animationa preview with no animation of yours
413too_large (+ max)a body over 12 MiB; a file over 8 MiB, a canvas over 4096 a side or 1 048 576 pixels
415unsupported_media_typeneither JSON nor multipart/form-data
422too_detailed (+ message, messagePt)too detailed even as whole frames, or too detailed for Mode 7 (over 256 tiles)
429rate_limited (+ retryAfter)the flash was written moments ago (each slot and file: once per 10 s, 100 a day), or more than 1 animation per 5 s

Every refusal carries message (English) and messagePt (Portuguese) in plain words.

Apps: settings and declaring

A program can be an app: it tells Miblo its name and the settings it needs, and the person sets them in the Miblo app's Apps tab (or miblo apps config <id> key=value), never in a file or an environment variable. The official apps (miblo-apps, the repository's apps/ folder) use exactly these routes.

JavaScript
const app = miblo.app({ id: 'weather', tool: 'weather.mjs' });
await app.declare({ name: { pt: 'Clima', en: 'Weather' }, category: 'day', promise: 'Vai chover?',
  settings: [{ key: 'city', label: { pt: 'Cidade', en: 'City' }, type: 'text', required: true, max: 40 }],
  preview: { v: 1, title: 'Clima', items: [{ t: 'big', value: '24°', label: 'Recife' }] } });
const { values, missing } = await app.settings.get();
if (missing.length) await app.settings.request(missing, 'Para mostrar a previsão');
const stop = app.settings.onChange((v) => redraw(v));   // the person saved: draw again at once
  • Who reads them. An app's settings belong to the program that declared it, by its tool name (x-miblo-sdk-tool, as the screen routes): another program gets 403 not_your_app. An app installed from a package belongs to the package's name. At most 32 apps per computer (409 too_many_apps).

  • Declare (POST /sdk/v1/apps/declare): id (a-z, 0-9, -; at most 48), name (a string or {pt, en}, 24 characters), settings (below), and for the Apps tab category (a short name), promise (one line, {pt, en}, 100 characters) and preview (a card with sample data). Declaring again replaces the schema and keeps every saved value still valid under it.

  • Settings schema: at most 12, each {key, label, type, default?, required?} with type text (max 1-200, default 100; suggest, below), number (min, max), select (options, below), toggle or url (http/https only), or connect (a "Conectar Google/Microsoft" button, Connect). Any setting may carry group: 'advanced': the Miblo app folds it under "Avançado". No secrets: there is no secret type, and a key named like one (token, password, api_key, secret...) is refused. A default must itself be valid.

  • Selects (docs/screen-sdk-architecture.md "Dynamic selects in settings"): options lists 1 to 2000 choices, each a string or {value, label} (value at most 64 characters; label a string or {pt, en}, cut at 60), at most 6000 in one app. max (1-20) above 1 makes a multi-pick: its value is a list of at most max distinct choices (miblo apps config <id> coins=bitcoin,solana; a multi-pick's values hold no comma), and a required one left empty is missing. dependsOn: '<key>' names an earlier single-choice select: options is then a map from that select's values to lists ({"bra.1": ["flamengo", ...], "eng.1": [...]}) and a saved value must be in the list of the parent's value (400 bad_request otherwise; declaring again drops one that no longer fits). An app fetches a long list from its source once a week and declares again when it changes: declaring keeps every saved value still valid, so keep the person's choices among the new options. The settings as the bridge keeps them (GET /plus/apps, miblo apps list --json) hold options as the values, labels ({value: {pt?, en?}}), max, and for a dependent select dependsOn and optionsBy ({parent value: [values]}).

  • Suggestions (type-ahead): a text setting declared with suggest: 'city' or 'free' gets type-ahead in the desktop app's form, answered by the running app. The app registers a handler (app.settings.onSuggest(key, async (q) => [{value, label}]); Python on_suggest), which holds a long poll (GET .../suggest/next, at most 20 s, counted with the settings waits) and answers each question (POST .../suggest {n, options}, at most 10, value 100 and label 60 characters). Whoever asks (GET .../suggest?key=&q=, any program with the token; the desktop app's GET /plus/apps/<id>/suggest?key=&q=) gets the app's options within 2 s, else [], and [] at once when the app is not polling or q is under 2 characters. A key without suggest is 400 bad_request.

  • Read (GET .../settings): values (defaults merged with what the person saved; a setting with neither is null), missing (required settings without a value) and rev (changes when the values do). With ?wait=1 the answer waits until the values change (from rev when given, else from now), at most 20 s; 16 waits at once at most (503 busy).

  • Ask (POST .../settings/request {keys?, reason?}): the person is asked now. The Miblo app shows a setup card with the form and the reason (80 characters, never starting like Miblo's notices), the phone says "Configure o app <name> no computador", and the app's own screen shows "Configure no app Miblo" in its card's place. It stays asked until the values change; an app whose required settings are missing always shows as needing setup.

  • The values stay on this computer (<data>/apps/<id>.json, 0600). The snapshot the phone and the Miblo app get lists apps: [{id, name, needsSetup, reason, keys}].

Running it for the person. miblo apps add "<command>" [--cwd <dir>] (from your own terminal; an AI agent cannot) registers your program; miblo apps enable <id> then keeps it running: Miblo starts it with MIBLO_TOKEN, MIBLO_PORT and MIBLO_APP_ID set (an official app also gets MIBLO_APP_STATE, its folder <data>/apps/<id>/state, where ctx.cached keeps its lists), restarts it when it exits (2 s, 10 s, 30 s, 2 min, 10 min), starts it again with Miblo, and keeps its output (miblo apps logs <id>). The official apps run the same way (miblo apps enable processador).

  • miblo apps enable <id> starts the bridge when it is not running. While any app is enabled the bridge stays up (it never exits for being idle). miblo apps start (the Miblo app at launch) starts the bridge and the enabled apps, and prints nothing when none is enabled.

  • miblo apps remove <id> stops one app, takes its screen and frame slots off, forgets a program added with apps add or an installed screen package, and deletes its settings.

  • miblo apps list --json answers {rotation, when, idleAfterS, gadgets: [{id, name, supportsWhen, when, idleAfterS}], apps: [{id, name, names, kind, enabled, state, needsSetup, reason, keys, schema, values, preview, category, promise, ...}]}; rotation is the seconds each app's screen shows (miblo apps rotation <seconds>); when and idleAfterS say when the gadgets show the App screen (miblo apps when, below); supportsWhen is false for a gadget whose firmware cannot apply it (no when in its GET /api/screen), null while it has not answered; each gadget's when and idleAfterS are what it holds now (null when not known). The desktop app's GET /plus/apps carries the same fields.

  • miblo apps when [rotation|idle|manual] [--idle-after <seconds>]: idle (default) shows the apps only when no AI session has been running, waiting or needing you for idleAfterS seconds (default 60, 10-3600) and takes them away when one starts; rotation gives the App screen its turn whatever is going on (the 1.25.0 behaviour); manual shows it only when pinned or peeking. Alerts always come first. The gadget applies it: after a choice here (or in the desktop app) the bridge sends POST /api/screen/rotation {dwellS, when, idleAfterS} (the gadget's own dwellS kept) once to every paired gadget whose GET /api/screen answers when, with the same retries as the card. Otherwise it only reads: a choice made on the gadget's settings page stays, also across bridge restarts. A gadget on firmware 1.25.0 keeps the rotation: apps when and miblo screen status name it ("Amon: update its firmware to choose when the apps show"). With no argument it prints the current choice.

  • Every app gets MIBLO_LANG (pt or en) unless the bridge's environment has one: the bridge's own detection, which also reads macOS' AppleLanguages and the runtime's locale, so an app speaks the person's language even when the bridge runs with LANG=C.UTF-8.

  • The phone gets the App screen now (screen: {card, bg, shown}) and the apps (apps: [{id, name, names, kind, enabled, state, needsSetup, reason, keys, schema, values, preview}]) in its status frame, within 24 KB: a select with more than 20 choices carries only the chosen ones (and choices, how many it has); the previews are dropped first, then the schemas and values, then apps.

Connect

An app can read the person's own Google or Microsoft data (the official Agenda reads their calendar) without ever holding a password or a refresh token: the bridge signs the person in, with OAuth 2.0 Authorization Code + PKCE as a public desktop client, and hands the app short-lived access tokens.

const app = miblo.app({ id: 'agenda', tool: 'Agenda' });
await app.declare({ name: 'Agenda', settings: [
  { key: 'google', label: { pt: 'Conectar Google', en: 'Connect Google' }, type: 'connect', provider: 'google',
    clientId: '<your desktop client id>.apps.googleusercontent.com', clientSecret: '<Google\'s installed-app secret>',
    scopes: ['https://www.googleapis.com/auth/calendar.readonly'],
    help: { pt: 'Os eventos aparecem no Miblo em até 30 s', en: 'Events show on Miblo within 30 s' } },
] });
// The person presses "Conectar Google" in the Miblo app (or your program opens this itself):
const { url } = await app.connect.start('google');
// Once connected (settings.values.google is the account, e.g. "ana@example.com"):
const { accessToken, expiresAt, account } = await app.connect.token('google');
await fetch('https://www.googleapis.com/calendar/v3/users/me/calendarList', { headers: { authorization: `Bearer ${accessToken}` } });
await app.connect.off('google');   // disconnect
  • The connect setting declares what to sign in to: provider (google or microsoft, one setting each), clientId (your OAuth client: a Google "Desktop app" client or an Azure public client), scopes (1-8), Google's clientSecret when it has one (Google issues one to desktop clients and documents it as not confidential; never for Microsoft), an optional help line (80 characters, under the button). It is never required and has no default: its value is the account connected (the id_token's email, else its preferred_username), '' until then, and only the bridge writes it (settings.request, the CLI and the Miblo app's Save refuse it). So settings.wait() wakes your program the moment the person connects or disconnects.

  • start answers the provider's sign-in address. The bridge listens on a loopback port of its own (Google: http://127.0.0.1:<port>; Microsoft: http://localhost:<port>, bound on 127.0.0.1 and ::1) for exactly one redirect within 5 minutes, checks its state, exchanges the code with the PKCE verifier (S256), and checks the id_token's nonce, audience, issuer and expiry (openid email are added to your scopes for that, and offline_access for Microsoft; Google gets access_type=offline&prompt=consent). The browser page says "Conectado como …". Starting again replaces a sign-in in progress; at most 4 at once (503 busy).

  • token answers an access token with at least a minute left, refreshing it when needed (a refresh token the provider rotates is kept). 404 not_connected until the person connects, and again after the provider refuses the refresh token (revoked: the account is forgotten and the setting goes back to ''). 503 unavailable (+ retryAfter) when the provider cannot be reached: try again later, still connected.

  • DELETE forgets the account on this computer (the person may also revoke it at myaccount.google.com or account.microsoft.com). Removing the app (miblo apps remove) does the same.

  • Who. Like the settings: only the program that declared the app (403 not_your_app).

  • Where the tokens live. <data>/apps/<id>/connect-<provider>.json (0600) holds the account and the refresh token sealed with AES-256-GCM under a key derived from the bridge key (HMAC-SHA256(bridge key, miblo-app-connect-v1|<id>|<provider>), bound to the app and provider): the file alone opens nothing, and nothing goes to miblo.ai. Access tokens are kept in the bridge's memory only.

  • The Miblo app's Apps tab draws each connect setting as a button ("Conectar Google", then "Conectado como … · Desconectar"); from a terminal: miblo apps connect <id> google|microsoft (opens the browser) and miblo apps disconnect <id> google|microsoft. The desktop routes: POST /plus/apps/connect {id, provider} -> {ok, url}, POST /plus/apps/disconnect {id, provider}.

Community apps with code

An app from the Miblo store may carry code written by someone else. It reaches a computer only after the store's deterministic scan, an AI review and a human's approval (docs/screen-sdk-architecture.md, "Community apps with code"), and then runs in Miblo's sandbox.

The package. GET https://miblo.ai/apps/<id>/package.json is the signed app package as for any store app, plus code: {sha256, url} (an https zip, at most 1 MB) and network: [hosts] (the only hosts the app may reach; [] means offline). The apps key signs the prefix miblo-app-package/1\n followed by the canonical JSON of {v, id, version, card, settings, frames, code, network} (keys sorted, no whitespace; code and network left out of a package without code, so older packages verify unchanged). The site must sign exactly this shape.

The zip. Plain Node ESM, no dependencies: manifest.json (id, name {pt, en}, category, promise, settings, network, intervalMs 10 s-24 h, preview) and main.js exporting run(ctx) as the official apps do (ctx: settings, state, lang, locale, now(), fetchJson(url), fetchText(url), timeOf(ms), sleep(ms), stateDir; it returns {card, peek?}). Only .js, .json and .md files, at most 64, no package.json, no links, no absolute names or .., 4 MB unpacked at most; manifest.json's network, when present, must equal the signed list.

miblo apps install <id> verifies the package's signature (APP_PACKAGE_KEYS), downloads the zip over https (no redirects, never read past 1 MB), checks its SHA-256 against the signed package, checks every entry, and unpacks it whole into <data>/apps/<id>/pkg (a new version replaces the old folder in one step and restarts the app if it is on). <data>/apps/<id>/state is its own writable folder. Then apps enable|disable|show|logs|config|remove work as for any app; apps list and apps show call it a community app and name its hosts; apps remove deletes its folder.

The sandbox. The supervisor starts it as

node --permission --allow-fs-read=<its pkg, its state, the Miblo runtime> --allow-fs-write=<its state>
     [--allow-net] --import <plugin>/lib/app-sandbox-hooks.js <plugin>/lib/app-sandbox-main.js <id> ...

with the same environment allowlist as any app plus MIBLO_APP_NETWORK (the signed hosts).

  • Node's permission model: it reads only its package, its state folder and the Miblo runtime, writes only its state folder; no child processes, worker threads, native addons or WASI (each needs an --allow-* flag the sandbox never passes). It needs Node 22.15+, 23.5+ or 24+; on an older Node the app does not run at all (state unsupported, a line in its log saying why). On Node 25+, which gates the network under --permission too, --allow-net is passed: the Status API client needs 127.0.0.1, and the next two layers are what hold the app's network.

  • The module hook (module.registerHooks, in-thread, for import and require): code that is not the Miblo runtime (the app's files, code it evaluates, data: modules) may import only files inside its own package and Node's built-ins except http, https, http2, net, tls, dgram, dns, child_process, cluster, worker_threads, vm, module, wasi, inspector, repl, v8, trace_events and sqlite. Code it writes to its state folder cannot be imported.

  • The Miblo fetch: globalThis.fetch (fixed, not writable) reaches only the signed hosts (exact host match, https on the default port, redirects followed by hand and held to the same rule), 10 s timeout, 256 KB per answer, at most 30 calls a minute. Fetch's connection pool on globalThis is guarded with the same hosts; WebSocket, EventSource, XMLHttpRequest, process.getBuiltinModule, process.binding and process.dlopen are gone; signals only to itself. The Status API token leaves the environment before the app loads (only the client keeps it).

  • The log: every refused import, fetch, signal and denied file access (logged even when the app catches the error) is a line blocked: <host or module or access> in miblo apps logs <id>, which counts them; apps show says how many.

  • The store's word stands: at miblo apps start, at the bridge's start and every hour, each community app's package is fetched again; one the store no longer serves (404, 410) or marks hidden is turned off with a line in its log and is not started again until it is served again. A network error changes nothing.

What it does not guarantee. The network is held only through the replaced fetch and the module hook (Node's permission model has no per-host rule), not by the operating system: a flaw in Node or in the hook would open it. An app can still compute as much as it likes (CPU, memory), read what Node gives every program (the time, the locale, os details such as the host name) and send that to its declared hosts. The human review before a version is published is the first gate; the sandbox is the second.

What shows where

Gadget. The session is listed with the others, by title. While working its line reads <tool> · <detail>. While it needs you, it shows as a question (a question alert, the "needs you" counter). Done shows as finished (the finished alert). Meeting and discreet modes hide the detail as they do for every session. Nothing changes in the firmware: the snapshot row is a normal session row with tool set to the program's name and an extra "src":"sdk", which the firmware does not read.

Phone. The phone gets the same row. A phone app that knows src labels the session "Outra ferramenta: <tool>" ("Other tool: <tool>"). An older one shows it as any other session, with <tool> <detail> as its activity. It is never offered approvals, replies or tasks: the session's harness is sdk, which has none of the Miblo+ capabilities (lib/harness/index.js capsOf('sdk'): no reply route, no approvals). The bridge's reply router answers unsupported or unknown_mode for it, and its history frames carry reply: false. Not done in this change: the phone app's label lives in the miblo-platform repository.

The day's summary (miblo today, the gadget's daily totals) counts the coding agents' sessions only.

Limits

WhatLimit
Live SDK sessions8 (409 too_many_sessions)
Request body4096 bytes (413 too_large); a declare 512 KiB
Select choices2000 per select, 6000 per app; multi-pick max 20
Suggestionsanswered within 2 s (else []); 10 per answer; 4 questions waiting per app
Session writes (register, state, end)10 per second, bursts of 20
Gadget actions (say, focus, timer)bursts of 3, then 1 every 10 s
Snapshot reads5 per second, bursts of 10
Challenges (hello)50 per second, 8 per connection per second, apart from the hooks'
tool / title / detail20 / 64 / 64 characters
Screen cards2 per second, bursts of 5; 1024 bytes
Screen frames1 write per slot per 10 s, 100 per slot per day; 1 MiB body; 60000 bytes of image data
App screen pin (show)1 per 10 s
Peek1 per 10 min per program, 1-10 s
Animations1 per 5 s (bursts of 2); 12 MiB body (8 MiB file, 256 frames read); the flash's 10 s / 100 a day per slot and file; one at a time
Mode 7 parameters1 per second, bursts of 3
Apps32 per computer, 12 settings each; settings long polls: 16 at once, 20 s each

The limits are the bridge's, for every program together. The gadget applies its own limits too.

Errors

Every error is JSON, {"error": "<code>", ...}. The codes are stable:

StatuserrorWhen
400bad_request (+ field, message)a field out of range or of the wrong type
400bad_jsonthe body is not JSON
401sdk_offno token on this computer (miblo sdk token)
401not_authorisedno valid challenge answer (wrong or rotated token, a spent challenge)
403forbidden host / origin not allowedthe Host or Origin guard
404unknown_sessionno such SDK session (ended, expired, or not an SDK session)
404not_foundno such path
405method_not_allowedwrong method for the path
409too_many_sessions (+ max)8 SDK sessions are live
403not_your_appan app's settings, asked by another program
404unknown_appno such app declared or installed
409too_many_apps32 apps are known on this computer
404not_connectedan app's account (Connect) is not connected
404no_connect_setting / unknown_providerthe app declares no connect setting for that provider
503unavailable / busy (+ retryAfter)the provider cannot be reached; 4 sign-ins at once
413too_large (+ max)body over 4096 bytes (unsigned answer)
415content-type must be application/jsona POST without JSON
429rate_limited (+ retryAfter, retry-after header)over a limit above
500internala bug: please report it

The clients add the codes for problems on their side: no_token, bridge_unavailable (nothing on the port: start the bridge with miblo sdk start), timeout and unverified_answer. A not_authorised from hello also covers a bridge from a Miblo release that predates this API.

Versioning

  • v1 never breaks. Within /sdk/v1/, fields may be added, optional in requests and extra in answers. Clients ignore fields they do not know, and the bridge ignores unknown request fields. No field changes type or meaning, no field is removed, no error code is renamed, and no limit gets stricter.

  • New states, new required fields or any other incompatible change go under /sdk/v2/, with /sdk/v1/ kept alongside. hello reports api: the highest version the bridge speaks.

  • The packages follow semver. 1.x speaks v1.

Security

What a hostile local program with the token can do, and what bounds it:

  • Show fake sessions: at most 8 at a time, named by itself. Each one is visibly a separate session, labelled with its program's name ("Outra ferramenta" on the phone). It cannot take a coding agent's name slot ("my-api" becomes "my-api 2").

  • Raise "needs you" alerts on the gadget and the phone. Each state change alerts once, and changes are limited to 10 a second for all programs together. miblo sdk off or --rotate stops it.

  • Put messages on the gadget: 40 characters, 3 at once and then 1 every 10 s, never starting like Miblo's own notices ("Approved:", "Phone:"...). Never a desktop notification.

  • Start or stop focus sessions and timers (the same rate limit).

  • Draw on the App screen: a card, up to four images and the pin. The App screen always shows the program's name, Miblo's own alerts draw over it, and its strings cannot start like Miblo's notices. A flood of images is bounded by the wear limits.

  • Play an animation (1.26): data only — tile indexes, positions, step times, a few bounded Mode 7 numbers, a mesh within its caps — checked by the bridge and again by the gadget, played by the firmware's fixed player under its frame-time governor. No code goes up. Its files are bounded (12 KB tile set, 16 maps, 8 frame slots never taken from another program) and so are its writes (the wear limits). The decoders refuse a hostile file (a huge canvas, a broken LZW stream, too many frames) before allocating anything large. POST /sdk/v1/screen/animate is the one route the bridge's guard lets in as multipart/form-data: like every Status API route it does nothing without the request's MAC over the body, which no web page or form can make.

  • Read a status-only snapshot: session names (project folders or titles), states, agent names and the paired gadgets' names. No commands, prompts, files, costs, tokens or addresses.

  • Change or end another program's SDK session: every program holding the token shares one trust domain, and the snapshot lists the SDK sessions' full ids. A coding agent's session stays out of reach.

What it cannot do:

  • Anything the bridge key gates: send hook events, touch a coding agent's session, read /status, stop the bridge, or use any /plus/* route (approvals, replies, the reply waiters, tasks, history, the account link, phones). Nor read the frames back: the desktop app does that with the bridge key.

  • Anything a gadget's pairing token gates: the bridge talks to the gadgets with its own pairing tokens, and the API only reaches the actions above (sessions, say, focus, timer and the App screen) with validated bodies. There is no settings, rename, reset, Wi-Fi, pet or firmware change, no pairing, and no mode change other than pinning the App screen.

  • Anything Miblo+ gates: an SDK session has no reply route and no approvals, the snapshot never carries Miblo+ data, and the API never reaches the relay, the account or miblo.ai.

  • Reach the API from a browser (Host/Origin guard, JSON-only POST) or from another computer (loopback only).

Without the token (another local user, a web page, the network): nothing but hello, which reveals whether the API is on. A process that takes the port while the bridge is down cannot pass hello's proof, so the clients send it nothing. They also check every answer's MAC.

Same-user code is out of scope (see Authentication). It already has more than this API gives.