Minibar Plugin Guide
API v1 (host 0.3.x) · This is the complete contract between a plugin and the Minibar host. If a capability is not described here, it does not exist. The Chinese original lives in 03-plugin-api.md; when the two disagree, the host implementation (and its tests) is the reference.
A Minibar plugin ("MiniApp") is a folder with a manifest.json and one JavaScript file. It runs inside a sandboxed JavaScriptCore virtual machine in a separate XPC process, renders a small declarative UI tree, and can nominate one line of text for the single menu bar slot Minibar owns.
0. Quick start
# from a checkout of the Minibar repository
swift run minibar-cli init ./hello --id com.example.hello --name "Hello"
swift run minibar-cli validate ./hello
node tools/jsharness/run.js ./hello # render once with a mock host (any OS)
swift run minibar-cli run ./hello # render once with the real runtime (macOS)
swift run minibar-cli pack ./hello --out hello.zip
Install the folder from Settings › Plugins › Install from Folder…, or use the hammer button next to it (Link Plugin Folder…, also in the menu bar's right-click menu): Minibar then watches the folder and reloads the plugin on every save, keeping its data, settings and grants.
// hello/main.js
defineMiniApp({
id: 'com.example.hello',
render(ctx) {
const count = ctx.storage.get('count', 0);
return {
title: { text: `Hello ×${count}`, icon: 'hand.wave' },
body: [
ctx.ui.text('Rendered at ' + ctx.now().toLocaleTimeString(ctx.locale)),
ctx.ui.button('Bump', 'bump'),
],
};
},
actions: {
bump(ctx) { ctx.storage.set('count', ctx.storage.get('count', 0) + 1); },
},
});
1. Package layout
com.example.myplugin/
├── manifest.json required
├── main.js required (rename through manifest.entry)
├── package.json required to publish on npm (package metadata only; nothing in it runs)
├── README.md optional, shown on the market detail page
└── screenshots/ optional, PNG, ≤ 1 MB each, ≤ 5 files
- Only
.json/.js/.md/.png/.jpg/.txtfiles are allowed; at most 200 files and 20 MB uncompressed; no single file above 5 MB. - Only the
entryfile is executed. There is no module system, norequire, noimport. Bundle multi-file projects into one file with your favourite build tool. - Distribution format: an npm package. A market plugin is a package on npm (the tgz
npm packproduces, with itspackage/prefix); the host downloads it from npm or a nearby mirror and checks it against the SHA-256 pinned in the signed index.package.jsonrules: a validname, aversionequal to the manifest's,keywordscontaining"minibar-plugin",fileslistingmanifest.jsonand the entry file; noscripts(nothing ever runs on install) and nodependencies(they are not installed).minibar-cli initwrites one for you. - Local installs still accept a folder, a zip (
.zipor.minibar) or a tgz whose root is the layout above, or that has exactly one top-level folder containing it (npm'spackage/).
2. manifest.json
{
"id": "com.minibar.weather",
"name": "Weather",
"version": "1.0.0",
"description": "Current conditions and the next 6 hours.",
"author": "Minibar",
"homepage": "https://example.com/weather",
"icon": "cloud.sun",
"category": "life",
"entry": "main.js",
"refreshInterval": 900,
"minHostVersion": "0.1.0",
"permissions": ["network", "location"],
"allowedHosts": ["open-meteo.com"],
"settings": [
{ "key": "city", "type": "text", "label": "City", "placeholder": "Leave empty to use your location" },
{ "key": "unit", "type": "select", "label": "Unit", "default": "celsius",
"options": [{ "value": "celsius", "label": "°C" }, { "value": "fahrenheit", "label": "°F" }] }
]
}
2.1 Fields
| Field | Required | Type | Default | Rules |
|---|---|---|---|---|
id | ✅ | string | — | reverse-DNS, ^[a-z0-9]+(\.[a-z0-9-]+)+$, ≤ 64 chars, globally unique |
name | ✅ | string | — | 1–32 chars |
version | ✅ | string | — | SemVer MAJOR.MINOR.PATCH, optional -pre suffix |
description | ❌ | string | "" | ≤ 200 chars |
author | ❌ | string | "" | ≤ 64 chars |
homepage | ❌ | string | — | https:// URL |
icon | ❌ | string | "square.grid.2x2" | SF Symbol name; unknown names fall back to the default |
category | ❌ | string | "other" | productivity / finance / developer / system / life / other |
entry | ❌ | string | "main.js" | relative path, no .., no leading /, must exist |
refreshInterval | ❌ | integer | 300 | seconds, 5 ≤ n ≤ 86400 |
minHostVersion | ❌ | string | "0.1.0" | SemVer; older hosts refuse to load and show an upgrade hint |
permissions | ❌ | string[] | [] | see §6; duplicates are an error |
allowedHosts | ❌ | string[] | [] | host names (no scheme/port/path) or "*"; must be non-empty when network is declared |
allowedSchemes | ❌ | string[] | [] | URL schemes of other apps that ctx.openURL may open (things, obsidian…), ≤ 20; needs the apps permission; file/javascript/data are never allowed |
settings | ❌ | Setting[] | [] | ≤ 20 entries, unique key |
localizations | ❌ | object | {} | translations of name, description and setting strings keyed by language tag (zh-Hans, ja, pt-BR), see §2.4; ≤ 20 locales |
Unknown fields are ignored (forward compatibility).
A host entry matches itself and its subdomains: example.com allows api.example.com but not notexample.com. "*" allows every host and is shown to users in red.
2.2 Setting
| Field | Required | Notes |
|---|---|---|
key | ✅ | ^[a-zA-Z][a-zA-Z0-9_]{0,31}$ |
type | ✅ | text / secret / number / toggle / select |
label | ✅ | form label, ≤ 40 chars |
description | ❌ | help text under the field, ≤ 120 chars |
default | ❌ | must match the type: text/secret → string, number → number, toggle → boolean, select → one of the option values |
placeholder | ❌ | text / secret / number |
required | ❌ | default false; when true and empty, the card shows "Needs setup [Open Settings]" and render is not called |
min / max / step | ❌ | number |
options | required for select | [{ "value": string, "label": string }] or string[], 2–50 entries |
secret values are edited in a password field, stored separately from other settings, and never appear in logs or error messages.
2.4 Localization
"localizations": {
"zh-Hans": {
"name": "天气",
"description": "当前天气与未来几小时。",
"settings": {
"city": { "label": "城市", "description": "…", "placeholder": "留空则使用你的位置" },
"unit": { "label": "单位", "options": { "celsius": "摄氏", "fahrenheit": "华氏" } }
}
}
}
- The host matches the user's languages in order of preference:
zh-Hans-CN→zh-Hans→zh, then the next preferred language, and finally the manifest's base (English) strings; every field falls back on its own. - Translations affect the host UI only (card title, settings form, market, onboarding); text your plugin renders itself goes through
ctx.i18n({ en: {...}, zh: {...} })inmain.js(§6.1). - Unknown setting keys or option values are warned about and ignored; an invalid language tag is an error.
2.3 Validation
Install, local load and minibar-cli validate share one ManifestValidator. Errors reject the package; warnings are shown and let it through.
| Level | Condition |
|---|---|
| Error | missing required field; malformed value; network without allowedHosts; entry missing; duplicate settings.key; select without options; default of the wrong type |
| Warning | allowedHosts contains "*"; refreshInterval < 30; icon is not a known SF Symbol; no description |
3. Lifecycle
load host creates a VM → injects the bootstrap → runs entry → entry must call defineMiniApp exactly once, with an id matching the manifest
first render immediately after a successful load
timer render every refreshInterval × user multiplier × (0.9…1.1) seconds; a refreshIn in the previous result replaces the next interval once
action user taps a button → the action runs → render
change user edits settings / grants or revokes a permission / taps refresh → render
unload timers stop → storage is flushed → VM destroyed
- There is no background execution: no
setTimeout,setIntervalor Promise callbacks are ever scheduled. Everything happens inside the synchronous call stack ofrenderor an action. renderand actions run strictly one after another, never concurrently.- A single
renderor action may run at most 5 seconds of JavaScript. Exceeding it terminates the plugin and auto-disables it (§8). Time spent waiting on the host (ctx.http, calendar, reminders, location, notifications, clipboard) does not count toward those 5 seconds but has its own caps: one http request ≤ 15 s, and at most 30 s of host waiting per render or action, after which further host calls fail immediately withtimeout(http returnsok:false, the others throw).
4. defineMiniApp(config)
defineMiniApp({
// optional; when present it must equal manifest.id (catches copy-paste mistakes)
id: 'com.minibar.weather',
// required: returns a RenderResult (§5)
render(ctx) { /* ... */ },
// optional: name → function(ctx, payload); payload comes from button.payload, default null
actions: {
refresh(ctx) { ctx.storage.remove('cache'); },
open(ctx, payload) { ctx.openURL(payload.url); }
}
});
rendermust be a function;actions, when present, must be an object whose values are all functions.- Any exception thrown from
renderor an action is caught by the host and shown on the card. It never affects other plugins or the host. - Module-level variables persist between calls, but the VM can be recreated at any time (update, crash recovery, host restart). Anything that must survive goes into
ctx.storage.
5. RenderResult
type RenderResult = {
title: string | HeadlineTitle | null; // required (may be null)
body: UINode[]; // required, may be empty
menu?: MenuItem[]; // secondary actions for the card's ⋯ menu, ≤ 6 (v0.7)
urgency?: number; // 0–1, default 0; ≥ 0.8 preempts the headline slot
urgencyTTL?: number; // seconds, default 60, range 5–3600
refreshIn?: number; // seconds, ≥ 1; one-off override of the next refresh interval
};
type MenuItem = {
label: string; // ≤ 200 chars
action?: string; // as for button: a name in actions, or a host action such as $openSettings / $refresh
url?: string; // as for button: http(s) / mailto
payload?: unknown; // passed to the action as is
icon?: string; // SF Symbol, optional
};
type HeadlineTitle = {
text: string; // ≤ 40 chars; the host truncates further to the user's width setting
icon?: string; // SF Symbol; defaults to manifest.icon
color?: ColorToken; // only success / warning / danger tint the menu bar text
};
- A string
titleis shorthand for{ text }. title: nullmeans "not a headline candidate this round" (for example a calendar with nothing left today). When the user has pinned (fixed) that plugin, the menu bar shows only its icon.- Card space is for information. The host already puts a refresh button in the card header, and "edit / change / settings" actions (
$openSettingsand friends) belong inmenu: the host lists them at the top of the card's ⋯ menu, above its own "Plugin Settings…", "Move Up / Down" and "Disable". Buttons inbodyare for actions on the content itself (start a focus session, complete a reminder, free memory, open that PR) or for guidance in empty and error states. A missing or emptymenuleaves only the host's items; invalid entries are dropped with a warning. - A
bodywith more than 500 nodes or deeper than 32 levels is truncated with a logged warning. - A non-object result, a missing
body, or a non-arraybodycounts as a failed render.
How the menu bar slot is chosen
The user picks one of four policies in Settings: fixed (always one plugin), rotate (cycle every N seconds, default 30), priority (score = user order × 0.6 + freshness × 0.3 + urgency × 0.1) and event (newest urgent result wins). Any result with urgency ≥ 0.8 preempts rotate, priority and event for urgencyTTL seconds; it never preempts a fixed pin. Use urgency sparingly: a timer that just finished, a build that just failed, a price alert that just fired.
6. The ctx API
Every API is synchronous. Two styles of result:
- Data APIs (
ctx.http.*) never throw; they return{ ok, ... }objects because network failure is normal. - Everything else returns a plain value and throws
MinibarError(§8) on misuse: permission not granted,openURLoutside an action, invalid arguments. Apermission_deniedthrown fromrenderturns the card into "Needs X permission [Allow]" instead of an error.
6.1 Basics
| API | Signature | Notes |
|---|---|---|
ctx.now() | () => Date | current time |
ctx.locale | string | the Minibar app's current language as a BCP 47 tag, e.g. "en-US", "zh-Hans-CN". The host hands its language list to the runtime service on every connection, so a per-app language chosen in System Settings › Apps › Minibar applies to plugin text too; used for translations and Intl |
ctx.i18n(tables) | ({ [tag]: T }) => T | picks one table by ctx.locale: the exact tag (zh-Hans-CN), then shorter prefixes (zh-Hans, zh), then en, then the first table. Tables may hold strings or functions; the bundled plugins translate their card text this way: const t = ctx.i18n({ en: {...}, zh: {...} }). Minibar 0.5.0+ |
ctx.host | { version: string, api: 1 } | host version and API version |
ctx.log(...args) | (...any) => void | writes to the host log (visible in the plugin's settings page); objects are JSON-serialised; each line ≤ 2 KB |
ctx.permissions.granted(name) | (string) => boolean | whether the user granted a permission, for graceful degradation (render another branch instead of throwing) |
ctx.permissions.list() | () => string[] | every permission currently granted |
6.2 Storage
| API | Signature | Notes |
|---|---|---|
ctx.storage.get(key, fallback?) | (string, any?) => any | returns fallback (default undefined) when missing |
ctx.storage.set(key, value) | (string, any) => void | value must be JSON-serialisable; undefined behaves like remove |
ctx.storage.remove(key) | (string) => void | |
ctx.storage.clear() | () => void | |
ctx.storage.keys() | () => string[] |
- 1 MB per plugin;
setthrowsstorage_quota_exceededbeyond that. - Cleared on uninstall, kept on update. Writes are flushed to disk shortly after each call and on unload.
6.3 Settings
| API | Signature | Notes |
|---|---|---|
ctx.settings.get(key, fallback?) | (string, any?) => any | the user's value; falls back to the manifest default, then to fallback |
ctx.settings.all() | () => object | a copy of every setting |
Settings are read-only from the plugin. To let the user change them, render a button with the $openSettings host action.
6.4 Network (permission network)
ctx.http.get(url: string, headers?: Record<string,string>): HttpResponse
ctx.http.getJSON(url: string, headers?: Record<string,string>): HttpJSONResponse
ctx.http.request(url: string, options: {
method?: 'GET'|'POST'|'PUT'|'PATCH'|'DELETE'|'HEAD';
headers?: Record<string,string>;
body?: string; // a string; JSON must be stringified by the plugin, with its Content-Type
timeout?: number; // milliseconds, default and maximum 15000
cache?: boolean; // GET only, default true: 30-second in-memory cache
}): HttpResponse
type HttpResponse = {
ok: boolean; // 2xx and no transport error
status: number; // 0 when no response arrived
body: string;
headers: Record<string,string>; // lower-case keys
error?: 'timeout' | 'too_large' | 'network' | 'http_error';
cached?: boolean;
};
type HttpJSONResponse = Omit<HttpResponse, 'body'> & { json: any; error?: HttpResponse['error'] | 'invalid_json' };
- Throws (misuse):
permission_denied,host_not_allowed(host not inallowedHosts),scheme_not_allowed(not https; localhost is the exception),invalid_argument. - Returns
ok: false(ordinary failure): timeout, response above 5 MB, DNS/connection errors, non-2xx status. - Redirects are followed and the target host is checked again; a redirect outside the allowlist comes back as
ok:false, error:'network'because it happens mid-request. - The host adds
User-Agent: Minibar/<version>; headers can override it. - Binary responses are not supported: the body is decoded as UTF-8 and failure is reported as
error: 'network'.
6.5 System metrics
ctx.system(): {
timestamp: number; // ms
osVersion: string; // "14.5"
processorCount: number;
hardware: { model: string | null; chip: string | null; memoryTotal: number; // "Mac14,9", "Apple M2 Pro"
cores: { performance: number; efficiency: number } | null }; // Apple silicon only; null on Intel
cpu: { usage: number; user: number; system: number }; // 0–1
load: { '1m': number; '5m': number; '15m': number };
memory: { used: number; free: number; total: number; percent: number; // bytes
pressure: 'normal' | 'warning' | 'critical'; swapUsed: number }; // kernel memory pressure; swap in use
disk: { used: number; free: number; total: number; percent: number }; // bytes, boot volume
network: { rxBytesPerSec: number; txBytesPerSec: number };
battery: { percent: number; charging: boolean; timeToEmptyMinutes: number | null } | null; // null on desktops
}
No permission required. Sampling is shared and refreshed at most once per second. Not included: host name, user name, serial number, IP or MAC addresses, uptime.
6.6 Calendar (permission calendar)
ctx.calendar.upcoming(options?: { hours?: number; limit?: number; includeAllDay?: boolean }): CalendarEvent[]
// hours default 24 (1–168), limit default 10 (1–50), includeAllDay default false
type CalendarEvent = {
id: string; title: string;
start: number; end: number; // ms
allDay: boolean;
location: string | null;
calendar: { title: string; color: string | null }; // color: "#RRGGBB"
url: string | null;
};
Throws permission_denied (plugin-level grant missing) or os_access_denied (the user refused macOS calendar access; the card offers a link to System Settings). Sorted by start; events that already ended are omitted.
Creating an event (action only):
ctx.calendar.add(options: {
title: string; start: number; end?: number; // ms; end defaults to start + 1 hour, must follow start and stay within a year
allDay?: boolean; calendar?: string; // calendar: a calendar's name, default calendar otherwise
location?: string; notes?: string; url?: string; alert?: number; // alert: minutes before start
}): { id: string; title: string; start: number; end: number; allDay: boolean; calendar: string }
Only calendars that allow modification; a name that does not exist throws invalid_argument. Titles ≤ 200 characters, notes ≤ 2000.
6.7 Reminders (permission reminders)
ctx.reminders.incomplete(options?: { list?: string; limit?: number }): Reminder[]
// list: list name, default all; limit default 50 (1–200)
type Reminder = { id: string; title: string; due: number | null; priority: number; list: string };
Throws as §6.6. Sorted by due; reminders without a due date come last.
Creating and completing (action only):
ctx.reminders.add(options: { title: string; due?: number; allDay?: boolean; list?: string; notes?: string; priority?: number; url?: string }):
{ id: string; title: string; due: number | null; list: string }
ctx.reminders.complete(id: string, completed?: boolean): { id: string; title: string; completed: boolean } // completed defaults to true
add:listdefaults to the system's default list;dueis a millisecond timestamp, and a due date with a time also gets an alarm (allDay: truekeeps the date only);priorityis 0 (none) to 9, matching Reminders' high 1 / medium 5 / low 9.complete:idcomes fromincomplete(); passfalseto reopen. The bundled Reminders plugin's per-row Done button is exactly this.- No deleting, moving or bulk edits.
6.8 Location (permission location)
ctx.location(): { latitude: number; longitude: number; accuracy: number; city: string | null; updatedAt: number }
The host caches the fix for 15 minutes. city comes from reverse geocoding and may be null. Throws permission_denied / os_access_denied / unavailable.
6.9 Notifications (permission notifications)
ctx.notify(title: string, body?: string, options?: { url?: string }): boolean
Returns false when rate-limited (one notification per plugin per 60 seconds). url opens when the notification is clicked. Throws permission_denied, invalid_argument.
6.10 Clipboard (permission clipboard)
ctx.clipboard(): string // current clipboard text; "" when there is none (same as ctx.clipboard.read())
ctx.clipboard.read(): string
ctx.clipboard.write(text: string): { written: true } // action only; ≤ 100 KB
Throws permission_denied. The first read in each app session additionally requires the user to confirm on the card ("Confirm Clipboard access for this session [Allow]"); until then it throws permission_denied too. Writing needs no session confirmation but is action-only (not_in_action from render).
6.11 Opening links
ctx.openURL(url: string): void
Only from an action; calling it inside render throws not_in_action. http, https and mailto need no permission; any other scheme must be listed in the manifest's allowedSchemes and needs the apps permission (things:///add?title=…); everything else throws invalid_argument.
6.12 UI builders
ctx.ui is a set of convenience functions returning plain objects. Writing the object literals by hand is equivalent.
ctx.ui.text(value, opts?) // { style, color, maxLines }
ctx.ui.icon(systemName, opts?) // { size, color }
ctx.ui.row(children, opts?) // { spacing, align: 'top'|'center'|'bottom' }
ctx.ui.column(children, opts?) // { spacing, align: 'leading'|'center'|'trailing' }
ctx.ui.spacer(size?) // no argument = flexible
ctx.ui.divider()
ctx.ui.progress(value, opts?) // 0–1; { color }
ctx.ui.button(label, action, opts?) // action: string | { action?, payload?, url? }; opts: { color, icon }
ctx.ui.chart(points, opts?) // number[]; { color, height, min, max }
ctx.ui.badge(text, opts?) // { color }
ctx.ui.keyValue(key, value, opts?) // { color }
ctx.ui.grid(children, opts?) // { columns, spacing }
ctx.ui.horizontalScroll(children, opts?) // { itemWidth, spacing }; Minibar 0.5.0+
6.13 System actions (permission system)
Do something to this Mac. Only from an action (calling from render throws not_in_action), and the user must have granted system.
ctx.freeMemory(): { freeBefore: number; freeAfter: number; freedBytes: number; allocatedBytes: number; seconds: number } // bytes
ctx.runShortcut(name: string, input?: string): { started: true }
ctx.keepAwake(enabled?: boolean): { enabled: boolean } // default true
freeMemory: the host briefly allocates and touches a large block so the kernel reclaims inactive and cached pages, then releases it (capped at half of physical memory and 3 seconds; stops early when free pages run low). Returns free bytes before and after. The effect is more free memory, not a faster Mac; it is most useful behind a button shown whenctx.system().memory.pressureiswarningorcritical.runShortcut: runs one of the user's own Shortcuts throughshortcuts://run-shortcut, passinginputas text. What the shortcut does is up to the user, which makes this the general-purpose way for a plugin to reach the rest of the system (open apps, control devices, call any service). Names are limited to 100 characters.keepAwake: prevents idle system sleep (IOPMAssertion; the display may still sleep). One assertion per plugin, released automatically when the plugin is disabled, losessystem, is uninstalled or the app quits, so offer an off button in your UI and callkeepAwake(false)when appropriate.- Throws
permission_denied,not_in_action,invalid_argument,unavailable.
6.14 Other apps (permission apps)
ctx.apps.running(): { name: string; bundleID: string; active: boolean; hidden: boolean }[] // any phase
ctx.apps.open(bundleID: string): { opened: true } // action only
running()lists regular (Dock-visible) apps only: no window titles, paths or process arguments.open()launches or activates an installed app;unavailablewhen it is not installed.- Together with
allowedSchemes,ctx.openURL('obsidian://open?vault=…')reaches other apps' URL schemes (§6.11).
6.15 Crypto and encoding (no permission)
ctx.crypto.sha256(text: string): string // lower-case hex
ctx.crypto.hmacSHA256(key: string, message: string): string
ctx.crypto.base64Encode(text: string): string
ctx.crypto.base64Decode(text: string): string // invalid input throws invalid_argument
ctx.crypto.randomUUID(): string
JavaScriptCore has no crypto, atob or btoa; use these to sign API requests (exchanges, cloud services). Inputs are treated as UTF-8.
6.16 Files (permission files)
A plugin cannot read any path it was not given: whatever the user picks by hand in the system file dialog is what the plugin can read (read-only).
ctx.files.pick(options?: { folders?: boolean; multiple?: boolean; types?: string[]; message?: string }): { opened: true } // action only
ctx.files.list(): { id: string; name: string; path: string; kind: 'file' | 'folder'; available: boolean; addedAt: number }[]
ctx.files.entries(id: string, options?: { hidden?: boolean; limit?: number }):
{ id: string; name: string; kind: 'file' | 'folder' | 'symlink' | 'other'; size: number | null; modified: number | null }[]
ctx.files.read(id: string, options?: { encoding?: 'utf8' | 'base64'; maxBytes?: number }):
{ id: string; name: string; size: number; content: string; encoding: string; truncated: boolean; modified: number | null }
ctx.files.forget(id: string): { forgotten: boolean } // action only
pick()returns at once. Minibar shows the dialog, names the plugin that is asking and re-renders the plugin after the user chose, sorendersees the new items throughlist().folders: truepicks folders (files otherwise),typesis a list of extensions (['md', 'txt']),message(≤ 120 characters) is shown at the top of the dialog. One dialog per plugin at a time.entries()lists direct children only (no recursion), skips hidden files unlesshidden: true, returns at most 500 entries sorted by name. A child'sidlooks like"f_1a2b3c4d/notes/today.md"and can be passed toentries()/read()again..., absolute paths and symlinks that leave the picked folder areinvalid_argument.read()returns at most 1 MB (maxBytescan lower that); the rest is not read andtruncatedistrue.utf8(default) decodes as UTF-8 (invalid bytes become �); usebase64for binary data.- Items that were moved, deleted or sit on a disconnected disk show
available: falseinlist(), andread()/entries()throwunavailable: offer a "pick again" button. - The user can forget items one by one or all at once under Settings › Plugins › "Files this plugin can read"; switching
filesoff clears everything. At most 50 items per plugin. - Throws
permission_denied,not_in_action,invalid_argument,unavailable. - Example:
docs/examples/notes-folder/(pick a notes folder; the headline counts today's edits). Locally,node tools/jsharness/run.js docs/examples/notes-folder --file ~/Notesplays the role of the file dialog.
7. UI DSL
Every node is { "type": string, ...fields }. Unknown types are ignored silently; a known type with a missing or mistyped required field is dropped with a logged warning (the rest of the render survives).
| type | Required | Optional (default) | Rendering |
|---|---|---|---|
text | value: string | style (body), color (primary), maxLines (0 = unlimited) | one paragraph, wraps |
icon | systemName: string | size (14), color (primary) | SF Symbol in a size × size alignment box |
row | children: UINode[] | spacing (6), align (center) | horizontal stack |
column | children: UINode[] | spacing (6), align (leading) | vertical stack |
spacer | — | size | flexible without size, fills the parent's main axis |
divider | — | — | hairline |
progress | value: number | color (accent) | 0–1, clamped |
button | label: string + (action or url) | payload (null), color, icon | small bordered button |
chart | points: number[] (≥ 2) | color (accent), height (32), min, max (auto) | sparkline, ≤ 500 points |
badge | text: string | color (accent) | capsule |
keyValue | key: string, value: string | color (applies to the value) | grey key left, value right |
grid | children: UINode[] | columns (2, 1–4), spacing (6) | equal-width grid |
horizontalScroll | children: UINode[] | itemWidth (56, 32–200), spacing (6, 0–64) | one horizontal scrolling row with equal-width items aligned at the top; height fits the content and width stays within the card; Minibar 0.5.0+ |
web | src: string | height (200, 60–600), data | an HTML page from the package rendered inline, see §7.1 |
horizontalScroll keeps every item in one row. It uses system horizontal scrolling gestures and scroll indicators, with space below the content for the indicator; vertical scrolling remains with the dashboard. Its children share the global 500-node / 32-level limits. Plugins depending on this node should declare minHostVersion: "0.5.0".
style: title (17 pt semibold) / headline (13 pt semibold) / body (13 pt) / caption (11 pt secondary) / mono (12 pt monospaced, tabular digits)
ColorToken: primary / secondary / accent / success / warning / danger / muted
button.action:
- a plain name → calls
actions[name](ctx, payload), then renders; $openSettings→ the host opens this plugin's settings form;$refresh→ the host renders immediately (no action is called);$allow:<permission>→ grants one permission declared in the manifest on tap (for example$allow:location), then renders. Undeclared permissions are ignored;urltogether withaction→ the action runs first, then the URL opens;- any other
$action → ignored with a logged warning.
7.1 Web cards (web node, micro-frontend)
{ "type": "web", "src": "ui/index.html", "height": 200, "data": { "count": 3 } }
| Field | Required | Notes |
|---|---|---|
src | ✅ | an .html file inside the plugin package (relative path, cannot escape the package) |
height | ❌ | 60–600 pt, default 200; the page may adjust it with minibar.resize(h) |
data | ❌ | any JSON (≤ 64 KB), pushed to the page on every render |
window.minibar, injected before the document runs:
minibar.data // current data
minibar.onData((data) => {}) // called once now and after every render; returns an unsubscribe function
minibar.action(name, payload?) // runs the plugin's actions[name] ($openSettings / $refresh / $allow:<permission> work too)
minibar.resize(height) // request a card height (60–600)
minibar.log(...args) // writes to the plugin log
minibar.locale, minibar.theme.dark
Rules: resources come from the package only (<script src="app.js"> with a relative path; bundle React / Vue / Svelte into the package, with Vite set base: './'); no network, iframes, forms or persistent storage, so fetch in main.js with ctx.http and pass the result as data; clicked http/https/mailto links open in the browser; theme colors arrive as --minibar-bg / --minibar-text / --minibar-secondary / --minibar-accent / --minibar-success / --minibar-warning / --minibar-danger / --minibar-separator CSS variables and prefers-color-scheme works; the web view exists only while the card is visible and is torn down when the dashboard closes. Full example: docs/examples/web-card/.
8. Errors and compatibility
8.1 MinibarError
class MinibarError extends Error { code: string; }
| code | Meaning |
|---|---|
permission_denied | plugin-level permission not granted (card shows "Needs X permission [Allow]") |
os_access_denied | macOS privacy permission refused (card shows "Open System Settings") |
os_access_pending | the macOS prompt has not been answered yet (card shows "Waiting for macOS permission"; the host re-renders once the user answers) |
host_not_allowed | request host not in the allowlist |
scheme_not_allowed | not https |
not_in_action | an action-only API was called from render |
invalid_argument | wrong argument type or range |
storage_quota_exceeded | storage above 1 MB |
unavailable | a host capability is currently unavailable (e.g. location services off) |
8.2 Failures and auto-disable
| Situation | Host behaviour |
|---|---|
render / action throws a non-permission error | card shows the message and a stack summary; the plugin is skipped for the headline; 3 failures in a row → auto-disabled |
| a single call exceeds 5 seconds of JavaScript time | terminated at once and auto-disabled with reason "timed out" (host waits excluded) |
| malformed return value | counts as one failure |
permission_denied / os_access_denied / os_access_pending | not counted; the card shows a guidance banner |
An auto-disabled plugin can be re-enabled with one click.
8.3 Compatibility rules
ctx.host.apiis the integer API version, currently1. Within one API version the host only adds; existing semantics never change.- New node types, fields and
ctxmethods may arrive in minor versions. Old hosts ignore unknown nodes and fields; a new method is simplyundefinedon an old host, so feature-detect or raiseminHostVersion. - Removals or semantic changes only happen in API v2, which plugins will opt into with
api: 2indefineMiniApp.
9. Security model in one place
- No file system, processes, shell, DOM,
fetch,XMLHttpRequest,setTimeout,require. - Network only to
allowedHosts, https only. - System metrics carry nothing identifying.
- Calendar, reminders, location, clipboard, notifications and system actions each need the user's explicit, per-plugin consent at install time and can be revoked at any time. Calendar, reminders and location additionally go through the macOS privacy prompt.
- System actions (free up memory, run a Shortcut) only respond to a user tap.
openURLonly responds to a user tap.- 5 seconds of JavaScript per call plus 30 seconds of host waiting, 1 MB storage, 5 MB response, 500 UI nodes.
- Source is viewable on the market detail page and in the plugin's settings.
10. Complete example: Stocks
// com.minibar.stocks/main.js
defineMiniApp({
id: 'com.minibar.stocks',
render(ctx) {
const symbols = String(ctx.settings.get('symbols', 'AAPL,NVDA,TSLA'))
.split(',').map(s => s.trim().toUpperCase()).filter(Boolean).slice(0, 10);
if (symbols.length === 0) {
return { title: null, body: [ctx.ui.text('Add some symbols to get started.'),
ctx.ui.button('Set symbols', '$openSettings')] };
}
const cache = ctx.storage.get('quotes', { ts: 0, data: {} });
if (Date.now() - cache.ts > 60_000) {
const data = { ...cache.data };
for (const sym of symbols) {
const res = ctx.http.getJSON(
`https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(sym)}?range=1d&interval=1d`);
const meta = res.ok && res.json?.chart?.result?.[0]?.meta;
if (meta && meta.regularMarketPrice != null) {
const prev = meta.chartPreviousClose ?? meta.previousClose ?? meta.regularMarketPrice;
data[sym] = { price: meta.regularMarketPrice, change: prev ? (meta.regularMarketPrice - prev) / prev * 100 : 0 };
}
}
ctx.storage.set('quotes', { ts: Date.now(), data });
}
const quotes = ctx.storage.get('quotes', { data: {} }).data;
const first = quotes[symbols[0]];
const rows = symbols.map(sym => {
const q = quotes[sym];
if (!q) return ctx.ui.keyValue(sym, '—');
const up = q.change >= 0;
return ctx.ui.row([
ctx.ui.text(sym, { style: 'mono' }),
ctx.ui.spacer(),
ctx.ui.text(q.price.toFixed(2), { style: 'mono' }),
ctx.ui.badge((up ? '+' : '') + q.change.toFixed(2) + '%', { color: up ? 'success' : 'danger' }),
], { spacing: 8 });
});
return {
title: first
? { text: `${symbols[0]} ${first.price.toFixed(0)} ${first.change >= 0 ? '▲' : '▼'}${Math.abs(first.change).toFixed(1)}%`,
icon: 'chart.line.uptrend.xyaxis', color: first.change >= 0 ? 'success' : 'danger' }
: { text: 'Loading…', icon: 'chart.line.uptrend.xyaxis' },
body: [
ctx.ui.text('Watchlist', { style: 'headline' }),
ctx.ui.divider(),
ctx.ui.column(rows, { spacing: 6 }),
ctx.ui.spacer(4),
ctx.ui.row([ctx.ui.button('Refresh', 'refresh'), ctx.ui.spacer(), ctx.ui.button('Edit list', '$openSettings')]),
],
};
},
actions: {
refresh(ctx) { ctx.storage.remove('quotes'); },
},
});
Its manifest:
{
"id": "com.minibar.stocks", "name": "Stocks", "version": "1.0.0",
"description": "Your watchlist, color-coded.", "author": "Minibar",
"icon": "chart.line.uptrend.xyaxis", "category": "finance",
"refreshInterval": 60, "permissions": ["network"], "allowedHosts": ["finance.yahoo.com"],
"settings": [{ "key": "symbols", "type": "text", "label": "Symbols", "default": "AAPL,NVDA,TSLA",
"description": "Comma separated, up to 10." }]
}
11. Developing and debugging
- Local install: Settings › Plugins › Install from Folder…, pick the plugin directory. After editing, tap refresh on the card.
- Developer mode: Settings › Plugins › 🔨 Link Plugin Folder… (or the menu bar's right-click menu). Every save re-validates the manifest and reloads the plugin; a broken manifest shows its errors right on the card. "Reload Now" and "Unlink" live in the plugin's settings page.
- Logs: Settings › Plugins › select the plugin › Logs.
ctx.logoutput and error stacks land there. - CLI:
minibar-cli init <dir>scaffolds a plugin;validate <dir>checks the manifest;run <dir>renders once with the real runtime and prints the title and UI tree (macOS);pack <dir>builds the zip and prints its SHA-256. - Node smoke test (
tools/jsharnessin the repository): renders with a mock host on any platform;--networkperforms real requests through curl;--set key=valueand--action name[=json]drive settings and actions.
12. Publishing
A registry is a signed registry.json plus zip packages served over https (Minibar's own is hosted on Vercel at https://minibar.084817.xyz, built by scripts/build_registry.py and served by server/; stats.json next to it carries aggregate download counts). Each entry carries the manifest, a download URL, the package SHA-256 and optional screenshots. Third-party submissions open with the 1.5 release; until then, local install and direct zip sharing are the distribution paths.
Sharing an install link
Put minibar://market/<plugin id> on your website or README (for example minibar://market/com.example.notesfolder): Minibar opens the Market at that plugin and the user confirms its permissions before anything is installed; the link itself never installs. minibar://install?id=<plugin id> is equivalent. The plugin must already be published in the registry.
11. Market sources: the official source and external sources
The Market lists plugins from a number of sources. A source is one registry.json (the §8.1 format, schemaVersion 2) plus the npm packages it points at:
| Source | Maintained by | In the app |
|---|---|---|
Official https://minibar.084817.xyz/registry.json | Minibar | Always present, cannot be changed or removed; must carry registry.sig, a failed signature check rejects it; the Market shows "Verified source" |
| External (any number) | Whoever runs it | Users add or remove them by address under Settings › Market › External sources; may be unsigned ("Unsigned"); every listing wears the source's host as a tag and the detail page says "External source: host" |
Merging rules:
- The official source comes first, then external sources in the order added. One plugin id, one listing: the earliest source wins, so an external source can never shadow an official plugin, and between two external sources the one added first wins (the log notes the duplicate).
- An id in the official source's
revokedlist is blocked everywhere; an external source'srevokedlist only affects plugins installed from it. Removing an external source leaves its installed plugins running; they just stop receiving updates. - Download counts and the
featuredlist come from each source itself; external plugins have no official download counts.
11.1 Publishing your own source
Anyone can run a source, for a team, for private plugins, or as a mirror:
- Package plugins as in §1 and publish them to any npm-compatible registry (npmjs, a Verdaccio on your network, or static files laid out like the official host:
<source>/<name>/-/<basename>-<version>.tgz). - Produce
registry.json: every entry needs at leastid,name,version,npm: { name, version },download(the full tarball URL),sha256(of the whole tgz),permissionsandallowedHosts; optionallynpm.registrieslists the npm registries the app should try first, per region.scripts/build_registry.pyin the repository is the official source's generator;--resolve-from <your npm registry>downloads the published packages, checks their manifests and computes the hashes for you. - Serve
registry.jsonover https (any static host;http://127.0.0.1works for local testing). Signing is optional:scripts/sign-registry.sh keygencreates a key pair andsignwritesregistry.signext to the index. The app only trusts the official public key, so an external source shows as unsigned even when signed; an internal build can embed another key inRegistryTrust. - Users add the address under Settings › Market › External sources. Installs still verify every package against the
sha256in your index, run the plugin in the sandbox and ask for each permission.
To be listed in the official source, follow the submission process in §10 / plugin-submission.en.md.