Saltar al contenido
miblo

SDK

Referencia de la Status API de Miblo (SDK 2.x)

La referencia completa de la Status API de Miblo (SDK 2.x): autenticación, rutas, pantalla, apps, límites, errores, versionado y seguridad, como dice el contrato del producto.

La referencia de abajo es el contrato de la Status API (SDK 2.x), copiado del repositorio del producto (docs/status-api.md) cada vez que se publica el sitio. Las páginas de la guía explican cada parte con ejemplos.

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 Miblo Desk and the phone (working, needs you, idle, done), and can put a short message on the Miblo Desk 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), version 2.x, downloaded from miblo.ai (they are not on npm or PyPI). Both follow this document. Any other language can too.

  • Versions: the API is named after the SDK that speaks it (2.x); the routes still live under /sdk/v1/ (the v1 is the route prefix, unchanged since the first SDK), and every request says the SDK API it speaks (x-miblo-sdk-api: 2). SDK 2.x needs Miblo 1.30.0 or newer on the computer and on the Miblo Desk; reading the person's AI sessions from an app needs 1.34.0, the progress of their plans 1.42.0 (SDK 2.1.0). See Versioning.

  • 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":2,"minApi":2,"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"} (still with api and minApi) and no proof.

    • Check the versions once the proof holds: minApi above the SDK API your client speaks means your client is too old (the clients raise upgrade); api below it means Miblo on the computer is too old (the clients raise bridge_outdated: miblo update).

  2. The request carries x-miblo-sdk-api: 2 (the SDK API your client speaks) and 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. A request without x-miblo-sdk-api, or below minApi, is answered (signed, once its MAC holds) 426 {"error":"upgrade","min":2,"message":"Atualize o SDK do Miblo: https://miblo.ai/sdk/instalacao. Update the Miblo SDK: https://miblo.ai/en/sdk/install."}. Every SDK before 2.0.0 sends no header, so every 1.x client gets this answer from Miblo 1.30.0 on.

  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.

Wire format (bridge → Miblo Desk, 1.30; internal)

Internal to Miblo, for its own maintainers: a program never sees it. Nothing here changes for programs: the Status API, the phone and the mirror speak JSON. Between the bridge and a Miblo Desk, the two biggest bodies travel in a compact binary form so that the Miblo Desk needs far less heap while it reads them: the snapshot (POST /api/state: a 6 KB JSON needed up to ~7.8 KB of ArduinoJson document while parsed) and the card (POST /api/screen/card: ~4 KB of document for a 1 KB card). The binary parser fills the firmware's own Snapshot and Card structs directly, with no document at all; the structs, their limits and every semantic rule (character limits, text cleaning, reserved phrases, ranges, counts) are the JSON parser's own, and firmware/test/test_wire proves both paths give byte-identical structs and the same errors over the shared fixtures.

  • A Miblo Desk that reads it lists "wire1" in GET /api/info caps. The bridge (plugin/lib/device-client.js) sends wire bodies only to such a Miblo Desk, as Content-Type: application/x-miblo-wire, sealed and signed exactly as JSON (MBA1., the body hash in the MAC); every other Miblo Desk, and a Miblo Desk that ever answered a wire body as not its format (a 400 at the parse, a 415), gets JSON (said once in the log). Both routes keep taking JSON, so an older bridge works with a newer Miblo Desk. The route tells a wire body by its magic; the server's size and heap verdicts run before the bytes reach either parser.

  • Encoder: plugin/lib/wire.js (encodeSnapshot, encodeCard; null when the format cannot carry a body or it would not fit the Miblo Desk's cap: JSON goes instead). Decoders: firmware/lib/miblo_core/src/miblo_wire.h (parseSnapshotWire, parseCardWire).

  • Conventions: little-endian integers; IEEE 754 f32/f64; str8 = u8 length, the bytes, a NUL; str16 = u16 length, the bytes, a NUL. A body cut short, a count past its limit, an enum byte out of range, a reserved flag bit or bytes left over is refused whole, the previous snapshot or card untouched.

Snapshot ("MWS" + format 0x01), in parseSnapshot's field order:

FieldWidthNotes
magic, format'M' 'W' 'S', u8format 1
vu8the protocol version, ≥ 1 (bad version otherwise)
flagsu81 usage given, 2 week given
seq, nowu32, u32
hoststr8≤ 20 characters
usage (if flag)str8 t, window h5, window d7t ≤ 12 characters
limsCount, limsu8 (≤ 3), each str8 t + window h5 + window d7an entry without a tool or a window is dropped, as in JSON
todayf32 usd, u16 turns, u32 work
lateststr8≤ 15 characters
week (if flag)u32 work, u16 turns, f32 usd, u8 toptop 0..6, 255 none
count, sessionsu8 (≤ 20), each: str8 id (≤ 8), str8 name (≤ 20), u8 st, str8 tool (≤ 32), str8 det (≤ 32), u32 since, u32 ts, str8 model (≤ 12), i16 ctx, i32 tokst 0 idle 1 running 2 perm 3 question 4 done; ctx/tok −1 none
moreu16
alertCount, alertsu8 (≤ 8), each: u32 id, u8 kind, str8 sid (≤ 8)kind 0 perm 1 question 2 done
zoneCount, zonesu8 (≤ 2), each: str8 z (1..47 bytes), i16 off, u32 next, u8 flags, i16 noffflags 1: noff given; offsets within ±18 h
windowu8 flags, f32 pct, u32 reset, u32 etaflags 1 present, 2 eta sent

Card ("MWC" + format 0x01), checked in parseCard's order, with its errors and fields:

FieldWidthNotes
magic, format'M' 'W' 'C', u8format 1
vu8must be 1
flagsu81 anim, 2 turn, 4 title, 8 icon, 16 bg
anim (if flag)u8 kind; steps: u8 n (1..16), n × (u8 slot, u16 ms), u8 loopkind 0 steps, 1 layer tiles, 2 layer mode7
turn (if flag)u16 n, u16 dwellS, u8 stepstep 255: none
itemsu8the count (0..4; 0 only with anim)
toolstr16raw: cleaned and checked on the Miblo Desk
title, icon, bg (if flags)str16, str16, u8 slot
each itemu8 kind, then its bodykind 0 big 1 ring 2 bar 3 text 4 spark 5 mesh 6 row
rowu8 n (2), n × item
item bodyu8 flags (1 label, 2 color, 4 fx, 8 speed, 16 id, 32 live, 64 range, 128 values); big: str16 value; ring/bar: f64 value; text: str16 value; spark: u8 n, n × f64; mesh: u8 shape, u8 shading, u8 light given [i8 ×3], custom: u8 nv, nv × 3 i8, u8 nf, nf × 3 u8, u8 ne, ne × 2 u8; then by the flags: str16 label, u8 color (1 amber..6 grey), u8 fx, f64 speed, str16 id, (u8 hz, u8 seconds), (f64 lo, f64 hi)shape 0 custom 1 cube 2 icosphere 3 miblo; numbers travel as the doubles JSON would parse to, so every clamp and rounding happens once, on the Miblo Desk

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/page{page, dir, data?, quality?, max?} (Miblo 1.45.0; at most 20 KB, data JSON at most 16 KB; page a .html file: an installed Desk app's own, else relative to dir, the program's absolute working folder; quality/max auto, verylow, low, medium, high, ultra)200 {ok, page, quality, max, current}
POST /sdk/v1/screen/data{data} (JSON, at most 16 KB)200 {ok}; 409 no_page before screen/page
POST /sdk/v1/screen/quality{quality, max?}200 {ok, quality, max}; 409 no_page before screen/page
POST /sdk/v1/screen/carda card (at most 1024 bytes; deprecated from 1.45.0, use screen/page)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, current, area, allowed, rotation, request, anim, live, budget, quiet}
POST /sdk/v1/screen/peek{seconds?: 1-10}200 {ok, seconds, delivered, gadgets}
POST /sdk/v1/screen/request{seconds?: 5-120}200 {ok, shown, seconds, why?, owner?, retryAfter?, held?, delivered, gadgets}
POST /sdk/v1/screen/release{}200 {ok, released, delivered, gadgets}
POST /sdk/v1/screen/live{samples: [{id, t, v}]} (1-128 samples)200 {ok, accepted, unknown}
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?, fps?} (fps 1..60, 1.28.1; 30 when left out)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?, alerts?, screen?, dwell?, sessions?, plans?}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}
POST /sdk/v1/apps/<id>/alert{title, text?, level?, seconds?, type?, key?, dedupeMin?, whenQuiet?}200 {ok, delivered, gadgets} (see Alerts)
POST /sdk/v1/apps/<id>/alert/test{type?, title?, text?}200 {ok, test: true, delivered, gadgets}
GET /sdk/v1/apps/<id>/alerts200 {id, permission, types, quiet, limit, queued, last}
GET /sdk/v1/apps/<id>/sessions200 {sessions: [{id, tool, project, state, since}]} (Miblo 1.34.0; see Sessions for apps)
GET /sdk/v1/apps/<id>/plans[?wait=1&rev=]200 {available, plans: [{id, title, status, progress, currentTask, running, ai, folder, updatedAt, startedAt, when}], rev} (Miblo 1.42.0; see Plans for apps)
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 Miblo Desk reads as a Claude Code tool (Bash, Edit, Read, Task, Agent...), and it cannot start with _.

  • title: the session's name on the Miblo Desk (up to 64 characters; the Miblo Desk shows 20). Without it, the folder of cwd (at most 1024 characters) 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 Miblo Desk, 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.

