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
- Copy the template that ships inside the app, at
Contents/Resources/ModeKit-SDK/templates/. - Edit
manifest.json(uniqueid,name,permissions) andindex.html. - 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. - 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"
}
| Field | Meaning |
|---|---|
id | Lowercase letters, digits, dots and dashes. Unique across the store. Frozen once published — favourites persist by it. |
name | Label under the icon. Keep it short: the grid tiles are small. |
icon | An SF Symbol name (browse them in Apple's SF Symbols app). |
height | Total 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(). |
width | Optional custom width (300–700). Avoid it: a width change re-centres the notch. |
permissions | What window.vaho will let you call. Shown in the store before install. |
allowedHosts | Hosts vaho.net.fetch may reach. Exact host or *.example.com. |
Permissions
| Permission | Unlocks |
|---|---|
nowPlaying | vaho.media.nowPlaying() and the nowplaying event |
media | vaho.media.playPause / next / previous / seek |
battery | vaho.battery.get() |
system | vaho.system.stats() — CPU, memory, network |
clipboard | vaho.clipboard.read / write |
network | vaho.net.fetch to allowedHosts |
openURL | vaho.openURL() and links opening in the browser |
shortcuts | vaho.shortcuts.run(name) |
notifications | vaho.notifications.post(title, body) |
calendar | vaho.calendar.next(count) — needs the user's Calendar grant |
location | vaho.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 — usevaho.net.fetch), navigation away from the mode,window.open,file://, and cookies or localStorage shared with anything else. Usevaho.storagefor 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
- Run a debug build and right-click the panel → Inspect Element; the web inspector is enabled there.
vaho.log(...)writes to/tmp/notch_debug.log, prefixedWebMode[<id>].- Settings → Store → Installed → ⋯ → Reload reloads the page without restarting vaho.
- A mode that fails validation — bad manifest, disallowed file — is skipped, with a line in the same log.
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.