SDK
Miblo Status API v1 reference
The complete Miblo Status API v1 reference: authentication, routes, screen, apps, limits, errors, versioning and security, as the product's contract says.
The reference below is the Status API v1 contract, copied from the product repository (docs/status-api.md) every time the site is published, so it never falls behind. The guide pages explain each part with examples.
Your own programs can show their status on Miblo: a build script, a CI watcher, a data job, an AI tool Miblo has no hooks for. Each one shows up as a session on the gadget and the phone (working, needs you, idle, done), and can put a short message on the gadget or start a focus session or a timer, the same ones the miblo CLI starts.
It does status and nothing else. The owner's rule: Miblo never interferes with the AI. Approvals, replies and tasks are not part of this API, and nothing in it can turn them on.
Server: the Miblo bridge,
plugin/lib/sdk-api.js(wired inplugin/bin/bridge.js).Clients:
sdk/ts(@miblo/status, Node 20+, no dependencies) andsdk/python(miblo-status, Python 3.9+, standard library only). Both follow this document. Any other language can too.CLI:
miblo sdk token [--rotate] | sdk off | sdk status | sdk start.
Quick start
export MIBLO_TOKEN=$(miblo sdk token) # turns the API on and prints its tokenimport { 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');from miblo_status import MibloStatus
job = MibloStatus().session("deploy.py", title="Deploy")
job.needs_you("confirm the deploy")
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, andGET /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 tokenmakes<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 buthellois 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 --rotatereplaces 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 offdeletes it.Programs get it from the person, explicitly (
MIBLO_TOKEN=$(miblo sdk token) ./job), not by looking for a file. The clients readMIBLO_TOKENor 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.
GET /sdk/v1/hellowithx-miblo-nonce: <32 hex>(fresh, random). The answer is200 {"ok":true,"app":"miblo-bridge","api":1,"sdk":"on","version":"<Miblo>"}with:x-miblo-sdk-proof: HMAC-SHA256(token, "miblo-sdk-hello:" + nonce)(hex). Check it: a listener that cannot prove it knows the token is not Miblo. Send it nothing.x-miblo-sdk-challenge: <32 hex>: single use, valid for 10 s. Missing when the bridge hands out too many: wait 250-500 ms and ask again (the clients retry twice).With the API off:
{"sdk":"off"}and no proof.
The request carries
x-miblo-sdk-auth: <challenge>:<mac>, wheremac = HMAC-SHA256(token, "miblo-sdk-req:<challenge>:<METHOD>:<path>:<hex SHA-256 of the body>"). The body is the exact bytes sent (empty forGET). The challenge is spent whatever the outcome.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_answerotherwise). One exception: a body over 4096 bytes (a declare: 512 KiB; a frame: 1 MiB) is answered413unsigned, before the request can be checked. The clients refuse such a body before sending it.
Endpoints
| Method and path | Body | Answer |
|---|---|---|
GET /sdk/v1/hello | see 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/snapshot | 200 {api, sessions, gadgets} | |
POST /sdk/v1/screen/card | a card (at most 1024 bytes) | 200 {ok, delivered, gadgets, app} |
PUT /sdk/v1/screen/frames/<0-3> | an MFRM1 file (application/octet-stream), or {png} / {rgba, w, h} / {mfrm} in base64 JSON | 200 {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/screen | 200 {ok, delivered, gadgets} | |
GET /sdk/v1/screen | 200 {card, frames: [{slot, bytes, at}], shown, owner} | |
POST /sdk/v1/screen/peek | {seconds?: 1-10} | 200 {ok, seconds, delivered, gadgets} |
POST /sdk/v1/screen/animate | {source, options} (JSON, base64) or multipart/form-data | 200 {ok, plan, warnings, delivered, gadgets} |
POST /sdk/v1/screen/mode7 | {fx, speed?, amplitude?, zoom?, period?, centre?, region?, dir?} | 200 {ok, mode7, warnings, delivered, gadgets} |
POST /sdk/v1/screen/stop | {} | 200 {ok, stopped, delivered, gadgets} |
GET /sdk/v1/screen/preview[?step=N&ms=M] | 200 {png (base64), kind, step, steps, exact, gap} | |
POST /sdk/v1/apps/declare | {id, name, settings?, category?, promise?, preview?} | 200 {ok, id} |
GET /sdk/v1/apps/<id>/settings[?wait=1[&rev=<rev>]] | 200 {id, values, missing, rev} | |
POST /sdk/v1/apps/<id>/settings/request | {keys?, reason?} | 200 {ok, needsSetup} |
GET /sdk/v1/apps/<id>/suggest?key=<key>&q=<text> | 200 {id, options: [{value, label}]} | |
GET /sdk/v1/apps/<id>/suggest/next | 200 {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>/token | 200 {provider, accessToken, expiresAt, account} | |
DELETE /sdk/v1/apps/<id>/connect/<provider> | 200 {provider, ok} |
The screen routes are described under Screen and Animations.
Sessions
tool(required): your program's name, 1-20 characters, one line. It cannot be a name the gadget reads as a Claude Code tool (Bash,Edit,Read,Task,Agent...), and it cannot start with_.title: the session's name on the gadget (up to 64 characters; the gadget shows 20). Without it, the folder ofcwdnames the session; without either,tooldoes. Two sessions with the same name get " 2", " 3"... as Claude Code's do.state:working(default) |needs_you|idle|done.needs_youanddonealert on the gadget, once per change, as a Claude Code question or a finished turn does.detail: one short line (up to 64 characters; longer is cut, control characters are removed). It is the activity while working, and the reason while it needs you.pid: the process whose exit ends the session (the clients send their own by default). Without one, a session expires after 12 h without an update (2 h once idle or done), as a coding agent's does.id: made by the bridge,sdk-+ 32 hex. Only these ids are accepted, so a program can never name, change or end a coding agent's session (unknown_session).
The sessions survive a bridge update (the handover) but not a computer restart.
Gadget actions
They work like miblo say, miblo focus and miblo timer (plugin/lib/daily-cli.js), with the same limits the firmware applies: a message of at most 40 characters and 47 UTF-8 bytes on one line, minutes 1-480 (default 30); focus 5-120 min, break 1-60, rounds 1-12; timer 1-180 min. Each paired gadget answers for itself: gadgets: [{id, name, ok, error?}], where error is offline | unpaired | unsupported | rejected | busy. delivered counts the gadgets that took it. With no Miblo paired, it is 0, which is not an error: the gadget is optional. A message cannot start like one of Miblo's own notices ("Approved:", "Phone:", "Miblo:", "Allow?"... in English and Portuguese, any case). Nothing goes to the desktop notifications.
Snapshot (read-only)
sessions: [{id, name, state, tool, sdk, since}] and gadgets: [{id, name, online}]. A coding agent's session shows by its 8-character id and its agent's name ("tool": "Claude Code"). It never shows a command, a file, a prompt, a cost, a limit or anything of Miblo+. since is epoch seconds.
Screen
Miblo 1.25 adds an App screen to the gadget's rotation (next to Overview, Limits and Sessions) that your program designs. The design and the file format are in screen-sdk-architecture.md; this section is the API.
const screen = new MibloStatus().screen({ tool: 'steps' });
await screen.frame(0, fs.readFileSync('background.png')); // once: a designed background
await screen.layer(0, { v: 1, title: 'Passos', items: [{ t: 'big', value: '8.432', label: 'hoje' }] });
await screen.show(true); // pin it (or: miblo mode app)screen = MibloStatus().screen("steps")
screen.frame(0, open("background.png", "rb").read())
screen.layer(0, {"v": 1, "title": "Passos", "items": [{"t": "big", "value": "8.432", "label": "hoje"}]})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'stool). The App screen always draws it, and it says who holds the screen. The clients send it for you (screen({ tool })).Each program its own screen. A program's screen is its declared app's (see Apps below), else its tool name's. The App screen shows one at a time and the bridge moves to the next every 15 s (
miblo apps rotation <seconds>;0: only the pinned one).screen.clear()(DELETE /sdk/v1/screen) takes your screen off;miblo screen cleartakes 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_takenwithownerfor another program).Card (
POST /sdk/v1/screen/card):{v: 1, title?, items: [1-4], icon?, bg?}; itemsbig(valueup to 10 characters,label),ringandbar(value0..1),text(40 characters),spark(values: 2-24 numbers),row(exactly 2 of the others).labelis up to 16 characters,title24.color:amber,blue,green,red,whiteorgrey.bg: "frame:N"draws the card over frame slot N (a layer). Strings are cleaned (invisible, bidi and control characters removed) and cut; one that starts like Miblo's own notices is refused; an unknown item type is refused; unknown fields are dropped. The card lives in the gadget's RAM (a restart clears it; the bridge sends it again within 15 minutes) and is dropped after 24 h without a refresh.Frame (
PUT /sdk/v1/screen/frames/<slot>): a 240x240 image. Send a PNG (8-bit RGB or RGBA, not interlaced, any size up to 4096 a side: it is fitted into 240x240 keeping its proportions), raw RGBA pixels ({rgba, w, h}), or a ready MFRM1 file. The bridge converts it to at most 64 colours and checks it. The body may be up to 1 MiB (one frame upload is read at a time:503 busyotherwise). 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_limitedwithretryAfter, checked before anything is converted or sent). Change the number on a card instead: a layer redraws without writing flash.Delivery. Like
say, every paired gadget answers for itself ingadgets, whereerrorcan also berate_limited(the gadget's own wear limit). A gadget that missed a change (offline, busy) is brought up to date by the bridge later on its own. A Miblo on a firmware before 1.25 answersunsupported.Pin (
POST /sdk/v1/screen/show {on}): your screen stays on the App screen (no rotation) and the gadget keeps the App screen, asmiblo mode app;{on: false}unpins it. Once per 10 s.Read (
GET /sdk/v1/screen): your screen: its card (withtool), your frame slots (atin epoch seconds),shown(pinned),current(on the App screen now) androtation.The phone and the desktop app get the card and its background reference in their snapshot (
screen: {card, bg, shown}); the desktop app also reads the frames, with the bridge key (GET /sdk/v1/screen/frames/<slot>is not open to programs).
Peek (
POST /sdk/v1/screen/peek {seconds}): the App screen on top of the rotation for 1-10 seconds (default 10), unless the Miblo is alerting. For the moment that matters (bitcoin moved 5 %, the build failed): once per 10 minutes per program, and at most one every 10 s (bursts offor all programs together (
429withretryAfter). It needs a card (409 no_card). It brings your screen forward first, unless another one is pinned.
The screen's own errors:
| Status | error | When |
|---|---|---|
| 400 | bad_request (field: tool, v, items[0].value...) | a card or name the rules refuse |
| 400 | bad_frame (+ message) | not a PNG / MFRM1 the converter takes (e.g. an interlaced PNG) |
| 409 | slot_taken (+ owner) | that frame slot is another program's |
| 409 | no_card | a peek before any card |
| 409 | too_many_screens | the bridge already holds 64 program screens: clear one (screen.clear(), miblo screen clear); a screen nothing wrote for a day goes by itself |
| 413 | too_large (+ max) | a card over 1024 bytes, a frame body over 1 MiB, an MFRM1 over 60000 bytes of image data |
| 415 | unsupported_media_type | a frame neither application/octet-stream nor JSON |
| 422 | too_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) |
| 429 | rate_limited (+ retryAfter) | cards (2 a second, bursts of 5), frames (the wear limit), show (1 per 10 s) |
Animations
Miblo 1.26 plays animations from its own flash: the program sends a file once, the gadget plays it on its own (nothing streams). Three levels, nothing else to learn:
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;orbittakesspeed, 0-360 deg/s), andpulse,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,flatneeds faces, one mesh per card, and the card's 1 KB: about 16 vertices; the SDKs also take{vertices, faces}).screen.animate('rain.gif', { fps }): the bridge does everything.The same call with options:
fps,loop(default true),region: [x, y, w, h](where on the 240x240 screen; outside it the App screen's background), and for a single picture a Mode 7 effect:fx: 'rotate' | 'zoom' | 'perspective' | 'wave' | 'scroll'withspeed(-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|downor 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);screen = MibloStatus().screen("rain")
r = screen.animate("rain.gif", fps=10) # the file's bytes are sent; the bridge decodes them
screen.mesh("cube", shading="wire")
screen.stop()What source may be: an animated GIF (LZW, local palettes, transparency, interlacing, frame disposal and delays all read), an APNG or a PNG, a list of PNGs, a sprite sheet {sheet, cell: [w, h] | n, count}, or a mesh ({mesh}; a path to an .obj is read by the client and reduced by the bridge: centred and scaled to -1..1, the nearest vertices merged until it fits a card, with a warning). On the wire: JSON {source: {gif | apng | png | bytes: base64} | {pngs: [base64]} | {sheet: base64, cell, count} | {mesh}, options: {...}}, or multipart/form-data with a file part (several: a list of PNGs), sheet or obj, and an options part (the same JSON). The body may be up to 12 MiB (8 MiB of file); one is read at a time (503 busy). options.dryRun: true answers the plan and warnings without sending anything.
What the bridge chooses (the program never sees tiles, slots or steps):
every frame fitted into the region, repeated frames merged, one palette of at most 64 colours for all frames (median cut; the Miblo palette's colours kept exactly);
tiles when the animation needs at most 256 different 8x8 tiles: tiles deduplicated across frames and horizontal/vertical flips, at most 8 sub-palettes of 16 colours (a tile with more is drawn with the nearest), one map per distinct frame (at most 16), and small objects that move (changed regions of at most 32x32 seen in two frames or more) lifted out as sprites when that saves tiles; a few tiles over 256 are merged with their nearest. Up to 30 fps;
rectangle frames when the tiles do not fit but only a small part changes (at most 120x120 pixels): one full frame under the card, then the changed rectangle per frame. Up to 10 fps;
whole frames otherwise (a photo-like animation): at most 8 frames at 4 fps, said in the warnings. A frame too detailed even then (over 60000 bytes after its reduction) is refused.
the steps come from
fps, else from the file's delays (a frame without one: 100 ms); a faster rate than the kind holds is lowered and said ("pesado demais para 20 fps: 4 fps ou uma região menor").
Plan and warnings. plan: {kind: 'tiles' | 'mode7' | 'rect' | 'frames' | 'mesh', fps, loop, region, sourceFrames, sourceColours, frames, colours, tiles?, sprites?, maps?, rect?, bytes, mode7?}. warnings: [{code, en, pt}], codes: reduced (frames and/or colours kept), fps_capped, whole_frames, rect_frames, tiles_merged, colours_near, zero_delays, cut_source (more than 256 frames in the file), heavy, slots_short, clamped (a Mode 7 value out of range), mesh_reduced.
One animation at a time on the bridge (the gadget keeps one tile set and one animation): it plays while its program's screen is the one on the App screen, and is replaced by the same program's next animate. Another program gets 409 anim_taken with owner. Its files go to free frame slots (4–7 first, never a slot a program wrote: a tile set and its animation take two, whole or rectangle frames one each); screen.stop() gives them back. The same file sent again writes nothing. Like a card, an animation nothing refreshed for a day goes.
Delivery is the frames' (chunked uploads, every gadget brought up to date later when it was away). A Miblo on 1.25 keeps getting your card and frames 0–3 without what it cannot draw (no fx, a mesh shown as the text "Miblo 1.26", no animation) and answers unsupported in gadgets.
Preview (GET /sdk/v1/screen/preview?step=N&ms=M, screen.preview(), miblo screen preview [file]): a 240x240 PNG of what the gadget would show of your animation at step N (Mode 7: at ms), drawn by the bridge's own renderer with the firmware's rules (tiles and sprites match the firmware's own sample pixel for pixel; Mode 7 mirrors its integer math, exact: false until a shared fixture proves it). Its gap until the firmware's screenweb WebAssembly exposes the 1.26 player: the card's items and header band are not drawn. miblo screen preview rain.gif --fps 8 -o rain.png converts a file on the computer without the bridge and prints the plan and the warnings.
The animations' own errors:
| Status | error | When |
|---|---|---|
| 400 | bad_source (+ message) | not a GIF, PNG or APNG the decoders read (a damaged LZW stream, a bad chunk) |
| 400 | bad_region / bad_fx / bad_option (+ field) | an option out of its range |
| 409 | anim_taken (+ owner) | another program's animation is playing |
| 409 | no_free_slots | every frame slot is held by programs |
| 409 | no_animation | mode7 before a picture was animated |
| 404 | no_animation | a preview with no animation of yours |
| 413 | too_large (+ max) | a body over 12 MiB; a file over 8 MiB, a canvas over 4096 a side or 1 048 576 pixels |
| 415 | unsupported_media_type | neither JSON nor multipart/form-data |
| 422 | too_detailed (+ message, messagePt) | too detailed even as whole frames, or too detailed for Mode 7 (over 256 tiles) |
| 429 | rate_limited (+ retryAfter) | the flash was written moments ago (each slot and file: once per 10 s, 100 a day), or more than 1 animation per 5 s |
Every refusal carries message (English) and messagePt (Portuguese) in plain words.
Apps: settings and declaring
A program can be an app: it tells Miblo its name and the settings it needs, and the person sets them in the Miblo app's Apps tab (or miblo apps config <id> key=value), never in a file or an environment variable. The official apps (miblo-apps, the repository's apps/ folder) use exactly these routes.
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 onceWho 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 gets403 not_your_app. An app installed from a package belongs to the package's name. At most 32 apps per computer (409 too_many_apps).Declare (
POST /sdk/v1/apps/declare):id(a-z, 0-9, -; at most 48),name(a string or{pt, en}, 24 characters),settings(below), and for the Apps tabcategory(a short name),promise(one line,{pt, en}, 100 characters) andpreview(a card with sample data). Declaring again replaces the schema and keeps every saved value still valid under it.Settings schema: at most 12, each
{key, label, type, default?, required?}withtypetext(max1-200, default 100;suggest, below),number(min,max),select(options, below),toggleorurl(http/https only), orconnect(a "Conectar Google/Microsoft" button, Connect). Any setting may carrygroup: 'advanced': the Miblo app folds it under "Avançado". No secrets: there is no secret type, and a key named like one (token,password,api_key,secret...) is refused. A default must itself be valid.Selects (docs/screen-sdk-architecture.md "Dynamic selects in settings"):
optionslists 1 to 2000 choices, each a string or{value, label}(valueat most 64 characters;labela 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 mostmaxdistinct 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:optionsis 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_requestotherwise; 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) holdoptionsas the values,labels({value: {pt?, en?}}),max, and for a dependent selectdependsOnandoptionsBy({parent value: [values]}).Suggestions (type-ahead): a text setting declared with
suggest: 'city'or'free'gets type-ahead in the desktop app's form, answered by the running app. The app registers a handler (app.settings.onSuggest(key, async (q) => [{value, label}]); Pythonon_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,value100 andlabel60 characters). Whoever asks (GET .../suggest?key=&q=, any program with the token; the desktop app'sGET /plus/apps/<id>/suggest?key=&q=) gets the app's options within 2 s, else[], and[]at once when the app is not polling orqis under 2 characters. A key withoutsuggestis400 bad_request.Read (
GET .../settings):values(defaults merged with what the person saved; a setting with neither isnull),missing(required settings without a value) andrev(changes when the values do). With?wait=1the answer waits until the values change (fromrevwhen given, else from now), at most 20 s; 16 waits at once at most (503 busy).Ask (
POST .../settings/request {keys?, reason?}): the person is asked now. The Miblo app shows a setup card with the form and the reason (80 characters, never starting like Miblo's notices), the phone says "Configure o app <name> no computador", and the app's own screen shows "Configure no app Miblo" in its card's place. It stays asked until the values change; an app whose required settings are missing always shows as needing setup.The values stay on this computer (
<data>/apps/<id>.json, 0600). The snapshot the phone and the Miblo app get listsapps: [{id, name, needsSetup, reason, keys}].
Running it for the person. miblo apps add "<command>" [--cwd <dir>] (from your own terminal; an AI agent cannot) registers your program; miblo apps enable <id> then keeps it running: Miblo starts it with MIBLO_TOKEN, MIBLO_PORT and MIBLO_APP_ID set (an official app also gets MIBLO_APP_STATE, its folder <data>/apps/<id>/state, where ctx.cached keeps its lists), restarts it when it exits (2 s, 10 s, 30 s, 2 min, 10 min), starts it again with Miblo, and keeps its output (miblo apps logs <id>). The official apps run the same way (miblo apps enable processador).
miblo apps enable <id>starts the bridge when it is not running. While any app is enabled the bridge stays up (it never exits for being idle).miblo apps start(the Miblo app at launch) starts the bridge and the enabled apps, and prints nothing when none is enabled.miblo apps remove <id>stops one app, takes its screen and frame slots off, forgets a program added withapps addor an installed screen package, and deletes its settings.miblo apps list --jsonanswers{rotation, when, idleAfterS, gadgets: [{id, name, supportsWhen, when, idleAfterS}], apps: [{id, name, names, kind, enabled, state, needsSetup, reason, keys, schema, values, preview, category, promise, ...}]};rotationis the seconds each app's screen shows (miblo apps rotation <seconds>);whenandidleAfterSsay when the gadgets show the App screen (miblo apps when, below);supportsWhenis false for a gadget whose firmware cannot apply it (nowhenin itsGET /api/screen), null while it has not answered; each gadget'swhenandidleAfterSare what it holds now (null when not known). The desktop app'sGET /plus/appscarries the same fields.miblo apps when [rotation|idle|manual] [--idle-after <seconds>]:idle(default) shows the apps only when no AI session has been running, waiting or needing you foridleAfterSseconds (default 60, 10-3600) and takes them away when one starts;rotationgives the App screen its turn whatever is going on (the 1.25.0 behaviour);manualshows it only when pinned or peeking. Alerts always come first. The gadget applies it: after a choice here (or in the desktop app) the bridge sendsPOST /api/screen/rotation {dwellS, when, idleAfterS}(the gadget's owndwellSkept) once to every paired gadget whoseGET /api/screenanswerswhen, with the same retries as the card. Otherwise it only reads: a choice made on the gadget's settings page stays, also across bridge restarts. A gadget on firmware 1.25.0 keeps the rotation:apps whenandmiblo screen statusname it ("Amon: update its firmware to choose when the apps show"). With no argument it prints the current choice.Every app gets
MIBLO_LANG(ptoren) unless the bridge's environment has one: the bridge's own detection, which also reads macOS'AppleLanguagesand the runtime's locale, so an app speaks the person's language even when the bridge runs withLANG=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 (andchoices, how many it has); the previews are dropped first, then the schemas and values, then apps.
Connect
An app can read the person's own Google or Microsoft data (the official Agenda reads their calendar) without ever holding a password or a refresh token: the bridge signs the person in, with OAuth 2.0 Authorization Code + PKCE as a public desktop client, and hands the app short-lived access tokens.
const app = miblo.app({ id: 'agenda', tool: 'Agenda' });
await app.declare({ name: 'Agenda', settings: [
{ key: 'google', label: { pt: 'Conectar Google', en: 'Connect Google' }, type: 'connect', provider: 'google',
clientId: '<your desktop client id>.apps.googleusercontent.com', clientSecret: '<Google\'s installed-app secret>',
scopes: ['https://www.googleapis.com/auth/calendar.readonly'],
help: { pt: 'Os eventos aparecem no Miblo em até 30 s', en: 'Events show on Miblo within 30 s' } },
] });
// The person presses "Conectar Google" in the Miblo app (or your program opens this itself):
const { url } = await app.connect.start('google');
// Once connected (settings.values.google is the account, e.g. "ana@example.com"):
const { accessToken, expiresAt, account } = await app.connect.token('google');
await fetch('https://www.googleapis.com/calendar/v3/users/me/calendarList', { headers: { authorization: `Bearer ${accessToken}` } });
await app.connect.off('google'); // disconnectapp = miblo.app("agenda", "Agenda")
url = app.connect.start("google")["url"]
token = app.connect.token("google")["accessToken"] # MibloError "not_connected" until connected
app.connect.off("google")The
connectsetting declares what to sign in to:provider(googleormicrosoft, one setting each),clientId(your OAuth client: a Google "Desktop app" client or an Azure public client),scopes(1-8), Google'sclientSecretwhen it has one (Google issues one to desktop clients and documents it as not confidential; never for Microsoft), an optionalhelpline (80 characters, under the button). It is neverrequiredand has no default: its value is the account connected (the id_token's email, else itspreferred_username),''until then, and only the bridge writes it (settings.request, the CLI and the Miblo app's Save refuse it). Sosettings.wait()wakes your program the moment the person connects or disconnects.startanswers 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 itsstate, exchanges the code with the PKCE verifier (S256), and checks the id_token'snonce, audience, issuer and expiry (openid emailare added to your scopes for that, andoffline_accessfor Microsoft; Google getsaccess_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).tokenanswers an access token with at least a minute left, refreshing it when needed (a refresh token the provider rotates is kept).404 not_connecteduntil 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.DELETEforgets the account on this computer (the person may also revoke it at myaccount.google.com or account.microsoft.com). Removing the app (miblo apps remove) does the same.Who. Like the settings: only the program that declared the app (
403 not_your_app).Where the tokens live.
<data>/apps/<id>/connect-<provider>.json(0600) holds the account and the refresh token sealed with AES-256-GCM under a key derived from the bridge key (HMAC-SHA256(bridge key,miblo-app-connect-v1|<id>|<provider>), bound to the app and provider): the file alone opens nothing, and nothing goes to miblo.ai. Access tokens are kept in the bridge's memory only.The Miblo app's Apps tab draws each
connectsetting as a button ("Conectar Google", then "Conectado como … · Desconectar"); from a terminal:miblo apps connect <id> google|microsoft(opens the browser) andmiblo apps disconnect <id> google|microsoft. The desktop routes:POST /plus/apps/connect {id, provider}->{ok, url},POST /plus/apps/disconnect {id, provider}.
Community apps with code
An app from the Miblo store may carry code written by someone else. It reaches a computer only after the store's deterministic scan, an AI review and a human's approval (docs/screen-sdk-architecture.md, "Community apps with code"), and then runs in Miblo's sandbox.
The package. GET https://miblo.ai/apps/<id>/package.json is the signed app package as for any store app, plus code: {sha256, url} (an https zip, at most 1 MB) and network: [hosts] (the only hosts the app may reach; [] means offline). The apps key signs the prefix miblo-app-package/1\n followed by the canonical JSON of {v, id, version, card, settings, frames, code, network} (keys sorted, no whitespace; code and network left out of a package without code, so older packages verify unchanged). The site must sign exactly this shape.
The zip. Plain Node ESM, no dependencies: manifest.json (id, name {pt, en}, category, promise, settings, network, intervalMs 10 s-24 h, preview) and main.js exporting run(ctx) as the official apps do (ctx: settings, state, lang, locale, now(), fetchJson(url), fetchText(url), timeOf(ms), sleep(ms), stateDir; it returns {card, peek?}). Only .js, .json and .md files, at most 64, no package.json, no links, no absolute names or .., 4 MB unpacked at most; manifest.json's network, when present, must equal the signed list.
miblo apps install <id> verifies the package's signature (APP_PACKAGE_KEYS), downloads the zip over https (no redirects, never read past 1 MB), checks its SHA-256 against the signed package, checks every entry, and unpacks it whole into <data>/apps/<id>/pkg (a new version replaces the old folder in one step and restarts the app if it is on). <data>/apps/<id>/state is its own writable folder. Then apps enable|disable|show|logs|config|remove work as for any app; apps list and apps show call it a community app and name its hosts; apps remove deletes its folder.
The sandbox. The supervisor starts it as
node --permission --allow-fs-read=<its pkg, its state, the Miblo runtime> --allow-fs-write=<its state>
[--allow-net] --import <plugin>/lib/app-sandbox-hooks.js <plugin>/lib/app-sandbox-main.js <id> ...with the same environment allowlist as any app plus MIBLO_APP_NETWORK (the signed hosts).
Node's permission model: it reads only its package, its state folder and the Miblo runtime, writes only its state folder; no child processes, worker threads, native addons or WASI (each needs an
--allow-*flag the sandbox never passes). It needs Node 22.15+, 23.5+ or 24+; on an older Node the app does not run at all (stateunsupported, a line in its log saying why). On Node 25+, which gates the network under--permissiontoo,--allow-netis 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, forimportandrequire): 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 excepthttp,https,http2,net,tls,dgram,dns,child_process,cluster,worker_threads,vm,module,wasi,inspector,repl,v8,trace_eventsandsqlite. 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 onglobalThisis guarded with the same hosts;WebSocket,EventSource,XMLHttpRequest,process.getBuiltinModule,process.bindingandprocess.dlopenare 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>inmiblo apps logs <id>, which counts them;apps showsays how many.The store's word stands: at
miblo apps start, at the bridge's start and every hour, each community app's package is fetched again; one the store no longer serves (404, 410) or markshiddenis turned off with a line in its log and is not started again until it is served again. A network error changes nothing.
What it does not guarantee. The network is held only through the replaced fetch and the module hook (Node's permission model has no per-host rule), not by the operating system: a flaw in Node or in the hook would open it. An app can still compute as much as it likes (CPU, memory), read what Node gives every program (the time, the locale, os details such as the host name) and send that to its declared hosts. The human review before a version is published is the first gate; the sandbox is the second.
What shows where
Gadget. The session is listed with the others, by title. While working its line reads <tool> · <detail>. While it needs you, it shows as a question (a question alert, the "needs you" counter). Done shows as finished (the finished alert). Meeting and discreet modes hide the detail as they do for every session. Nothing changes in the firmware: the snapshot row is a normal session row with tool set to the program's name and an extra "src":"sdk", which the firmware does not read.
Phone. The phone gets the same row. A phone app that knows src labels the session "Outra ferramenta: <tool>" ("Other tool: <tool>"). An older one shows it as any other session, with <tool> <detail> as its activity. It is never offered approvals, replies or tasks: the session's harness is sdk, which has none of the Miblo+ capabilities (lib/harness/index.js capsOf('sdk'): no reply route, no approvals). The bridge's reply router answers unsupported or unknown_mode for it, and its history frames carry reply: false. Not done in this change: the phone app's label lives in the miblo-platform repository.
The day's summary (miblo today, the gadget's daily totals) counts the coding agents' sessions only.
Limits
| What | Limit |
|---|---|
| Live SDK sessions | 8 (409 too_many_sessions) |
| Request body | 4096 bytes (413 too_large); a declare 512 KiB |
| Select choices | 2000 per select, 6000 per app; multi-pick max 20 |
| Suggestions | answered within 2 s (else []); 10 per answer; 4 questions waiting per app |
| Session writes (register, state, end) | 10 per second, bursts of 20 |
| Gadget actions (say, focus, timer) | bursts of 3, then 1 every 10 s |
| Snapshot reads | 5 per second, bursts of 10 |
| Challenges (hello) | 50 per second, 8 per connection per second, apart from the hooks' |
tool / title / detail | 20 / 64 / 64 characters |
| Screen cards | 2 per second, bursts of 5; 1024 bytes |
| Screen frames | 1 write per slot per 10 s, 100 per slot per day; 1 MiB body; 60000 bytes of image data |
App screen pin (show) | 1 per 10 s |
| Peek | 1 per 10 min per program, 1-10 s |
| Animations | 1 per 5 s (bursts of 2); 12 MiB body (8 MiB file, 256 frames read); the flash's 10 s / 100 a day per slot and file; one at a time |
| Mode 7 parameters | 1 per second, bursts of 3 |
| Apps | 32 per computer, 12 settings each; settings long polls: 16 at once, 20 s each |
The limits are the bridge's, for every program together. The gadget applies its own limits too.
Errors
Every error is JSON, {"error": "<code>", ...}. The codes are stable:
| Status | error | When |
|---|---|---|
| 400 | bad_request (+ field, message) | a field out of range or of the wrong type |
| 400 | bad_json | the body is not JSON |
| 401 | sdk_off | no token on this computer (miblo sdk token) |
| 401 | not_authorised | no valid challenge answer (wrong or rotated token, a spent challenge) |
| 403 | forbidden host / origin not allowed | the Host or Origin guard |
| 404 | unknown_session | no such SDK session (ended, expired, or not an SDK session) |
| 404 | not_found | no such path |
| 405 | method_not_allowed | wrong method for the path |
| 409 | too_many_sessions (+ max) | 8 SDK sessions are live |
| 403 | not_your_app | an app's settings, asked by another program |
| 404 | unknown_app | no such app declared or installed |
| 409 | too_many_apps | 32 apps are known on this computer |
| 404 | not_connected | an app's account (Connect) is not connected |
| 404 | no_connect_setting / unknown_provider | the app declares no connect setting for that provider |
| 503 | unavailable / busy (+ retryAfter) | the provider cannot be reached; 4 sign-ins at once |
| 413 | too_large (+ max) | body over 4096 bytes (unsigned answer) |
| 415 | content-type must be application/json | a POST without JSON |
| 429 | rate_limited (+ retryAfter, retry-after header) | over a limit above |
| 500 | internal | a bug: please report it |
The clients add the codes for problems on their side: no_token, bridge_unavailable (nothing on the port: start the bridge with miblo sdk start), timeout and unverified_answer. A not_authorised from hello also covers a bridge from a Miblo release that predates this API.
Versioning
v1never breaks. Within/sdk/v1/, fields may be added, optional in requests and extra in answers. Clients ignore fields they do not know, and the bridge ignores unknown request fields. No field changes type or meaning, no field is removed, no error code is renamed, and no limit gets stricter.New states, new required fields or any other incompatible change go under
/sdk/v2/, with/sdk/v1/kept alongside.helloreportsapi: the highest version the bridge speaks.The packages follow semver.
1.xspeaksv1.
Security
What a hostile local program with the token can do, and what bounds it:
Show fake sessions: at most 8 at a time, named by itself. Each one is visibly a separate session, labelled with its program's name ("Outra ferramenta" on the phone). It cannot take a coding agent's name slot ("my-api" becomes "my-api 2").
Raise "needs you" alerts on the gadget and the phone. Each state change alerts once, and changes are limited to 10 a second for all programs together.
miblo sdk offor--rotatestops it.Put messages on the gadget: 40 characters, 3 at once and then 1 every 10 s, never starting like Miblo's own notices ("Approved:", "Phone:"...). Never a desktop notification.
Start or stop focus sessions and timers (the same rate limit).
Draw on the App screen: a card, up to four images and the pin. The App screen always shows the program's name, Miblo's own alerts draw over it, and its strings cannot start like Miblo's notices. A flood of images is bounded by the wear limits.
Play an animation (1.26): data only — tile indexes, positions, step times, a few bounded Mode 7 numbers, a mesh within its caps — checked by the bridge and again by the gadget, played by the firmware's fixed player under its frame-time governor. No code goes up. Its files are bounded (12 KB tile set, 16 maps, 8 frame slots never taken from another program) and so are its writes (the wear limits). The decoders refuse a hostile file (a huge canvas, a broken LZW stream, too many frames) before allocating anything large.
POST /sdk/v1/screen/animateis the one route the bridge's guard lets in as multipart/form-data: like every Status API route it does nothing without the request's MAC over the body, which no web page or form can make.Read a status-only snapshot: session names (project folders or titles), states, agent names and the paired gadgets' names. No commands, prompts, files, costs, tokens or addresses.
Change or end another program's SDK session: every program holding the token shares one trust domain, and the snapshot lists the SDK sessions' full ids. A coding agent's session stays out of reach.
What it cannot do:
Anything the bridge key gates: send hook events, touch a coding agent's session, read
/status, stop the bridge, or use any/plus/*route (approvals, replies, the reply waiters, tasks, history, the account link, phones). Nor read the frames back: the desktop app does that with the bridge key.Anything a gadget's pairing token gates: the bridge talks to the gadgets with its own pairing tokens, and the API only reaches the actions above (sessions, say, focus, timer and the App screen) with validated bodies. There is no settings, rename, reset, Wi-Fi, pet or firmware change, no pairing, and no mode change other than pinning the App screen.
Anything Miblo+ gates: an SDK session has no reply route and no approvals, the snapshot never carries Miblo+ data, and the API never reaches the relay, the account or miblo.ai.
Reach the API from a browser (Host/Origin guard, JSON-only POST) or from another computer (loopback only).
Without the token (another local user, a web page, the network): nothing but hello, which reveals whether the API is on. A process that takes the port while the bridge is down cannot pass hello's proof, so the clients send it nothing. They also check every answer's MAC.
Same-user code is out of scope (see Authentication). It already has more than this API gives.