Miblo Desk 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 Miblo Desk answers for itself: gadgets: [{id, name, ok, error?}], where error is offline | unpaired | unsupported | rejected | busy. A Miblo Desk on a firmware older than 1.30.0 is not driven at all (it is told to update) and reports offline. delivered counts the Miblo Desks that took it. With no Miblo paired, it is 0, which is not an error: the Miblo Desk 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. The snapshot is for programs holding the SDK token; an app that wants the person's coding agents reads Sessions for apps (under Alerts) with the person's permission.

Screen

The App screen is a screen of the Miblo Desk's rotation (next to Overview, Limits and Sessions) that your program designs. It needs Miblo 1.30.0 or newer on the computer and on the Miblo Desk. 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 after each app's own time (miblo apps dwell <id> <seconds>, 5-120; 15 s by default). 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).

  • One app at a time on the Miblo. The Miblo Desk only ever holds the screen whose turn it is: a card you send while another program's screen shows is accepted (200, current: false) and kept by the bridge, and reaches the Miblo Desk when your turn comes; so do your animation's play and your live samples. Your frames and animation files wait too: they go up to the Miblo Desk's flash on your turn, right before your card (or at once when no app is showing). When your turn ends, your animation is stopped on the Miblo Desk before the next program's card goes. App alerts are not screens: they go ahead of every screen delivery waiting for a Miblo Desk.

  • Card (POST /sdk/v1/screen/card): {v: 1, title?, items: [1-4], icon?, bg?}; a card may have no items (items: []) while your animation plays: the title stays in the header and the animation is the screen (send your next card before you stop it); 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 Miblo Desk's RAM (a restart clears it; the bridge sends it again within 15 minutes) and is dropped after 24 h without a refresh. 1.27: a spark, ring or bar may be live (id, range, live): see Live charts below.

  • 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 Miblo Desk answers for itself in gadgets, where error can also be rate_limited (the Miblo Desk's own wear limit). A Miblo Desk that missed a change (offline, busy) is brought up to date by the bridge later on its own.

  • Pin (POST /sdk/v1/screen/show {on}): your screen stays on the App screen (no rotation) and the Miblo Desk 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), rotation, area ({y, h} in pixels: where your content goes on the 240x240 screen; {y: 0, h: 240} unless the person keeps the limits strip on top, so lay out within it), request, anim, live (below), budget (what your screen costs the Miblo Desk in RAM, storage and processing, in bands with advice; the SDKs' screen.budget()) and allowed (false: the person turned this app's screen off — "Mostrar a tela no Miblo" — and it runs only for its alerts: its card is kept and its live samples taken, never shown; an animation, a frame, a peek or a pin is answered 200 {ok, hidden: true, delivered: 0} with nothing sent. An event then goes as an alert, if allowed; the official apps' anim-kit moment() does this).

  • miblo apps screen <id> on|off (Miblo Hub's "Mostrar a tela no Miblo"): off, the app keeps running while its alerts are allowed (apps.json screenOff; apps list --json showScreen; enabled stays "it runs"), never in the rotation; with its alerts refused it stops. miblo apps peek <id> (Miblo Hub's "Mostrar agora"): its screen on the Miblos now, for 10 s.

  • The phone and Miblo Hub get the card and its background reference in their snapshot (screen: {card, bg, shown}); Miblo Hub 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.

  • Request (1.29.0; POST /sdk/v1/screen/request {seconds?}, screen.request({ seconds }), Python screen.request(seconds)): your screen on the App screen for seconds (5-120; your own time, miblo apps dwell, by default), for the moment that matters (a goal, a meeting in 5 min), only if nothing important is happening: no AI session waiting on the person, no alert up. Honest: 200 {ok, shown: true, seconds, delivered, gadgets}, or 200 {ok, shown: false, why, seconds, gadgets} with why one of waiting (an AI session needs the person), alert (one is on the screen, or the sessions' turn right after one: 1.46), manual (the person shows the apps only when they ask), taken (another app's pin or request holds the screen; owner, retryAfter), hidden (your screen is off), refused (the Miblo Desk said no), offline, no_gadget, or a Miblo Desk's own error (busy, rejected). It needs a card (409 no_card). One app at a time; again only 60 s after your last hold ended (429 rate_limited with retryAfter); while you hold it, a request answers the seconds left (held: true). The rotation waits for it; the app whose turn it was comes back after it, or at release (POST /sdk/v1/screen/release, screen.release() -> {ok, released}). GET /sdk/v1/screen says request: {seconds, until} | null. It works whatever the person chose, except manual; with miblo apps when request it is the only way the apps show. Each Miblo Desk answers for itself (gadgets[].error: waiting, alert, manual...).

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)
403alerts_onlya screen write from an app that declared screen: false
409slot_taken (+ owner)that frame slot is another program's
409no_carda peek, or a request for the screen, 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
410app_offthe app this program draws for was turned off (miblo apps disable, Miblo Hub, removed): its cards, frames, samples, animations, pins and peeks change nothing until it is turned on again; reading and clearing its screen still work
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
503busy (+ retryAfter)another frame or animation body is being read: try again in a second
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), frame conversions (1 a second, bursts of 3), frames (the wear limit), show (1 per 10 s), a request for the screen within 60 s of your last hold

A write while the person turned your app's screen off is not an error: 200 {ok, hidden: true, delivered: 0}, with nothing sent.

Frame sizes in practice. The SDKs send a PNG or raw RGBA as base64 inside JSON, and the body cap is 1 MiB, so a PNG file of about 768 KB or more, or raw RGBA of more than about 196,000 pixels (about 443x443), is refused 413 before it is converted; the source picture may also have at most 4,000,000 pixels. A 240x240 picture is all the Miblo Desk shows: resize first.

Animations

