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

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

FieldRequiredTypeDefaultRules
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❌integer300seconds, 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

FieldRequiredNotes
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
optionsrequired 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": "华氏" } }
    }
  }
}

2.3 Validation

Install, local load and minibar-cli validate share one ManifestValidator. Errors reject the package; warnings are shown and let it through.

LevelCondition
Errormissing required field; malformed value; network without allowedHosts; entry missing; duplicate settings.key; select without options; default of the wrong type
WarningallowedHosts 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

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); }
  }
});

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
};

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:

6.1 Basics

APISignatureNotes
ctx.now()() => Datecurrent time
ctx.localestringthe user's preferred language as a BCP 47 tag, e.g. "en-US", "zh-Hans-CN"; used for translations and Intl, regardless of the XPC service's own UI localizations
ctx.i18n(tables)({ [tag]: T }) => Tpicks 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) => voidwrites to the host log (visible in the plugin's settings page); objects are JSON-serialised; each line ≤ 2 KB
ctx.permissions.granted(name)(string) => booleanwhether 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

APISignatureNotes
ctx.storage.get(key, fallback?)(string, any?) => anyreturns fallback (default undefined) when missing
ctx.storage.set(key, value)(string, any) => voidvalue must be JSON-serialisable; undefined behaves like remove
ctx.storage.remove(key)(string) => void
ctx.storage.clear()() => void
ctx.storage.keys()() => string[]

6.3 Settings

APISignatureNotes
ctx.settings.get(key, fallback?)(string, any?) => anythe user's value; falls back to the manifest default, then to fallback
ctx.settings.all()() => objecta 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' };

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

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).

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

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

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

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).

typeRequiredOptional (default)Rendering
textvalue: stringstyle (body), color (primary), maxLines (0 = unlimited)one paragraph, wraps
iconsystemName: stringsize (14), color (primary)SF Symbol in a size × size alignment box
rowchildren: UINode[]spacing (6), align (center)horizontal stack
columnchildren: UINode[]spacing (6), align (leading)vertical stack
spacer—sizeflexible without size, fills the parent's main axis
divider——hairline
progressvalue: numbercolor (accent)0–1, clamped
buttonlabel: string + (action or url)payload (null), color, iconsmall bordered button
chartpoints: number[] (≥ 2)color (accent), height (32), min, max (auto)sparkline, ≤ 500 points
badgetext: stringcolor (accent)capsule
keyValuekey: string, value: stringcolor (applies to the value)grey key left, value right
gridchildren: UINode[]columns (2, 1–4), spacing (6)equal-width grid
horizontalScrollchildren: 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+
websrc: stringheight (200, 60–600), dataan 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:


7.1 Web cards (web node, micro-frontend)

{ "type": "web", "src": "ui/index.html", "height": 200, "data": { "count": 3 } }
FieldRequiredNotes
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; }
codeMeaning
permission_deniedplugin-level permission not granted (card shows "Needs X permission [Allow]")
os_access_deniedmacOS privacy permission refused (card shows "Open System Settings")
os_access_pendingthe macOS prompt has not been answered yet (card shows "Waiting for macOS permission"; the host re-renders once the user answers)
host_not_allowedrequest host not in the allowlist
scheme_not_allowednot https
not_in_actionan action-only API was called from render
invalid_argumentwrong argument type or range
storage_quota_exceededstorage above 1 MB
unavailablea host capability is currently unavailable (e.g. location services off)

8.2 Failures and auto-disable

SituationHost behaviour
render / action throws a non-permission errorcard 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 timeterminated at once and auto-disabled with reason "timed out" (host waits excluded)
malformed return valuecounts as one failure
permission_denied / os_access_denied / os_access_pendingnot counted; the card shows a guidance banner

An auto-disabled plugin can be re-enabled with one click.

8.3 Compatibility rules


9. Security model in one place


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

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.

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:

SourceMaintained byIn the app
Official https://minibar.084817.xyz/registry.jsonMinibarAlways 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 itUsers 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:

11.1 Publishing your own source

Anyone can run a source, for a team, for private plugins, or as a mirror:

  1. 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).
  2. Produce registry.json: every entry needs at least id, name, version, npm: { name, version }, download (the full tarball URL), sha256 (of the whole tgz), permissions and allowedHosts; optionally npm.registries lists the npm registries the app should try first, per region. scripts/build_registry.py in 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.
  3. Serve registry.json over https (any static host; http://127.0.0.1 works for local testing). Signing is optional: scripts/sign-registry.sh keygen creates a key pair and sign writes registry.sig next 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 in RegistryTrust.
  4. Users add the address under Settings › Market › External sources. Installs still verify every package against the sha256 in 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.