Mini-apps
A mini-app is custom logic that blocks can't express: a game, a calculator, a drawing board, a 3D scene. It's one self-contained HTML document in blueprint.apps, shown by a miniapp block. The AI writes them on request; developers can too.
"apps": [{ "id": "snake", "name": "Snake", "html": "<canvas id=c></canvas><script>…</script>" }],
"blocks": [{ "id": "snake-game", "kind": "miniapp", "props": { "app": "snake", "height": 420 } }]
The block's settings #
A miniapp block's config prop (plain JSON, up to 16 KB) reaches the app as window.zvessa.config, so one app placed twice can behave twice differently. config.connections names the space connections the app may read through the bridge's connections.call, read-only (MCP tools the server marks read-only, HTTP GET). Big data belongs in a collection, not in config.
Starter mini-apps #
Ready-made mini-apps people add from HQ → Library, each a kit with the same id (group Tools). Code: convex/lib/miniapps/, built from miniapps/*.html with npm run miniapps.
| Kit | What it is | Settings (config) |
|---|---|---|
calculator | Calculator, keyboard too | decimals |
drawing | Drawing board with the style's colours, undo, eraser | colors, background (plain, grid, dots), shared |
timer | Countdown timer with presets; can run cycles | minutes, presets, cycle ([{ label, minutes }]), rounds, sound |
focus | The timer set to 25 min focus / 5 min break, four rounds | (as timer) |
stopwatch | Stopwatch with laps | |
scorekeeper | Scores for any game | players, steps, start, target, lowWins, title, shared |
points | Points for members (Ledger unit points, weekly and all-time rankings, give points) | (blocks) |
dice | Dice, coin, random number | modes, dice, sides, min, max |
converter | Unit converter (8 kinds) | categories, category |
split | Tip and bill splitter | tips, tip, people, currency |
countdown | Days, hours and minutes to a date | date, title, editable |
flashcards | Flip cards, again or got it, edit the deck | cards ([[front, back]]), editable |
metronome | Metronome with accent and tap tempo | bpm, beats |
habits | Habit tracker with streaks | habits, days |
colors | Colour picker: HEX, RGB, HSL, shades, the space's colours | color |
qr | QR codes for links, text or Wi-Fi | text |
puzzles | Tic-tac-toe, sliding puzzle, memory match | games, size, symbols |
compass | Compass that can point out a direction | mark ({ label, bearing }), hz |
weather | Weather now, by hour and for the week (Open-Meteo, free) | place, lat, lon, units, refresh, connections |
liveinfo | Live information from any MCP server or API of the space, with an AI summary | connection, tool, arguments, path, query, items, fields, limit, summarize, refresh, title |
toolbox | Pocket tools: calculator, converter, splitter, timer, stopwatch |
Every starter app also takes words (change any word it shows) and save (a name, so two copies keep separate saved state). Weather and Live info need the bridge; the compass needs it on phones.
Reuse, don't copy. Once an app is in /apps, place more miniapp blocks pointing at it with another config: a second countdown, a golf scorekeeper (lowWins: true). Kits share one copy too (Focus uses the Timer's). To remix, read the app's html from /apps, change it, and add it under a new id.
{ "id": "trip", "kind": "miniapp", "props": { "app": "countdown", "height": 320,
"config": { "date": "2027-06-01", "title": "Lisbon trip", "words": { "days": "sleeps" } } } }
The sandbox #
Nothing anyone makes runs as code inside Zvessa itself. A mini-app runs:
- on phones in a WebView that can't navigate anywhere (
src/components/MiniAppFrame.tsx); - on the web in an iframe with
sandbox="allow-scripts"and no same-origin access (MiniAppFrame.web.tsx).src/components/MiniApp.tsxis the same on both.
Its content policy (src/components/miniAppDoc.ts):
default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; media-src data: blob:; font-src data:;
So a mini-app can run inline scripts and styles and show data: and blob: images, sounds and fonts. It can't load other scripts or images from the internet, fetch anything, open sockets, read the app's sign-in, keys or data, or navigate away. (An https: image would let a mini-app send what people type to any server inside the image's address, so those are out.) Everything else goes through the bridge.
The style #
A mini-app matches every style, including ones made after it, because it gets the resolved style twice:
- CSS variables on
:root: every colour by name (--bg,--surface,--raised,--text,--text2,--text3,--line,--accent,--accent2,--onAccent,--success,--warn,--danger, and any named colour such as--gold,--win), plus--radius,--radius-smalland--line-width(--lineis the line colour).color-schemeis set to light or dark; the body already uses--bgand--text. window.zvessa.style:{ id, scheme, dark, colors, shape, surface, type }for scripts (canvas games readcolors.accentand so on).
<button style="background: var(--accent); color: var(--onAccent); border-radius: var(--radius-small)">Play</button>
<script>
const { colors, dark } = window.zvessa.style;
ctx.fillStyle = colors.accent;
</script>
Never write a colour of your own; name one (var(--felt, var(--surface))) so styles can set it.
The bridge #
The bridge is the one way a mini-app reaches the space, the phone and the outside world. The mini-app asks; the host checks, then answers. Code: the list of calls is convex/lib/miniapps/bridge.ts (the AI's guide is built from it), the host is src/kits/miniapps/host.tsx, the server side is convex/miniapps/.
const me = await zvessa.call("me");
await zvessa.call("storage.set", { key: "best", value: 42 });
const off = zvessa.on("sensors.compass", (v) => needle(v.heading), { hz: 15 });
if (zvessa.can("location.get")) { /* show the "use my location" button */ }
zvessa.call(name, args)answers once (a Promise).zvessa.on(name, handler, args)streams until you call the function it returns.- A failed call rejects with an
Errorwhosecodeis one of:denied(not listed inneeds, the role can't, or the person said no),missing(no such call),limit(too many calls; try again shortly),unsupported(this device or browser can't),conflict(someone changed it first),invalid(bad arguments),failed(the message says why). zvessa.can(name)says whether this mini-app may use a call here, so it can hide a button instead of failing.zvessa.allowedis the full list.window.zvessaalso hasapp({ id, name }),config,styleandpreview(true while someone tries it from the library: saved state lives in memory and space data is off).- Mini-apps written for the old name keep working:
window.myaibiois the same object aswindow.zvessa.
What a mini-app declares: needs #
Basic calls (saved state, haptics, sound, share, clipboard copy, open, resize…) need nothing. Every other call must be listed in the app's needs, and a call on a collection names it:
{ "id": "quiz", "name": "Quiz", "html": "…", "needs": ["records.add:scores", "records.list:scores", "ai.ask", "location.get"],
"about": "A pub quiz", "icon": "trophy", "age": "everyone" }
Then the host and the server both check, in order: the call exists, it's in needs, the viewer's role allows it (records.add needs add, a shared storage.set needs play…), the person has allowed it on this device when it touches the phone (location, camera, microphone, contacts, calendar, motion; asked once per app, in the app's own words), and the rate limit (bursts of 60, 30 a second, 12 at a time). The server checks again from the saved blueprint, so a changed page can't skip a check.
Outside information #
connections.callreads from the space's own connections: a read-only MCP tool, or aGETon an HTTP API. Only the connections the block lists inconfig.connections(a list of ids, or an object such as{ "forecast": "weather-forecast" }). Keys stay on the server. It answers{ text, json?, status? }.ai.askasks the space's AI ({ prompt, context?, system?, format: "text" | "json" }) on the space's own key.- A published mini-app added to another space uses only that space's connections, the ones its owner lists in the block's config. Never the author's.
Every call #
Who and where
| Call | Does | Args → gives | Needs |
|---|---|---|---|
me | The viewer | – → { id, name, role, member, can: { view, post, play, add, edit, invite, manage } } | – |
space | The space it runs in | – → { id, name, kids } | – |
env | The device | – → { platform: 'ios'|'android'|'web', width, height, language, dark, reduceMotion, preview } | – |
members.list | People in the space | { limit? } → [{ id, name, role }] | in needs, role: view |
Saved state
| Call | Does | Args → gives | Needs |
|---|---|---|---|
storage.get | Read a saved value. scope 'me' (default) is this person's own; 'space' is shared by everyone | { key, scope? } → { value, version } or null | – |
storage.set | Save a JSON value (up to 64 KB). On scope 'space' it needs the play permission. Pass ifVersion to save only if nobody changed it since you read it (turns in a game); otherwise fails with code 'conflict' | { key, value, scope?, ifVersion? } → { version } | – |
storage.delete | Delete a saved value | { key, scope? } | – |
storage.list | Saved keys | { scope?, prefix? } → [{ key, version, at }] | – |
| on("storage.watch") | Live value of a key: fires now and whenever anyone changes it (shared boards, turn-based games) | { key, scope? } → { value, version } or null | – |
Space data
| Call | Does | Args → gives | Needs |
|---|---|---|---|
records.list | Records of a collection, newest first | { collection, limit? } → [{ id, data, at, mine }] | in needs, role: view |
| on("records.watch") | Live records of a collection | { collection, limit? } → [{ id, data, at, mine }] | in needs, role: view |
records.add | Add a record (fields are checked against the collection) | { collection, data } → { id } | in needs, role: add |
records.update | Change a record: your own with add, anyone's with edit | { collection, id, data } | in needs, role: add |
records.delete | Delete a record: your own with add, anyone's with edit | { collection, id } | in needs, role: add |
Scores and events
| Call | Does | Args → gives | Needs |
|---|---|---|---|
scores.submit | Submit a score to a board; each person keeps their best | { board, value, lower? (true when lower is better) } → { best, rank } | in needs, role: play |
scores.top | A board's best scores | { board, limit?, lower? } → [{ rank, id, name, value, me }] | in needs, role: view |
events.record | Count something that happened (shows in the space's analytics when that kit is on) | { name } | – |
ledger.balance | The viewer's balances (coins, points…) | – → [{ unit, name, amount }] | in needs, role: view |
ledger.give | Give units to a member (needs the unit's issue permission, manage by default) | { unit, to, amount, note? } | in needs |
ledger.send | Send the viewer's own units to a member, when the unit allows transfers | { unit, to, amount, note? } | in needs |
Together
| Call | Does | Args → gives | Needs |
|---|---|---|---|
channel.send | Send a small JSON message (up to 4 KB) to everyone with this mini-app open in the space | { data } | in needs, role: play |
| on("channel") | Messages others send with channel.send (the last minute) | – → { from: { id, name }, data, at } | in needs, role: view |
| on("presence") | Who has this mini-app open right now (the viewer counts as here while it's open) | – → [{ id, name }] | in needs, role: view |
notify.members | Push a notification to members (10 an hour per mini-app) | { title, body?, to?: 'all' | [memberId], page? } → { sent } (sent on topic apps.<appId>) | in needs, role: post |
Outside information
| Call | Does | Args → gives | Needs |
|---|---|---|---|
connections.describe | What a connection offers: an MCP server's tools, or an HTTP API. Only connections the block lists in config.connections (a list of ids, or an object whose values are ids) | { connection } → { id, name, kind, tools? } | in needs |
connections.call | Read from a connection: run a read-only MCP tool, or GET an HTTP path. Keys stay on the server. Only connections the block lists in config.connections | { connection, tool?, arguments?, path?, query? } → { text (HTTP answers start with the status line, like "200\n…"), json? (parsed when it's JSON), status? } | in needs |
ai.ask | Ask the space's AI. format 'json' answers with JSON. Counts toward the space's AI use | { prompt (or question), context? (text it should use), system?, format?: 'text'|'json', maxTokens? } → { text, json? } | in needs |
Feel and sound
| Call | Does | Args → gives | Needs |
|---|---|---|---|
haptics.tap | A tap the person feels. Follows the style's haptics setting (phones) | { style: 'light'|'medium'|'heavy'|'success'|'warning'|'error'|'select' } | – |
audio.play | Play a sound from a data: URL (inline it). Follows silent mode | { src, volume?, loop? } → { id } | – |
audio.stop | Stop a sound, or all of them | { id? } | – |
speech.say | Read text aloud | { text, language?, rate?, pitch? } | – |
speech.stop | Stop reading aloud | – | – |
| on("speech.listen") | Speech to text while on | { language?, interim? } → { text, final } | in needs, person allows (speech) |
mic.record | Record a clip (up to 60 s) | { seconds } → { dataUrl, seconds } | in needs, person allows (microphone) |
| on("mic.level") | Live loudness while on (voice games) (phones) | { hz? } → { level: 0..1 } | in needs, person allows (microphone) |
Sensors
| Call | Does | Args → gives | Needs |
|---|---|---|---|
| on("sensors.motion") | Tilt and movement | { hz? (default 30, max 60) } → { rotation: { alpha, beta, gamma }, acceleration: { x, y, z } } | in needs, person allows (motion) |
| on("sensors.accelerometer") | Acceleration in g | { hz? } → { x, y, z } | in needs, person allows (motion) |
| on("sensors.gyroscope") | Rotation rate | { hz? } → { x, y, z } | in needs, person allows (motion) |
| on("sensors.magnetometer") | Magnetic field in µT (phones) | { hz? } → { x, y, z } | in needs, person allows (motion) |
| on("sensors.barometer") | Air pressure (phones) | { hz? } → { pressure, relativeAltitude? } | in needs, person allows (motion) |
| on("sensors.light") | Light level (Android) (phones) | { hz? } → { illuminance } | in needs, person allows (motion) |
| on("sensors.pedometer") | Steps since it was turned on (phones) | – → { steps } | in needs, person allows (motion) |
| on("sensors.compass") | Which way the phone points | { hz? } → { heading (degrees from north), accuracy } | in needs, person allows (location) |
location.get | Where the person is (rounded to about 1 km unless precise) | { precise? } → { lat, lon, accuracy } | in needs, person allows (location) |
| on("location.watch") | Where the person is, as they move | { precise?, meters? } → { lat, lon, accuracy, speed?, heading? } | in needs, person allows (location) |
Camera and files
| Call | Does | Args → gives | Needs |
|---|---|---|---|
camera.take | Take a photo (or a short video) | { video?, maxSize? (px, default 1280) } → { dataUrl, width, height } or null | in needs, person allows (camera) |
camera.scan | Scan a QR code or barcode | – → { type, data } or null | in needs, person allows (camera) |
media.pick | Pick photos or videos | { multiple?, kind?: 'image'|'video'|'any', maxSize? } → [{ dataUrl, width, height, kind }] | in needs, person allows (photos) |
files.pick | Pick a file (up to 5 MB) | { types? (like ['application/pdf']) } → { name, type, size, dataUrl } or null | in needs, person allows (files) |
files.save | Save or share a file the mini-app made | { name, data (text or data: URL) } | – |
share | Open the share sheet with text, a link or an image | { text?, url?, title?, image? (data: URL) } | – |
clipboard.copy | Copy text | { text } | – |
clipboard.read | Read copied text | – → { text } | in needs, person allows (clipboard) |
Calendar and contacts
| Call | Does | Args → gives | Needs |
|---|---|---|---|
calendar.add | Open the calendar's add-event screen, filled in (phones) | { title, start, end, location?, notes? } (times as ms or ISO) | in needs, person allows (calendar) |
contacts.pick | The person picks one contact (phones) | – → { name, phones, emails } or null | in needs, person allows (contacts) |
notify.me.later | A reminder on this phone at a time (timers, alarms) (phones) | { at (ms or ISO), title, body? } → { id } | in needs, person allows (notifications) |
notify.me.cancel | Cancel a reminder made with notify.me.later (phones) | { id } | – |
Screen
| Call | Does | Args → gives | Needs |
|---|---|---|---|
screen.keepAwake | Keep the screen on while the mini-app shows (games, timers, recipes) | { on } | – |
screen.orientation | Lock the screen while the mini-app shows (phones) | { lock: 'portrait'|'landscape'|'any' } | – |
screen.brightness | Brighten the screen while it shows (tickets, QR codes); goes back after (phones) | { level: 0..1 } | – |
resize | Change the mini-app's height | { height (px, 120 to 2000) } | – |
open.page | Open a page of the space | { page } | – |
open | Open a button-style href: "#page-id" opens a page of the space, an https link opens outside (the person confirms) | { href } | – |
open.link | Open an https link (the person confirms) | { url } | – |
Later (needs store setup first): in-app purchases through Apple and Google, and reading steps and workouts from Health.
Pausing #
When the app is in the background or the mini-app is off screen, the host sends a pause: animation frames, timers and media stop, events stop arriving, sensors and sounds turn off, and myaibio:pause / myaibio:resume fire. Live values (storage.watch, records.watch, channel, presence) catch up on resume.
The public library #
Mini-apps can be shared with everyone. Nothing is public until its owner taps Publish.
- Publish (people who manage the space, from the Apps page: kit
app-library, blocksminiapps.library,miniapps.mine,miniapps.review). Pick a category and an age rating (everyone,teen,mature), write what it does, and send it. The AI can't publish. - Review. Every version waits for a Zvessa admin, who can try it, then accept or reject with a reason. Only accepted versions can be added anywhere else. Admins can also take a listing down, which stops it in every space, and put it back.
- Browse and add. Anyone signed in can search, read the details and the calls it asks for, and try it in a preview. Add to my space puts it in the space with its own page; Remix makes a copy the space owns and can change. Kids spaces only see and add apps rated
everyone. - Updates. An added app remembers where it came from. When a newer version is accepted, the space's managers see Update, with any new calls it asks for listed first.
- Ratings and reports. One rating per person; reports go to the admins.
- Free: publishing, adding and using cost nothing.
The library over MCP #
Any MCP client can browse the public library: POST {CONVEX_SITE_URL}/mcp/library needs no account or token and offers search_miniapps and view_miniapp (source: true includes the HTML), plus search_templates and view_template for templates and kits. It's read-only and lists only accepted pieces. A space's own /mcp server and the in-app AI get the same two tools plus add_miniapp({ id, remix? }), which is a proposal a person approves. Publishing is still only a person's tap in the app.
Writing a good mini-app #
- One file, no network. Inline everything; images as
data:URLs. Outside information comes throughconnections.call. - List what you use. Put every non-basic call in
needs, and usezvessa.can()to hide what isn't allowed here. - Fit any width. Phones first: 320 px wide and up, touch first, then mouse and keys.
- Light on the device. Draw with
requestAnimationFrameonly while something moves; stop when the game is paused or over. Ask sensors for the lowest rate that works (30 Hz is enough for tilt games). More in Performance. - Paused when hidden. When the app is in the background or the mini-app is off screen, the host calls
window.zvessa.setPaused(true):requestAnimationFrame, timers and media stop, and timers never fire faster than every 16 ms. Listen for themyaibio:pauseandmyaibio:resumeevents (or readwindow.zvessa.paused) to save state or stop sounds. - No dialogs or forms.
alert,confirmand form submits are blocked in the sandbox; use a second tap to confirm, and a button (plus Enter) to send an input row. - No browser storage. It's blocked in the sandbox. Use the bridge's
storage.get(answers{ value, version }ornull) andstorage.set;scope: "space"shares a value with everyone andstorage.watchkeeps it live. Checktypeof zvessa.call === "function"so the app still works outside Zvessa. - Small. Aim for well under 200 KB of HTML; the whole blueprint goes to every device.
- Readable words. Put the app's words near the top of the script so people (and the AI) can change them.
- Accessible. Real buttons, labels, enough contrast (use
--texton--bg).
Planned #
Developer apps hosted elsewhere behind the same bridge; in-app purchases and Health once the store setup is done; Skia and GL for native-speed games.