← Back to vaho
Build

Creating a community mode

A community mode is a folder of HTML, CSS and JavaScript that vaho renders inside the notch. It's the format the store distributes: anyone can build one, anyone can install one with a click, and it runs in a sandbox — a mode only reaches what it declares, through vaho, and never your files or other apps.

The short version

  1. Copy the template that ships inside the app, at Contents/Resources/ModeKit-SDK/templates/.
  2. Edit manifest.json (unique id, name, permissions) and index.html.
  3. Put the folder at ~/.config/vaho/webmodes/<id>/ and press Rescan in Settings → Store → Installed, or use Add a mode from a folder…. It shows up in the notch's grid right away; edit, press Reload in the same list, repeat.
  4. Publish: Settings → Store → Installed → ⋯ → Publish to the store…

The folder

com.example.nowplaying/
  manifest.json      ← required
  index.html         ← required, the entry point
  style.css, app.js, images…   ← anything the page references

Only web file types are accepted: html, css, js, json, svg, png, jpg, gif, webp, fonts, mp3, wav, mp4, txt, md. No dotfiles, no symlinks, 20 MB max. The folder name must equal the manifest id.

manifest.json

{
  "id": "com.example.nowplaying",
  "name": "Now Playing",
  "version": "1.0.0",
  "author": "Your name",
  "summary": "One line, shown in the store and in Settings.",
  "icon": "waveform",
  "height": 180,
  "permissions": ["nowPlaying", "media"],
  "allowedHosts": ["api.example.com"],
  "minVahoVersion": "2.0.0"
}
FieldMeaning
idLowercase letters, digits, dots and dashes. Unique across the store. Frozen once published — favourites persist by it.
nameLabel under the icon. Keep it short: the grid tiles are small.
iconAn SF Symbol name (browse them in Apple's SF Symbols app).
heightTotal panel height in points, 120–440. The top 32 pt is vaho's mode bar; your page gets the rest. Default 180. Change it at runtime with vaho.notch.setHeight().
widthOptional custom width (300–700). Avoid it: a width change re-centres the notch.
permissionsWhat window.vaho will let you call. Shown in the store before install.
allowedHostsHosts vaho.net.fetch may reach. Exact host or *.example.com.

Permissions

PermissionUnlocks
nowPlayingvaho.media.nowPlaying() and the nowplaying event
mediavaho.media.playPause / next / previous / seek
batteryvaho.battery.get()
systemvaho.system.stats() — CPU, memory, network
clipboardvaho.clipboard.read / write
networkvaho.net.fetch to allowedHosts
openURLvaho.openURL() and links opening in the browser
shortcutsvaho.shortcuts.run(name)
notificationsvaho.notifications.post(title, body)
calendarvaho.calendar.next(count) — needs the user's Calendar grant
locationvaho.location.get() — needs the user's Location grant

Always available, no permission needed: vaho.theme.get(), vaho.storage.*, vaho.notch.*, vaho.haptic(), vaho.log(), and the theme / visible events.

The page

Include the kit for a native look:

<link rel="stylesheet" href="/__vaho/vaho.css">

It sets a transparent background, the system font and white text, and provides .vaho-panel, .vaho-card, .vaho-button (plus .primary, .accent), .vaho-list, .vaho-progress, .vaho-input and .vaho-title / .vaho-subtitle / .vaho-caption. Two CSS variables track the host: --vaho-tint (white, or black when the user picked dark icons) and --vaho-accent (the current album colour). The window.vaho object is injected before your scripts run — no script tag needed.

What the sandbox blocks: any network from page code (fetch, XHR, WebSocket — use vaho.net.fetch), navigation away from the mode, window.open, file://, and cookies or localStorage shared with anything else. Use vaho.storage for state that must survive.

Layout. The panel is 440 pt wide — minus the curved side walls, so keep about 40 pt of horizontal inset, which .vaho-panel already does — and height − 32 pt tall. Author in points; vaho scales the whole notch for the display, so you never read the screen size.

window.vaho

Every call returns a Promise. A call your manifest didn't ask permission for rejects with Error("permission: …").

await vaho.theme.get()            // { tint: "#ffffff", accent: "#7ec8ff", isDark: true }

await vaho.media.nowPlaying()     // { title, artist, isPlaying, position, duration, artwork | null }
await vaho.media.playPause(); vaho.media.next(); vaho.media.previous(); vaho.media.seek(42)

await vaho.battery.get()          // { level: 0…1, isCharging, hasBattery }
await vaho.system.stats()         // { cpu, memoryUsedBytes, memoryTotalBytes, netDownBytesPerSec, netUpBytesPerSec }

await vaho.clipboard.read()       // string | null
await vaho.clipboard.write("hi")

const r = await vaho.net.fetch("https://api.example.com/x", { method: "GET" })
r.status, r.ok, r.headers, r.text(), r.json()

await vaho.openURL("https://…")
await vaho.shortcuts.run("My Shortcut", "optional input")   // { status, output }
await vaho.notifications.post("Title", "Body")
await vaho.calendar.next(3)       // [{ title, start, end, isAllDay, location, calendar }]
await vaho.location.get()         // { access: "granted", latitude, longitude, city }

await vaho.storage.set("key", { any: "json" })
await vaho.storage.get("key"); vaho.storage.remove("key"); vaho.storage.keys()

await vaho.notch.setHeight(240)   // animate the panel; null restores the manifest height
await vaho.notch.close()          // collapse the notch
vaho.haptic(); vaho.log("debug", obj)

// Events pushed by the host
vaho.on("nowplaying", np => …)    // track/play state changed (needs nowPlaying)
vaho.on("theme", t => …)          // tint/accent changed (CSS vars already updated)
vaho.on("visible", v => …)        // { visible: true|false } — pause timers when hidden

The panel is unmounted every time the notch collapses, but the page itself stays alive in the background — its state and timers survive — so reopening is instant. Use the visible event to pause work while hidden.

Debugging

Publishing

Settings → Store → Installed → ⋯ → Publish to the store… zips the folder and uploads it. It appears in the catalog within a minute. The same Mac can publish an update by re-publishing with a higher version; other people can't overwrite your id. Users can report a mode, and the maintainers can take one down.

The catalog itself is public: index.json.

Stuck, or want someone to try your mode before you publish it? The Discord is the place.