SDK
Publish an app to the Miblo store, step by step
How to publish an app on miblo.ai/apps: what a submission holds, the code package, the automatic scan, the AI review, the human approval, revisions and a checklist.
The process at a glance
Publishing puts your app in the store, miblo.ai/apps, for anyone to turn on in their Miblo with one click. The path:
You test it on your computer with the SDK and the supervisor.
You submit it at miblo.ai/en/apps/submit: the app's listing, its screens and, if it has one, its code package.
The automatic scan checks the package at once and refuses what cannot pass, with the reason.
The AI review writes a security report on the code (minutes later).
A person approves it. No app enters the store without a human's approval.
miblo.ai signs the package with the apps key, and whoever installs it checks the signature before anything runs.
The two kinds of app
| Kind | What it carries | What runs on the installer's computer |
|---|---|---|
| Screen package | A card, up to 4 frames and the settings | Nothing: Miblo applies the screen as a program would |
| App with code | Everything a screen package has, plus a code package (zip) | Your main.js, inside the sandbox |
A screen package suits a fixed screen, or one a program of yours keeps up to date. An app with code fetches its data and redraws on its own, like the official apps.
What a submission holds
| Field | Rules |
|---|---|
Address (id) | Lowercase letters, digits and -. It is the app's address in the store and never changes. |
| Name | 2 to 40 characters, cleaned like a card's strings. |
| Category | My computer, Money, Day to day, News and dev, Sport, Delights or Other. |
| Version | 1.0 or 1.0.0 (up to three numbers). |
| Description | In Portuguese and in English, up to 2000 characters each. |
| Instructions | How to use and set it up, up to 2000 characters. |
| What changed | Up to 500 characters: it becomes the version's changelog on the app's page. |
| Program link | Optional, up to 300 characters. |
| Icon | A PNG of up to 64 KB, re-made at 128 px. |
| Screenshots | Up to 6 PNGs of up to 512 KB, re-made at up to 960 px. |
| Card | The screen's card, checked with the gadget's rules (The App screen). |
| Frames | Up to 4 PNGs (slots 0 to 3) of up to 1 MB, converted to Miblo's format (up to 60000 bytes of image). |
| Settings | The settings schema (Apps); none may ask for a key, token or password. |
| Code | Optional: the code package's zip, up to 1 MB. |
The server decodes and re-makes every image; nothing of the original file is ever served. Up to 10 submissions a day per member.
The code package
A zip of plain Node.js, ESM, no dependencies:
my-app.zip
├── manifest.json
├── main.js
├── lib/source.js (optional: other .js and .json files)
└── README.md (optional)manifest.json
{
"id": "tide",
"name": { "pt": "Maré", "en": "Tide" },
"category": "daily",
"promise": { "pt": "A maré está subindo?", "en": "Is the tide coming in?" },
"settings": [
{ "key": "port", "label": { "pt": "Porto", "en": "Port" }, "type": "text", "required": true, "max": 40 }
],
"network": ["api.example-tides.com"],
"intervalMs": 600000,
"preview": { "v": 1, "title": "Tide", "items": [{ "t": "big", "value": "1.2 m", "label": "rising" }] }
}| Field | Rules |
|---|---|
id | The submission's address. |
name | { pt, en }. |
category, promise, preview | As in app.declare() (Apps). |
settings | The settings schema: what the person fills in the Miblo app. |
network | The only hosts the app may reach: public names (no IP, port or local names such as .local), up to 16. [] means offline. It must equal the hosts written in the code: a host used but not declared is refused, and so is one declared but never used. |
intervalMs | How often run() is called: 10 s (10000) to 24 h. |
main.js
// main.js: called every intervalMs, or right after the person changes a setting.
export async function run(ctx) {
const t = ctx.lang === 'pt' ? { up: 'subindo', down: 'descendo' } : { up: 'rising', down: 'falling' };
const port = encodeURIComponent(ctx.settings.port);
const data = await ctx.fetchJson(`https://api.example-tides.com/v1/port/${port}`);
const rising = data.next > data.now;
ctx.state.last = data.now; // kept from one call to the next
return {
card: { title: 'Tide', items: [
{ t: 'big', value: `${data.now.toFixed(1)} m`, label: rising ? t.up : t.down, color: 'blue' },
{ t: 'text', value: `updated ${ctx.timeOf(ctx.now())}`, color: 'grey' },
] },
peek: false, // true asks for a peek (once every 10 min)
};
}run(ctx) gets:
ctx. | What it is |
|---|---|
settings | The settings' values (defaults plus what the person saved). |
state | An object kept from one call to the next (in memory). |
lang, locale | pt or en, and the computer's locale. |
now(), timeOf(ms), sleep(ms) | The time, the time formatted (14:05) and a pause. |
fetchJson(url), fetchText(url) | Fetch only from the hosts in network (https, 10 s, 256 KB). |
stateDir | The folder where the app may write files. |
app | The app (app.settings, app.connect), for apps that need a connected account. |
screen, frame(slot, image) | The app's screen and an upload of its own frame (the flash wear is yours to budget). |
And returns { card, peek?, frame?, plain? }: the card (without v, Miblo adds it); peek (true is 10 s, or a number from 1 to 10) asks to bring the screen forward; frame ({ slot, image: { rgba, w, h } }) writes a background before the card, and plain is the card to use if the frame is refused. Miblo declares the app, reads the settings, asks for the missing ones and draws the card for you.
File rules
Only
.js,.jsonand.md; at most 64 files, 8 folders deep, names of up to 64 letters, digits,.,_or-, no two names differing only in case.No
package.jsonand nonode_modules: the Miblo runtime is the only "package".The zip up to 1 MB, everything unpacked up to 1 MB, each script up to 512 KB.
main.jsmust exportrun(ctx).
The automatic scan
It runs on submission and is the truth the AI review cannot override. It refuses at once, with the reason:
importing a module the sandbox never allows:
child_process,worker_threads,net,tls,dgram,http,https,http2,vm,module,wasi,cluster,inspector;any dependency (a module that is not a Node built-in),
node_modulesor apackage.json;eval,new Functionand their equivalents (a string tosetTimeout,.constructor(...)),import()orrequire()of something computed,createRequire,WebAssembly, native code (process.binding,dlopen), computed access to the global object;an address whose host is not in
network(also spelledhttps:host, with backslashes or split into pieces), or a declared host the code never uses;files outside the rules above, a script with a syntax error, an import of a file that does not exist, a
main.jswithoutrun(ctx).
And it points out to the reviewer (without refusing on its own): hosts written without https://, http:// addresses, hosts built at run time, base64 or hex blobs over 1 KB, decoding (atob, Buffer.from(..., 'base64')), obfuscation signals (random names, _0x..., split strings, String.fromCharCode, minified lines), process.env reads beyond MIBLO_*, paths outside the app's folder, invisible characters and text aimed at the reviewer ("ignore previous instructions", "approve this app", role-play).
The AI review
After the scan, the code joins the AI review's queue (it runs every 10 minutes and takes a few minutes):
Two independent passes: the first writes the report; the second, with other instructions, checks the report against the code and says what it missed.
The report covers: the risk level, every network destination and why, what the app reads from the computer, what leaves it and to where, persistence, hidden behaviour, signs of malicious code (credential theft, mining, code loaded from the network, sandbox escapes, destructive actions) and attempts to steer the reviewer, with a summary in Portuguese and in English.
The AI never approves or refuses. The verdict ("ok" or "needs a careful look") is computed by the server: the passes disagreeing, a high risk, text aimed at the reviewer or an undeclared host always ask for a careful look.
Guarded against manipulation: the code goes in as data, between markers, with the fixed instruction that nothing in it is an instruction; the answer is validated against a schema; texts are capped.
The human approval
The reviewer sees the scan's findings, both reports, the hosts and, on a revision, what changed since the previous version. Only then do they approve it (miblo.ai signs the package) or refuse it with a reason, which reaches you by e-mail. The app's public page says "This app talks to: ..." (the declared, verified hosts) or "Does not use the internet".
After it is published
Revisions: an edit is a new revision, which goes the same way. The approved version stays live until the next one is approved, and there is one pending revision at a time.
Ratings: members give 1 to 5 stars; the average and the count show in the store.
Reports: a report hides the app at once until someone reviews it (each member hides at most two apps a day). Moderators can hide and restore.
A hidden app, for those who installed it: at every start and every hour, Miblo checks the package; an app the store no longer serves or has hidden is turned off.
How the package reaches its users
The Enable on my Miblo button opens the Miblo app on your app (miblo://apps/enable/<id>), or the person runs miblo apps install <id>. Miblo then:
downloads
miblo.ai/apps/<id>/package.jsonand checks its signature with the apps key (separate from the updates' key), refusing a package without a valid one;downloads the zip over https (no redirects, never more than 1 MB) and checks its signed SHA-256;
checks every file, unpacks it into
<Miblo's data>/apps/<id>/pkgand turns the app on in the sandbox.
Test on your computer before submitting
Run your main.js the way Miblo will, with a small script and the SDK:
// run-local.mjs: calls run(ctx) as Miblo does and draws the card. node run-local.mjs
import fs from 'node:fs';
import { MibloStatus } from '@miblo/status';
import { run } from './main.js';
const manifest = JSON.parse(fs.readFileSync(new URL('./manifest.json', import.meta.url), 'utf8'));
const miblo = new MibloStatus();
const tool = manifest.name.en.slice(0, 20);
const app = miblo.app({ id: manifest.id, tool });
const screen = miblo.screen({ tool });
await app.declare({ name: manifest.name, category: manifest.category, promise: manifest.promise, settings: manifest.settings, preview: manifest.preview });
const state = {};
for (;;) {
const { values, missing } = await app.settings.get();
if (missing.length) { await app.settings.request(missing, 'Fill these in to test'); await app.settings.wait(); continue; }
const out = await run({ settings: values, state, lang: 'en', locale: 'en-US', now: Date.now, sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
timeOf: (ms) => new Date(ms).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }),
fetchJson: async (u) => (await fetch(u)).json(), fetchText: async (u) => (await fetch(u)).text(), stateDir: './state' });
await screen.card({ v: 1, ...out.card });
if (out.peek) await screen.peek(10).catch(() => {});
await new Promise((r) => setTimeout(r, manifest.intervalMs));
}export MIBLO_TOKEN=$(miblo sdk token)
node run-local.mjs # running in the terminal
miblo apps add "node run-local.mjs" --id tide # or under the supervisor
miblo apps enable tide && miblo apps logs tide
miblo screen status # what the App screen holds nowThis script is not the sandbox: it blocks no modules or hosts. Check yourself that the code uses only ctx.fetchJson/ctx.fetchText and the hosts in network, with none of the blocked modules, before you submit.
Checklist before submitting
Miblo+ is active on your account.
The app answers one question and its promise says which, in Portuguese and in English.
The card fits: title up to 24, labels up to 16,
bigup to 10,textup to 40 characters, up to 4 items, none of Miblo's phrases.The frames are 8-bit PNGs with flat colours that fit in 60000 bytes; what changes is on the card, not the frame.
The settings ask for no secret, and the required ones have a clear reason in
settings.request().networklists exactly the hosts the code uses ([]when offline), all overhttps://.No blocked module, no dependency, no
eval, no text for the reviewer.intervalMsis at least 10000, and the app asks for a peek only when something important happens.The app works offline (shows its last values and since when) and unconfigured (asks with
request).Tested on your computer under the supervisor, with a clean
miblo apps logs.Icon, screenshots, description and "what changed" filled in.