Zvessa docs

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.

KitWhat it isSettings (config)
calculatorCalculator, keyboard toodecimals
drawingDrawing board with the style's colours, undo, erasercolors, background (plain, grid, dots), shared
timerCountdown timer with presets; can run cyclesminutes, presets, cycle ([{ label, minutes }]), rounds, sound
focusThe timer set to 25 min focus / 5 min break, four rounds(as timer)
stopwatchStopwatch with laps
scorekeeperScores for any gameplayers, steps, start, target, lowWins, title, shared
pointsPoints for members (Ledger unit points, weekly and all-time rankings, give points)(blocks)
diceDice, coin, random numbermodes, dice, sides, min, max
converterUnit converter (8 kinds)categories, category
splitTip and bill splittertips, tip, people, currency
countdownDays, hours and minutes to a datedate, title, editable
flashcardsFlip cards, again or got it, edit the deckcards ([[front, back]]), editable
metronomeMetronome with accent and tap tempobpm, beats
habitsHabit tracker with streakshabits, days
colorsColour picker: HEX, RGB, HSL, shades, the space's colourscolor
qrQR codes for links, text or Wi-Fitext
puzzlesTic-tac-toe, sliding puzzle, memory matchgames, size, symbols
compassCompass that can point out a directionmark ({ label, bearing }), hz
weatherWeather now, by hour and for the week (Open-Meteo, free)place, lat, lon, units, refresh, connections
liveinfoLive information from any MCP server or API of the space, with an AI summaryconnection, tool, arguments, path, query, items, fields, limit, summarize, refresh, title
toolboxPocket 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:

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:

<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 */ }

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 #

Every call #

Who and where

CallDoesArgs → givesNeeds
meThe viewer– → { id, name, role, member, can: { view, post, play, add, edit, invite, manage } }–
spaceThe space it runs in– → { id, name, kids }–
envThe device– → { platform: 'ios'|'android'|'web', width, height, language, dark, reduceMotion, preview }–
members.listPeople in the space{ limit? } → [{ id, name, role }]in needs, role: view

Saved state

CallDoesArgs → givesNeeds
storage.getRead a saved value. scope 'me' (default) is this person's own; 'space' is shared by everyone{ key, scope? } → { value, version } or null–
storage.setSave 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.deleteDelete a saved value{ key, scope? }–
storage.listSaved 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

CallDoesArgs → givesNeeds
records.listRecords 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.addAdd a record (fields are checked against the collection){ collection, data } → { id }in needs, role: add
records.updateChange a record: your own with add, anyone's with edit{ collection, id, data }in needs, role: add
records.deleteDelete a record: your own with add, anyone's with edit{ collection, id }in needs, role: add

Scores and events

CallDoesArgs → givesNeeds
scores.submitSubmit 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.topA board's best scores{ board, limit?, lower? } → [{ rank, id, name, value, me }]in needs, role: view
events.recordCount something that happened (shows in the space's analytics when that kit is on){ name }–
ledger.balanceThe viewer's balances (coins, points…)– → [{ unit, name, amount }]in needs, role: view
ledger.giveGive units to a member (needs the unit's issue permission, manage by default){ unit, to, amount, note? }in needs
ledger.sendSend the viewer's own units to a member, when the unit allows transfers{ unit, to, amount, note? }in needs

Together

CallDoesArgs → givesNeeds
channel.sendSend 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.membersPush 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

CallDoesArgs → givesNeeds
connections.describeWhat 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.callRead 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.askAsk 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

CallDoesArgs → givesNeeds
haptics.tapA tap the person feels. Follows the style's haptics setting (phones){ style: 'light'|'medium'|'heavy'|'success'|'warning'|'error'|'select' }–
audio.playPlay a sound from a data: URL (inline it). Follows silent mode{ src, volume?, loop? } → { id }–
audio.stopStop a sound, or all of them{ id? }–
speech.sayRead text aloud{ text, language?, rate?, pitch? }–
speech.stopStop reading aloud––
on("speech.listen")Speech to text while on{ language?, interim? } → { text, final }in needs, person allows (speech)
mic.recordRecord 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

CallDoesArgs → givesNeeds
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.getWhere 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

CallDoesArgs → givesNeeds
camera.takeTake a photo (or a short video){ video?, maxSize? (px, default 1280) } → { dataUrl, width, height } or nullin needs, person allows (camera)
camera.scanScan a QR code or barcode– → { type, data } or nullin needs, person allows (camera)
media.pickPick photos or videos{ multiple?, kind?: 'image'|'video'|'any', maxSize? } → [{ dataUrl, width, height, kind }]in needs, person allows (photos)
files.pickPick a file (up to 5 MB){ types? (like ['application/pdf']) } → { name, type, size, dataUrl } or nullin needs, person allows (files)
files.saveSave or share a file the mini-app made{ name, data (text or data: URL) }–
shareOpen the share sheet with text, a link or an image{ text?, url?, title?, image? (data: URL) }–
clipboard.copyCopy text{ text }–
clipboard.readRead copied text– → { text }in needs, person allows (clipboard)

Calendar and contacts

CallDoesArgs → givesNeeds
calendar.addOpen 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.pickThe person picks one contact (phones)– → { name, phones, emails } or nullin needs, person allows (contacts)
notify.me.laterA reminder on this phone at a time (timers, alarms) (phones){ at (ms or ISO), title, body? } → { id }in needs, person allows (notifications)
notify.me.cancelCancel a reminder made with notify.me.later (phones){ id }–

Screen

CallDoesArgs → givesNeeds
screen.keepAwakeKeep the screen on while the mini-app shows (games, timers, recipes){ on }–
screen.orientationLock the screen while the mini-app shows (phones){ lock: 'portrait'|'landscape'|'any' }–
screen.brightnessBrighten the screen while it shows (tickets, QR codes); goes back after (phones){ level: 0..1 }–
resizeChange the mini-app's height{ height (px, 120 to 2000) }–
open.pageOpen a page of the space{ page }–
openOpen a button-style href: "#page-id" opens a page of the space, an https link opens outside (the person confirms){ href }–
open.linkOpen 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.

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 #

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.