Miblo 1.26 plays animations from its own flash: the program sends a file once, the Miblo Desk 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 per-line 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, or at the same tile count when fewer maps make the file smaller with the same pixels; a few tiles over 256 are merged with their nearest. Up to 50 fps (a step of an animation file lasts 20 ms at least); the Miblo Desk then plays at the rate asked while the measured draw fits it (1.28.1: no floor by the area that changed);

  • 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, up to 10 fps (100 ms a step at least), 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's files hold is lowered and said ("arquivos de animação usam no mínimo 20 ms por passo: 60 fps ajustado para 50 fps").

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 per-line effect value out of range), mesh_reduced.

One animation playing at a time, the current app's. Any program may animate at any time: the bridge keeps each program's latest animation (as it keeps a background card) and plays only the one whose screen is on the App screen. The answer says current: true when it plays now, else queued: true (kept: it plays on the program's turn — send nothing again for that). On each rotation turn the previous program's animation stops and the next one's plays from the flash (its files went up on its first turn: no write after that). The same program's next animate replaces its own at once. 409 anim_taken (+ owner, retryAfter and a Retry-After header) only while another program holds the screen at that instant: it is pinned (show; retryAfter 60 s) or peeking (retryAfter: the peek's seconds left). A peek brings that program's screen and its animation for its seconds (at most 10), then the turn owner's screen and animation come back with what was left of its turn.

Scenes stay in the flash. An animation's files go to frame slots (the Miblo Desk has 48: slots 4-47 first, then 0-3, the programs' frame slots, when nothing else is free; a tile set and its animation take two, whole or rectangle frames one each) and stay there after it stops or another scene plays: playing a scene the Miblo Desk already holds writes nothing (no wear, no wait, no limit — also after a bridge restart: the bridge keeps what each Miblo Desk's slots hold, by SHA-256, in its data folder, and checks it against the Miblo Desk). A new scene takes a free slot the flash may write now, else the least recently played slot of the same program, else a slot of an app that is turned off or hidden, or that has not played for 6 hours — never the current app's (a program's own PUT /frames/<0-3> may take back a slot another program's kept, not playing, animation uses). Options: scene: 'water' (the same scene always comes back to its slots) and slot: n | [n, ...] (slots to prefer). The answer says writes (files written) and slots. Like a card, a program's slots go with its screen (a day without a write, screen.clear()).

Delivery is the frames' (chunked uploads, every Miblo Desk brought up to date later when it was away; a Miblo Desk answering busy right after flash writes is asked again in about 1.5 s, without it counting as a failure, and gets the current card as soon as it answers).

What plays, and what the Miblo Desk achieves: GET /sdk/v1/screen (your anim) and the snapshot's screen.anim carry {kind, slot, steps?, fps, loop, step, fx, mode7?, region, at, real: {fps, drawMs}}, real being the Miblo Desk's own measure (asked every few seconds while it plays); yours in GET /sdk/v1/screen also says playing: true (your screen is the current one: it plays now) or queued: true (kept for your turn; step and real then say nothing). With a 1.28.1 Miblo Desk it also carries loaded (the scene is in the Miblo Desk's RAM), yielded (its memory is lent to a request; it comes back by itself), why (while not loaded: the passes refused by reason, {notShown, pending, hold, free, room, failed, lastFree, lastBlock}) and yields (how often its memory was lent since it started): what each one means and what to do is in docs/miblo-memory.md. The snapshot's is the playing one's (the bridge's internal GET /screen/state, behind the bridge key, lists the others); miblo screen status prints it ("Animação: … · N fps real · M ms · não carregada (sem memória livre: 7,9 KB livres)"). A refused animate is noted with its code in the app's log (miblo apps logs <id>).

Preview (GET /sdk/v1/screen/preview?step=N&ms=M, step 0-63, ms 0-3600000, screen.preview(), miblo screen preview [file]): a 240x240 PNG of what the Miblo Desk would show of your animation at step N (with fx: 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; the per-line effects mirror 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, retryAfter)another program holds the pinned or peeking screen right now
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 a per-line effect (over 256 tiles)
429rate_limited (+ retryAfter, daily)a new file for a slot written moments ago ("wait N s": once per 10 s per slot) or past its daily cap (daily: true: 100 a day per slot); more than 1 written animation per 2 s (a burst of 4); one decode per 2 s (a burst of 4) for every request that decodes — the same file and options again are not decoded again, and playing a kept scene never waits

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

Live charts

Miblo 1.27 gives a chart that changes all the time two modes (docs/screen-animation-guide.md "Gráficos ao vivo: dois modos"):

  • Near real time (every card, unchanged): the value comes with each card and the item's fx animates the change on the Miblo Desk. Each card is one request (2 a second, bursts of 5) that costs the Miblo Desk 40–400 ms; an item with fx reaches about 10 fps on a plain card, 5 under an animation. Nothing new to learn.

  • Fluid animations (live): the program pushes samples at any rate; the bridge ships them in batches of seconds (3 s) and the Miblo Desk plays them back at its own clock, 24–30 fps, redrawing only the chart. The buffer is necessary and it costs a delay of seconds (3 s), which is compensated because the next batch is loaded behind the scenes while the current one is being shown: playback never waits for the network.

await screen.card({ v: 1, title: 'CPU', items: [
  { t: 'ring', id: 'cpu', value: 0.4, range: [0, 100], label: 'now', live: { hz: 10, seconds: 3 } },
  { t: 'spark', id: 'hist', range: [0, 100], label: 'last 6 s', live: { hz: 10, seconds: 3 } },
] });
screen.push('cpu', 42.5);                 // or screen.push({ cpu: 42.5, hist: 42.5 })
const st = await screen.status();         // st.live: [{ id, seq, buffered, playing, late, real: { fps, drawMs } }]

Item. live is accepted on spark, ring and bar: {t: 'spark', id: 'cpu', range: [0, 100], live: {hz: 10, seconds: 3}, label?, color?}.

  • id: required with live; 1–16 characters [a-z0-9_-], unique within the card; the key push uses.

  • range: [min, max] (finite, min < max): required with live on a spark; [0, 1] by default on a ring or bar. Samples are quantised by the bridge to 0..1000 within the range (clamped).

  • live.hz: 1..30 (default 10); live.seconds: 1..5 (default 3). A card holds at most 2 live items (top level or inside a row): more is 400 live_too_many. Every live item of a card has the same seconds, and their hz × seconds add up to at most 180 (one batch): otherwise 400 bad_request. live and fx on the same item: fx is ignored.

  • A live spark keeps values optional (the first picture before the first batch); on the Miblo Desk its window is the last hz × seconds × 2 samples (two batches), scrolling.

Push (POST /sdk/v1/screen/live {samples: [{id, t, v}]}, 1-128 samples, t the sample's age in ms when the request was sent, 0-60000; 10 requests a second, bursts of 20, for all programs): answers {ok, accepted, unknown: [ids the current card has no live item for]}. screen.push(id, value) or screen.push({...}) at any rate (≤ 60 Hz; faster calls are coalesced). The client keeps the samples per id with their time and flushes them every 500 ms or 32 samples; a refused flush goes to the client's onPushError (else it is dropped), and screen.flush() sends what waits and rejects with that error. screen.stop() and screen.clear() also end the live streams; a new card without the id ends that stream.

What the bridge does. Per program and id it keeps the samples and, every seconds (aligned to the stream's start), resamples the window to exactly hz × seconds values at a fixed rate (hold-last over gaps, linear in between, the last known value when nothing arrived; an empty window sends nothing and the Miblo Desk holds), quantises to 0..1000 and sends one batch per Miblo Desk per window with every live item of the current card (≤ 1 KB, through the per-Miblo Desk queue). Batches go only while that program's screen is current; when it comes up (the rotation, a peek, a pin) the bridge pre-rolls the latest complete window at once, so the chart fills within about a second. A program pushes whenever it runs; it need not watch current. The Miblo Desk starts playing when it holds one whole batch and the next is on its way; a late batch holds the last sample (late); two batches queued behind the one playing drop the oldest. One live request per Miblo Desk per seconds; the Miblo Desk answers 429 to a second batch within 1 s. Nothing is written to flash; nothing is kept across a bridge restart.

What plays. GET /sdk/v1/screen and the snapshot's screen.live carry [{id, i, r, hz, seconds, seq, value, buffered: 0..2, playing, late, real: {fps, drawMs}}] (i, r: the item's place in the card; value: the latest sample), one entry per live item of the current card; miblo screen status prints "Ao vivo: cpu · 2 lotes · 24 fps real · 6 ms". Miblo Hub and the phone draw a live item with its latest sample (no playback in the mirror).

The live charts' own errors (a push for an id the current card has no live item for is dropped, as the stream of an id a new card no longer has):

StatuserrorWhen
400bad_request (field: items[0].id, items[0].range, items[0].live.hz...)a bad id, range or live on a card
400live_too_manya third live item in the card
400bad_request (field: items)live items with different seconds, or hz × seconds adding up past 180

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 Miblo Hub'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 64 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}, cut at 24 characters), settings (below), and for the Apps tab category (^[a-z][a-z0-9-]{0,23}$), promise (one line, {pt, en}, cut at 100 characters) and preview (a card with sample data); also screen (false: an alert-only app, below), dwell (5-120: the seconds it suggests on the App screen; the person's choice wins), alerts (what its alerts are about, or its alert types), sessions (true: it reads the person's AI sessions, Miblo 1.34.0) and plans (true: it reads the progress of the person's plans, Miblo 1.42.0). Texts over their limit are cut, not refused. 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, url (http/https only), time ("HH:MM", 24 h; typed as 22:00, 7:30 or 22h30, always stored as "HH:MM"; Miblo Hub shows a native time picker), or connect (a "Conectar Google/Microsoft" button, Connect). A typed setting (text, number, url, time) may carry placeholder (a string or {pt, en}, 40 characters): an example of what to type, shown in the empty field. Declare a list as a select wherever one exists; keep free text only where no list can, and then say what to type. Any setting may carry group: 'advanced': Miblo Hub 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. A setting may carry seed in a declare: a value your program carries over from its earlier settings (an old text split into new settings). The bridge saves it once, only where the person has no value yet, never keeps it in the schema, and ignores one that is not a valid value. A saved text whose parts are all choices of a multi-pick that replaced it carries over by itself.

  • 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 Miblo Hub'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; Miblo Hub'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.

  • "Usar minha localização": next to every suggest: 'city' field Miblo Hub offers the computer's location (the operating system's, after its permission prompt; refused or not available, an approximate one from the IP address, only after saying so). The bridge names the place (GET /plus/apps/place?lat=&lon=&lang=, or without lat/lon for the IP address → {place: {name, region, country, lat, lon, approx}}, BigDataCloud's key-free client-side reverse geocoder, asked from this computer when the button is pressed, nothing kept) and the form fills "Brasília, DF". A field that also takes coordinates says coords: true (only with suggest: 'city'): there the form fills "lat,lon" (Radar's home).

  • 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. Miblo Hub 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 Miblo Hub" 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 Miblo Hub 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 (Miblo Hub 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, supportsRequest, when, idleAfterS}], apps: [{id, name, names, kind, enabled, state, needsSetup, reason, keys, schema, values, preview, category, promise, ...}]}; rotation is the seconds an app's screen shows when it has no time of its own (15 by default); when and idleAfterS say when the Miblo Desks show the App screen (miblo apps when, below); supportsWhen is false for a Miblo Desk whose firmware cannot apply it (no when in its GET /api/screen), null while it has not answered; supportsRequest is false for one that cannot show the apps when an app asks (no request in its GET /api/screen); each Miblo Desk's when and idleAfterS are what it holds now (null when not known). Miblo Hub's GET /plus/apps carries the same fields.

  • miblo apps when [rotation|idle|request|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 in a carousel whatever is going on (1.29.0: the members in the order the person arranged — the sessions, each app, the pet — each for its own time; a session starting, finishing or needing you brings the sessions at once; a pin there is one turn); request (1.29.0) shows the apps only when an app asks for the screen (above: a goal, a meeting in 5 min, for its own time, while nothing needs you), pinned or peeking; manual shows it only when pinned or peeking (a request is refused). Alerts always come first. The Miblo Desk applies it: after a choice here (or in Miblo Hub) the bridge sends POST /api/screen/rotation {dwellS, when, idleAfterS} (the Miblo Desk's own dwellS kept) once to every paired Miblo Desk whose GET /api/screen answers when, with the same retries as the card. Otherwise it only reads: a choice made on the Miblo Desk's settings page stays, also across bridge restarts. With no argument it prints the current choice.

  • miblo apps dwell [<id>|sessions|pet [<seconds>|default]]: how long one app stays on the App screen (5-120 s; default: what the app suggests in its declaration, dwell, else 15 s), or the sessions' or (1.29.0) the pet's turn in the carousel under apps when rotation. One time per app: the global apps rotation of 1.25-1.28 is out of the usage and Miblo Hub (still accepted from older scripts and Miblo Hub versions as that default; 0 reads as it). With no argument it lists the sessions, the pet and every app that is on. apps list --json adds sessionsDwell, sessionsDwellOwn, petDwell, petDwellOwn, order and, per app, dwell and dwellOwn (the person chose it).

  • miblo apps order [<id>|sessions|pet <position>] (1.29.0): the carousel's members in the order they take their turns (the sessions, each app, the pet), or one member moved to that position (1..n). Miblo Hub's Apps tab has the same list with a step up and a step down per row. miblo apps order --sessions-between on|off: "Voltar às sessões entre um app e outro" (on by default, the owner's 2026-10-09 rule): the sessions take a turn before every app and before the pet unless the member before it already is the sessions, so the default list with three apps runs sessions → A → sessions → B → sessions → C → sessions → pet; off: the list as arranged, one turn each. apps list --json adds sessionsBetween; Miblo Hub has the switch under the list; the Miblo Desk's settings page shows it. A firmware without the field keeps the 1.27.1 cycle.

  • 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 Miblo Hub (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 Miblo Hub'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.

  • Miblo Hub'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}.

Alerts

An app may tell the desk that something happened — a goal, a site down, a meeting in 5 minutes — with an alert: drawn over whatever screen the Miblo shows (another app's App screen too) for a few seconds, then the screen comes back.

JavaScript
const app = miblo.app({ id: 'jogo-do-time', tool: 'Jogo do time' });
await app.declare({ name: 'Jogo do time', alerts: { pt: '(gol, fim de jogo)', en: '(goals, full time)' } });
await app.alert({ title: 'GOL do Flamengo!', text: 'Flamengo 2 x 1 Fluminense', level: 'important', seconds: 12 });

POST /sdk/v1/apps/<id>/alert {title, text?, level?, seconds?} (Python: app.alert(title, text, level, seconds)):

  • title at most 24 characters (required), text at most 40; cleaned as a card's strings, and never one of Miblo's own notices or alert phrases (400 bad_request). level: info (default; the notice) or important (the screen flashes first, like Miblo's own alerts, in violet: never the amber of "needs you" nor the blue of "finished"). seconds 1-15 (default 8). The app's name (the tool that declared it) is always drawn on it.

  • Only with the person's permission. Each app has an alerts switch, off until the person turns it on: Miblo Hub's Apps tab ("Permitir alertas") or miblo apps alerts <id> on|off (refused under an AI agent; turning it off works from anywhere). An app's first alert before the person ever decided asks them: Miblo Hub shows "<name> quer enviar alertas <alerts>. Permitir?" (alerts from declare, at most 40 characters) with Permitir / Agora não; the request is written to the audit log (app_alerts_asked, then app_alerts_on / app_alerts_off). Until allowed, every alert is dropped (logged by the bridge) and answered 403 alerts_off with asked: true; once the person turned them off, asked: false and nobody is asked again. The request never marks the app needsSetup: its App screen keeps its own card. The state lives in the app's file (alerts: 'granted' | 'denied' | 'asked', alertsAskedAt), so a bridge restart neither asks nor audits again; "Agora não" (POST /plus/apps/alerts {id, later: true}, miblo apps alerts <id> later) holds the ask for 7 days, then the app's next alert asks once more. Only real changes reach the audit log (app_alerts_asked, app_alerts_later, app_alerts_on, app_alerts_off).

  • Miblo comes first. The Miblo Desk draws an app alert only while none of its own alerts ("needs you", permission, a finished session), prompts, codes or setup screens is up and nobody waits on the person. Meanwhile it waits (at most 3 per app, 4 in all) and is dropped 2 minutes after it came. A Miblo alert that comes while one is on takes the screen at once; the app alert comes back afterwards once, from the start. In meeting mode an important alert blinks once; with the Miblo's alerts turned off it does not flash.

  • Limits: one per 2 minutes and 20 a day (rolling) per app (429 rate_limited, retryAfter), and the shared bucket of 5, then 1 every 10 s. The Miblo Desk keeps its own cap per app as a backstop (one per 115 s, 20 a day).

  • Bridge → Miblo Desk: POST /api/screen/alert {title, text, level, seconds, app}, sealed, to every paired Miblo Desk at once (not retried: an alert is about now). GET /api/screen reports the alert on screen (alert: {app, title, level, leftMs} or null) and alertsWaiting.

The official apps that alert: Jogo do time (a goal, the final whistle), Serviços (a site down after two failed checks, and back up: "api.exemplo.com voltou após 12 min") and Agenda (5 minutes before a meeting, and when it starts), each through sdk/kit/alert.js.

Sessions for apps

An app that wants to see the person's AI coding sessions (Project Genesis, the city where your agents live, is the first) declares "sessions": true in its manifest (or sessions: true in declare). It is off by default: the app's first read asks the person once, in Miblo Hub ("Permitir" / "Agora não"), and only the person grants it: Miblo Hub, the phone (read-only) or miblo apps sessions <id> on|off|later; an AI agent cannot turn it on. "Agora não" holds the question for 7 days. Every change is audited (app_sessions_asked, app_sessions_on, app_sessions_off, app_sessions_later).

GET /sdk/v1/apps/<id>/sessions (the app's own token and id) answers

JSON
{ "sessions": [ { "id": "a1b2c3d4", "tool": "Claude Code", "project": "miblo", "state": "working", "since": 1760270000 } ] }
  • id: the tracker's short id (stable while the bridge runs and across its restarts); tool: the agent's label; project: the last folder of the session's directory (cleaned, at most 64 characters, never a path; session when there is none); state: working, needs_you, idle or done; since: epoch seconds.

  • At most 50 sessions, coding agents only.

  • Never exposed: prompts, commands, files, session titles, full paths, anything of Miblo+, and SDK sessions (the ones programs open with POST /sdk/v1/sessions).

  • Needs Miblo 1.34.0 or newer: on an older Miblo ctx.sessions is undefined in the runner and app.sessions() answers 404 not_found; an app should treat both like sessions_off and keep its last state.

  • Poll every 30 seconds; it shares the read rate limit (429 rate_limited).

  • Errors: 403 not_declared (the manifest does not ask), 403 sessions_off (+ asked: the person was asked now or before; keep your last state and try again later), 403 not_your_app.

Clients: app.sessions() (TypeScript, Python), ctx.sessions() in a miblo-apps runner (an array; it rejects with MibloError). The CLI is miblo apps sessions <id> [on|off|later].

Plans for apps

Miblo 1.42.0. The plans the AIs keep in Miblo+ Pro (saved from a session, scheduled from the phone, run by the autopilot) can show their progress on the desk: an app declares "plans": true in its manifest (or plans: true in declare) and asks the person exactly as for the sessions: off by default, asked once on the app's first read ("<app> quer ver o progresso dos seus planos": the Miblo Hub's "Permitir" / "Agora não", the phone read-only, or miblo apps plans <id> on|off|later), granted only by the person (an AI agent cannot), "Agora não" holding the question for 7 days, every change audited (app_plans_asked, app_plans_on, app_plans_off, app_plans_later).

GET /sdk/v1/apps/<id>/plans (the app's own token and id) answers

JSON
{ "available": true, "rev": "3f9a0c1d2b4e5f60", "plans": [ {
  "id": "9b2e4f1a0c3d", "title": "Login com Google", "status": "in_progress",
  "progress": { "done": 3, "total": 7 }, "currentTask": "Tela de login",
  "running": ["Tela de login", "Testes da API"], "ai": "Claude Code", "folder": "loja",
  "updatedAt": 1760800000, "startedAt": 1760796400, "when": null } ] }
  • available: Miblo+ Pro with the plans on, on this computer. Otherwise false and no plans: the app shows a calm empty state (Planos leaves the App screen), never an error.

  • status: not_started, scheduled, in_progress, blocked (it waits on the person) or done (discarded plans are left out). progress: the tasks done and the tasks in all. running: the titles of the tasks being done now, the latest first (at most 8); currentTask: the first of them. ai: the AI's name; folder: the last folder of the plan's project (never a path). updatedAt, startedAt: epoch seconds. when, for a scheduled plan: {at} (epoch seconds) or {reset: true} (when the AI's usage limit comes back, with at when it is known); else null. id: a stable id of its own (not the plan's).

  • At most 50 plans, the most recently updated first. Titles cut at 64 characters.

  • Never exposed: the plan's text, a task's detail, notes or reasons, the plan's summary, its runs, paths, sessions, or anything else Miblo+ keeps.

  • The change event: ?wait=1&rev=<rev> is answered as soon as the answer changes from rev, or after about 20 s with the same one (as the settings' long poll; 503 busy past 16 waits). Clients: app.plans.wait(rev) and app.plans.onChange(cb) (TypeScript), app.plans(wait=True, rev=...) (Python). An app in a miblo-apps runner may simply read ctx.plans() each round.

  • Errors: 403 not_declared, 403 plans_off (+ asked), 403 not_your_app. On a Miblo before 1.42.0: 404 not_found (treat it like plans_off); ctx.plans is undefined in an older runner.

Quiet (Miblo 1.42.0): GET /sdk/v1/screen also says quiet: "focus" while a paired Miblo is in a focus round (Pomodoro), "meeting" while one is in meeting mode, else null (the Miblo Desk says it in its answer to the bridge's regular push: nothing more is asked of it; known within about 10 s). An app keeps its screen calm then: no big animation, a celebration waits until it is over. Miblo's own alerts are never affected.

This computer for apps (ctx.sys, app API 3)

Miblo 1.44.0. An app never runs a program or opens a file of the computer (the sandbox denies both); Miblo reads for it. The manifest lists what it reads in system (any of cpu, memory, battery, network, gpu, processes, containers, tcpProbe, disk, temperature, nowPlaying, notifications, calendar; "api": 3), and ctx.sys gets one function per listed name:

namectx.sys.<name>() gives
cpu{ idle, total, cores }: ms summed over the cores; diff two readings for the busy share
memory{ total, used } bytes, or null
battery{ pct, state: charging | discharging | full | ac, minutes }, null (no battery) or { status: 'failed' } (not readable this time)
network{ rx, tx }: cumulative bytes over the real interfaces, or null
gpu{ pct } or null
processes{ cpu: [{ name, pct }], memory: [{ name, bytes }] }: the top 5 each, program names only
containers{ available, reason?, containers: [{ id, name, state, code, agoSec }] } (Docker)
tcpProbe(host, port){ ms }: one TCP connection, nothing sent; 504 no_answer
disk(folder?){ total, free } bytes of the volume holding the home folder, or a folder the person saved in one of the app's settings (~/... allowed); { status: 'missing' }; another path: 403 path_not_allowed
temperature{ c, name } (°C, the CPU sensor) or { unavailable: 'mac' | 'win' | 'none' }
nowPlaying{ status: 'ok', app, playing, track, artist, position, duration, album?, art? } or { status } (macOS). art: { url } (Spotify: the cover's https address on i.scdn.co, which the app fetches: list i.scdn.co in network) or { jpeg } (Music: the cover itself, base64 JPEG of at most 48 KiB, made by Miblo with sips in a private folder it removes at once). Under the same nowPlaying consent
notifications({ since, apps }){ status, items: [{ id, appId, appName, title, subtitle, body, at }], apps? }
calendar{ status, events: [{ start, end, title, allDay, where }] } (the next 24 hours)

Never a path, a process id, a command line or raw program output. Over HTTP (any program with the app's tool name and the person's SDK token): GET /sdk/v1/apps/<id>/sys/<name> (tcpProbe ?host=&port=, notifications ?since=<ms>&apps=1) -> { name, value }; app.sys(name, params) in the TypeScript SDK; declare({ system }). At most 20 a second (burst 40).

Consent, as sessions and plans, one question per group, asked on the first read, granted only by the person (Miblo Hub, or miblo apps <group> <id> on|off|later in their own terminal; never an AI agent), audited (app_<group>_asked|on|off|later):

  • computer ("informações deste computador"): cpu, memory, battery, network, gpu, processes, containers, tcpProbe, disk, temperature (miblo apps computer <id>, POST /plus/apps/computer);

  • nowPlaying (miblo apps now-playing <id>, POST /plus/apps/now-playing);

  • notifications (miblo apps notifications <id>, POST /plus/apps/notifications);

  • calendar (miblo apps calendar <id>, POST /plus/apps/calendar).

A community app reads at most what its installed package lists (community.json system: what the store reviewed), whatever its program declares. Errors: 403 not_declared, 403 system_off (asked, group), 403 not_your_app, 404 unknown_name, 403 host_not_allowed (tcpProbe). GET /plus/apps carries systemWanted and, per group asked for, <group>, <group>State, ask<Group>, <group>Wanted.

tcpProbe reaches only a host of the app's signed network, the host of a site the person saved in one of its networkFromSettings settings, the address the person saved in one of its probeFromSettings settings, or a public resolver on 443 (1.1.1.1, 1.0.0.1, 8.8.8.8, 8.8.4.4, 9.9.9.9: "am I online?"). Every name is resolved by Miblo and the connection goes to the address it gave: a name of the signed network must lead only to public addresses (never this computer, a private, CGNAT, link-local or multicast address: its author's DNS cannot point it at the person's network); a site the person saved follows the probeFromSettings rule below.

probeFromSettings (a list of the keys of its type: 'text' settings, at most 8): tcpProbe also reaches the address the person typed there, host (any port) or host:port ([v6]:port): Rede's "how far is my router?". Only a value the person saved themselves counts (as for networkFromSettings: never an AI agent, a default or a carried-over value). Because the person chose it on purpose, a device on their own network is allowed (192.168.0.1, nas.local, a private IPv6); never this computer (localhost, 127.0.0.0/8, ::1), an unspecified, link-local (169.254.0.0/16, fe80::/10, a cloud's metadata address among them), multicast or broadcast address. A name is resolved by Miblo first, every address it gives must pass the same rule, and the connection goes to that address (a name cannot be pointed at this computer between the check and the probe). It is part of what the person agrees to: the access hash gains ;probeFromSettings=<sorted,comma> when the list is not empty.

networkFromSettings (a list of the keys of its type: 'url' settings): the Miblo fetch also reaches the site the person saved there. Only a value the person saved themselves counts (Miblo Hub's Save, or miblo apps config in their own terminal; never an AI agent, never the setting's default or a value the app carried over with migrate), only https, only a public DNS name (no IP address, localhost, .local, .lan, .internal...), and the exact host and port saved. GET /sdk/v1/apps/<id>/network -> { hosts } (the sandbox asks it itself; at most every 30 s, and again 5 s after a refusal).

fetchMaxBytes: each answer the Miblo fetch reads is capped at 256 KiB, or at the manifest's fetchMaxBytes (at most 2 MiB). ctx.fetchJson, ctx.fetchText and ctx.fetchBytes take maxBytes (at most 2 MiB) under that cap. redirect: 'manual' gets the redirect itself, never followed.

Interval: intervalMs from 3 s (it was 10 s).

The rest of a store app's ctx (the same runner as the official apps; what an official app had in its own process and a store app does not: cpuTimes/topProcess (use sys.cpu and sys.processes), calendarSystem (use sys.calendar), the raw sys.exec/readFile): lang, locale, timeOf, now, sleep, settings, state, stateDir, cached(key, ttl, fn) (its state folder), fetchJson, fetchText, fetchBytes, sys, app, alerts, screen, frame(slot, image), sessions(), plans(), log. From main.js the runner also takes options(ctx), suggest, migrate(values) and localDefaults({ timeZone, locale }) (settings defaults from the computer), and from manifest.json dwell, previews and previewBg.

Package: at most 2 MiB packed and 2 MiB unpacked, at most 64 files of .js, .json, .md and .png (a PNG at most 256 KiB, the top-level icon.png at most 64 KiB). network entries are host names, or a subdomain wildcard *.<name>.<tld> over one registrable domain (*.scdn.co: its subdomains, not scdn.co itself); never *, a public suffix (any rule of the Public Suffix List, ICANN and private: *.com.br, *.github.io, *.run.app...) or a wildcard elsewhere in the name. A name that only a local network answers (.local, .lan, .internal, .home.arpa...) or that a service turns into the address written in it (127.0.0.1.nip.io, sslip.io, localtest.me, plex.direct...) cannot be declared. Whatever the name, the runtime resolves it and connects only to a public address (never this computer, a private or link-local one, CGNAT or unique-local), to the very address it checked, on each redirect too; a name that leads anywhere else is logged as blocked.

Alert-only apps, alert types and quiet hours

Most apps only need to say that something happened. An alert-only app has no screen at all: it declares screen: false and the types of alert it sends, and the person turns each type on or off (Miblo Hub's Apps tab, or miblo apps alerts <id> <type> on|off).

JavaScript
const alerts = miblo.alerts({ id: 'placar', tool: 'Placar' });
await alerts.declare({ name: 'Placar', types: [
  { id: 'gol', label: { pt: 'Gol', en: 'Goal' }, level: 'important' },
  { id: 'fim', label: { pt: 'Fim de jogo', en: 'Full time' }, level: 'info', cooldownMin: 30 },
] });
await alerts.send({ type: 'gol', title: 'GOL do Flamengo!', text: 'Flamengo 2 x 1', key: 'jogo-123-gol-2' });

(Python: alerts = miblo.alerts("placar", "Placar"), alerts.declare(name=..., types=[...]), alerts.send(title, type=..., text=..., key=..., dedupe_min=..., when_quiet=...), try_send, test, status.)

  • Declaring (POST /sdk/v1/apps/declare): screen: false and alerts as a list of 1-8 types { id (a-z, 0-9, -; at most 16; not on, off, later, test, history), label (≤ 24, string or {pt, en}), level: 'info' | 'important' (the most the type may use), default?: true (on until the person chooses), cooldownMin?: 0-1440 }. An alert-only app has no preview. With types, the permission card lists their labels as what the alerts are about. A screen app may declare types too. Declaring again keeps the person's switches.

  • Sending (POST /sdk/v1/apps/<id>/alert) takes, besides title, text, level, seconds: type (required when the app declared types), key (≤ 64; the same key within dedupeMin minutes, 1-1440, default 10, is one alert: 200 {ok, duplicate: true, delivered: 0}) and whenQuiet: 'drop' | 'later'. The checks, in order: the permission (403 alerts_off), the type's switch (403 type_off, type), the key, quiet hours (409 quiet, retryAfter = seconds until they end; with whenQuiet: 'later' the alert is held instead, 202 {ok, queued: true}, at most 3 per app, sent one by one when they end, still under the limits), the type's cooldown (429 rate_limited, reason: 'cooldown'), then the app's limits above (429, reason: 'limit'). level is capped at the type's.

  • Testing (POST /sdk/v1/apps/<id>/alert/test {type?, title?, text?}, alerts.test()): drawn as "Teste · <title>" ("Test · " in English), before the person allowed the app's alerts too, so a developer sees it first. It keeps the type's switch, quiet hours, the 2-minute gap and 10 a day. The person's own "Testar alerta" (POST /plus/apps/alerts/test, miblo apps alerts <id> test [type]) skips all but the gap.

  • Status (GET /sdk/v1/apps/<id>/alerts, alerts.status()): `{ permission: 'granted' | 'denied'

    | 'asked' | null, types: [{ id, label, level, on }], quiet: { on, from, to, active, endsInS }, limit: { left, nextInS }, queued, last }`.

  • Quiet hours are the person's (miblo apps quiet on|off [--from HH:MM] [--to HH:MM], the Miblo Hub; default off, 22:00 to 08:00, this computer's time). They hold app alerts only, never Miblo's own.

  • History: the bridge keeps each app's last 50 attempts (<data>/apps/alerts/<id>.json: time, type, title, delivered / dropped and why / held / duplicate / test), for Miblo Hub and miblo apps alerts <id> history [--json].

  • No screen: an app that declared screen: false gets 403 alerts_only from the screen routes that draw (card, frames, animate, mode7, peek, show): it never enters the rotation and is never pinned.

  • Official-app style: in apps/lib/, a manifest with screen: false and alerts types runs like any other app, its run(ctx) returning { alerts: [{ type, title, text?, key? }] } (at most 3 a run) or calling ctx.alerts.send(); miblo-apps check manifest.json checks it. Examples: sdk/ts/examples/alerts-only.mjs, sdk/python/examples/alerts_only.py.

Community apps with code

An app from the Desk App Store may carry code written by someone else. It reaches a computer only after the Desk App 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 Desk App Store app, plus code: {sha256, url} (an https zip, at most 2 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, api: the app API level it was written for, see "Versioning") and main.js exporting run(ctx) as the official apps do (ctx: settings, state (memory only, empty after a restart), lang, locale, now(), fetchJson(url, {timeoutMs?, headers?}), fetchText(url, {timeoutMs?, headers?}) (headers: only authorization, accept, accept-language, if-none-match, if-modified-since; a non-2xx answer throws HTTP <status> from <host/path>: <redacted excerpt> with status and host), timeOf(ms), sleep(ms), stateDir, log, cached(key, ttlMs, fetcher), alerts, app, screen, frame, sessions() (1.34.0, with sessions: true), and cpuTimes() / topProcess(), which answer null in the sandbox; it returns {card, peek?, frame?, plain?, alerts?, declare?, offline?, error?}; a frame shows only under a card with bg: 'frame:N'). The Desk App Store re-packs manifest.json and drops the fields it does not know (dwell, previews). Only .js, .json and .md files, at most 64, no package.json, no links, no absolute names or ..; the Desk App Store takes a zip of at most 2 MB, 2 MB unpacked (the plugin checks the same ceilings at install); 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 2 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 Desk App 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 Desk App 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.

  • Updates (1.37.0): the same hourly check installs a newer signed version by itself when it reaches exactly what the installed one does (the same hosts, sessions and alerts), and restarts it if it is on. A version that asks for other access only shows "Atualização disponível" (updateAvailable in apps list --json and Miblo Hub) and waits for the person: the Miblo Hub's consent, or miblo apps install <id>, which shows what changed and asks y/N in the person's own terminal (never under an AI agent).

  • A card Desk app that becomes a page one waits for the firmware (1.45.0): a store version with display: "page" over an installed card Desk app is not installed while no paired Miblo Desk is known to have the remote display (firmware 1.45.0 or newer): the installed version keeps running and the new one is recorded as updateWaits (the hourly check, miblo apps install <id> and Miblo Hub's update all stop there; the command exits 1 saying "Não atualizado: a versão nova (X) precisa do Miblo Desk 1.45 ou mais novo (<nomes>); fica a atual até um deles atualizar", or in English "Not updated: the new version (X) needs Miblo Desk 1.45 or newer (<names>); the installed one stays until one of them is updated"). One Miblo Desk with the remote display lets it through at the next hourly check; a Miblo Desk whose firmware is not known holds nothing back; a fresh install and an installed page Desk app never wait. In miblo apps list --json and GET /plus/apps an app waiting has updateWaits: {version, firmwareNeeded: [{id, name}]} (null otherwise, and once a Desk has the remote display), next to updateAvailable (new access, which asks the person).

  • What it may cost (1.37.0): a 128 MB JavaScript heap (--max-old-space-size; past it the process ends and restarts with the growing wait), and a CPU guard on macOS and Linux: a process at 90% of a core or more for 2 minutes is killed and counted as a crash, so its restart wait grows (2 s, 10 s, 30 s, 2 min, then every 10 min).

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 use CPU in bursts (under the guard's 2 minutes) and memory up to its cap, 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

Miblo Desk. 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 Miblo Remote version 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.

The day's summary (miblo today, the Miblo Desk'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
Miblo Desk actions (say, focus, timer), settings/request, Connect start and DELETEbursts of 3, then 1 every 10 s, one bucket for all of them
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; conversions 1 a second, bursts of 3; 1 MiB body; 4,000,000 source pixels; 60000 bytes of image data
App screen pin (show)1 per 10 s
Peek1 per 10 min per program, 1-10 s
Request for the screen (1.29.0)5-120 s; one app at a time; again 60 s after the last hold ended; bursts of 3, then 1 per 10 s for all
Animations1 written per 2 s (bursts of 4; a kept scene played again counts as a card), and 1 decode per 2 s (bursts of 4) for every request that decodes, dry runs and .obj meshes included (the same body again is not decoded); 12 MiB body (8 MiB file, 256 frames and 24 million pixels read); the flash's 10 s / 100 a day per slot and file; one playing at a time (the current app's; the others kept for their turn)
Per-line effect parameters (mode7)1 per second, bursts of 3
Live charts2 live items per card, the same seconds, hz × seconds ≤ 180 together; hz 1–30, seconds 1–5; push 60 a second (coalesced), flushed every 500 ms or 32 samples; 1-128 samples a request, t ≤ 60 s, 10 requests a second (bursts of 20); one batch (≤ 1 KB) per Miblo Desk per seconds
Alerts1 per 2 min and 20 a day per app, 1-15 s; title / text 24 / 40 characters
Apps64 per computer, 12 settings each; settings long polls: 16 at once, 20 s each

The limits are the bridge's, for every program together: each bucket is shared by every program and every enabled official app on this computer, so a rate_limited can come from someone else's traffic. Wait retryAfter seconds. The Miblo Desk 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)
426upgrade (+ min, message)the request has no x-miblo-sdk-api, or one below minApi: install SDK 2.x from miblo.ai
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
403alerts_off (+ asked)an alert from an app the person has not allowed (asked: they were asked now or before)
403type_off (+ type)an alert of a type the person turned off
403sessions_off (+ asked)a sessions read from an app the person has not allowed
403not_declareda sessions read from an app whose manifest has no sessions: true
403alerts_onlya screen write from an app that declared screen: false
409quiet (+ retryAfter)an alert in the person's quiet hours (send it with whenQuiet: 'later' to hold it)
404unknown_appno such app declared or installed
409too_many_apps64 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; 16 settings waits at once; a frame or animation body already being read
413too_large (+ max)body over 4096 bytes (a declare 512 KiB, a frame 1 MiB, an animation 12 MiB; unsigned answer)
415content-type must be application/jsona POST without JSON
429rate_limited (+ retryAfter, retry-after header; an alert's reason: limit or cooldown)over a limit above
500internala bug: please report it

The screen, animation, live chart, alert and Connect routes add their own codes in their sections above (slot_taken, no_card, app_off, anim_taken, no_free_slots, no_animation, live_too_many, not_connected...).

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, unverified_answer and bridge_outdated (hello answered an api below the client's: update Miblo on the computer, miblo update). A not_authorised from hello also covers a bridge from a Miblo release that predates this API.

Versioning

  • SDK API levels. Every request says the SDK API it speaks (x-miblo-sdk-api), and hello reports the bridge's api (the level it speaks) and minApi (the oldest it accepts). The paths stay /sdk/v1/. Level 1 was the SDK before 1.30.0 (no header); level 2 came with Miblo 1.30.0, and since then minApi is 2: every 1.x client is answered 426 upgrade.

  • What needs what. SDK 2.x (@miblo/status 2.x, miblo-status 2.x) needs Miblo 1.30.0 or newer on the computer; the bridge drives only a Miblo Desk on firmware 1.30.0 or newer (an older one gets nothing but its update). Reading the person's AI sessions from an app needs 1.34.0. An SDK newer than the bridge gets bridge_outdated from its own hello check (miblo update).

  • Within a level fields are only added, optional in requests and extra in answers. Clients ignore fields they do not know, and the bridge ignores unknown request fields. A change that breaks a client raises the level (SDK_API and MIN_SDK_API in plugin/lib/min-version.js).

  • The packages follow semver: their major version is the SDK API level they speak (2.x speaks 2).

Apps: the app API level

An app receives the SDK at run time, never bundled: run(ctx) gets the ctx, the client in it and the bridge behind them from the Miblo it runs on. So what apps count on only grows, and an app's manifest.json says the app API level it was written for: "api": 2 (the plugin's lib/min-version.js APP_API, today 5 (1.44.0, level 3: system, networkFromSettings, fetchMaxBytes, ctx.fetchBytes, the 3 s interval, options/suggest/migrate for store apps; 1.45.0, level 4: page Desk apps, display: "page"; 1.46.0, level 5: page Desk apps with the render profiles, render, and the safe update; the oldest still run is 2). A page Desk app should declare "api": 5 (4 is still accepted, render is valid at both): a Miblo Hub before 1.46 then reads a level above its own, refuses the version and keeps the one it has, instead of installing a page over a working card version without 1.46's safeguards. An app that uses nothing of level 3 may keep "api": 2. miblo apps new writes the newest level the Desk App Store takes too (STORE_APP_API).

  • Additive, no new level: a new ctx member, a new optional field or setting type, a new card item or effect, a looser limit. Every installed app keeps working.

  • Bumps the level (APP_API and MIN_APP_API together): anything removed or renamed, a changed meaning or behaviour, a stricter limit.

  • Below MIN_APP_API ("needs an update from its author" / "precisa ser atualizado pelo autor"): miblo apps install refuses it, an installed copy never starts (state outdated, apiProblem: "old" in GET /plus/apps and apps list --json, said in apps list, apps show, Miblo Hub's Apps tab and the phone), and the Desk App Store refuses a submission and leaves the listing out of its lists (its package is still served, so the plugin says why).

  • Above APP_API ("needs a newer Miblo"): not installed, not run (state too_new, apiProblem: "new"); miblo update brings the newer Miblo.

  • No api: an app published before the field (2026-10-12) counts as level 2 (APP_API_UNDECLARED, frozen) and runs as before. From then on miblo apps new writes it, and miblo apps check, the simulator and the Desk App Store require it.

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 Miblo Desk 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 Miblo Desk: 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 effect numbers, a mesh within its caps — checked by the bridge and again by the Miblo Desk, 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, and count every frame's own pixels against one budget of 24 million (a tiny canvas with huge frames, a list of PNG bombs, a sheet of 1x1 cells); each decode takes the decode rate limit before any byte is decoded. 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 Miblo Desks' 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: Miblo Hub does that with the bridge key.

  • Anything a Miblo Desk's pairing token gates: the bridge talks to the Miblo Desks 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.

Miblo Desk assets (GET /api/assets, firmware 1.30+; internal)

Not part of the Status API, and nothing a program needs: a Miblo Desk route, for the settings page, miblo and anyone paired (a paired computer's token, or the page's web session). Since 1.30 the Miblo Desk keeps part of its firmware's data on its data partition and fetches it from miblo.ai by itself (strings of the languages other than English and Portuguese, the Latin font pack, the settings page's extra scripts): docs/miblo-memory.md "O que vive na flash e o que vive nos dados".

GET /api/assets
{"fw":"1.30.0","gen":1760000000,"state":"done","have":7,"total":7,"bytes":88276,
 "fs":{"used":131072,"total":1024000},"lastCheck":1760191000,"lastError":"","catalog":true,"picked":0,
 "items":[{"name":"strings-de.bin","kind":1,"id":6,"size":7928,"installed":true,"current":true,"listed":true,
           "required":true,"wanted":true,"at":1760191000,"from":"miblo.ai","verified":true,
           "title":"Idioma: Deutsch","reason":"usado pelo idioma do aparelho"}, ...]}

state: idle, checking, downloading, done, no_internet, failed. lastError: dns, connect, timeout, http, too_large, catalog, signature, rollback, sha256, no_space, storage, memory or "". kind: 1 strings (id = the language), 2 the page bundle, 3 a font pack (id = the script: 0 Latin, 1 Cyrillic, 2 Greek, 3 CJK, 4 Arabic, 5 Hebrew, 6 symbols). required: needed by the Miblo Desk's language or the page (shown with a lock, never deleted); wanted false: the person deleted it, or nothing needs it.

  • POST /api/assets/remove {"name":"strings-fr.bin"} → {"ok":true}; 409 {"error":"required", "reason":"..."} for a required one; 404 when unknown. A deleted resource is not fetched again unless the person picks it or the language changes.

  • POST /api/assets/fonts {"pack":"cjk","on":true} → {"ok":true,"picked":8}: an extra font pack picked (fetched on the next pass) or unpicked (removed).

  • POST /api/assets/check {} → {"ok":true}: a check soon (the bridge may nudge; nothing depends on it).

GET /api/info carries a summary for paired callers: assets: {gen, have, lastError} (the verified catalogue's generation, 0 before one; the files installed; the last pass's error name, as lastError above). The full list stays here (it reads the flash).