Runtime protocol
Version 1 of the contract between the Blipbar app, the Node runtime inside it, the SDK
(@blipbar/api), and extensions.
Pieces
Section titled “Pieces”| Piece | Language | Job |
|---|---|---|
| Host | Swift (the Mac app) | Finds extensions, owns scheduling, triggers (interval, webhook, file watch), secrets, and the store of live blips. Spawns and supervises the runtime. |
| Runtime | TypeScript, bundled into the app | One long-lived Node process. One worker_thread per extension. Validates payloads, stamps seq, checks permissions. |
| SDK | TypeScript | @blipbar/api and its blipkit CLI: types, builders, the integration kit, and the dev CLI. |
| Extensions | TypeScript | Blipbar’s own and yours, built with blipkit build. |
The app ships its own Node (Blipbar.app/Contents/Resources/runtime/node), version 22 or later.
Files on disk
Section titled “Files on disk”~/Library/Application Support/Blipbar/ Extensions/<extension-id>/ user-installed or dev-linked extensions (a folder or a symlink) Data/<extension-id>/ per-extension storage, owned by the runtime webhook.json {"port": 47811, "token": "<random>"} (mode 0600, rewritten at launch) bin/blipbar-hook helper that agent hooks call (installed by the host)Blipbar.app/Contents/Resources/Extensions/<extension-id>/ first-party extensions shipped with the appAn extension folder contains package.json (with the manifest) and dist/index.js, plus dist/tools.json
when it has tools (written by blipkit build; see “Tools” below). An extension in the user
folder overrides a bundled one with the same id. The host watches both folders. When anything in an
extension’s folder changes, the host reloads that extension, which is how hot reload works.
Manifest (package.json → blipbar)
Section titled “Manifest (package.json → blipbar)”{ "name": "blipbar-stripe", "version": "1.0.0", "main": "dist/index.js", "blipbar": { "id": "dev.yourname.stripe", "title": "Stripe", "description": "Today's revenue, live.", "icon": "creditcard.fill", "author": "Your Name", "categories": ["Finance"], "interval": "30s", "triggers": { "webhook": false, "watch": [] }, "preferences": [ { "name": "apiKey", "title": "Restricted key", "type": "password", "required": true, "placeholder": "rk_live_…", "description": "Read-only access to charges." }, { "name": "currency", "title": "Currency", "type": "dropdown", "default": "usd", "data": [{ "title": "US Dollar", "value": "usd" }] }, { "name": "includeRefunds", "title": "Include refunds", "type": "checkbox", "default": false } ], "permissions": { "network": ["api.stripe.com"], "files": [], "exec": [] } }}id: reverse-DNS, and stable.icon: an SF Symbol name.interval:"10s","5m","1h". The minimum is 10s. The host schedules runs with tolerance and pauses them while the screen is asleep or locked.- Preference
type:textfield,password(stored in the Keychain by the host),checkbox,dropdown, ornumber. Atextfieldwithmultiline: trueholds a few entries, one per line (the notch shows them joined by “; “, so read either).blips: ["repo.", "inbox"]shows an option in place only on those blips (keys, or the start of keys). permissions.network: the hostnamesfetchmay reach (*.example.comis allowed). Empty meansfetchreaches nothing.permissions.files: paths (with~) the extension may read or watch.permissions.exec: the executablesctx.execmay run.triggers.webhook: the extension receivesPOST /v1/hooks/<id>bodies as events.triggers.watch: paths whose changes are delivered as events. They must also be listed inpermissions.files.oauth: services the app signs in to for the extension, by name (the integration kit). Each is{title?, authorizeUrl, tokenUrl, revokeUrl?, clientId, scopes?, scopeSeparator?, params?}: OAuth 2.0 authorization code with PKCE, public clients only (there’s no client secret), every addresshttps, and the token and revoke hosts inpermissions.network. The redirect is alwayshttp://127.0.0.1:47812/oauth/callback, which the service’s app must register exactly. A mistake here shows in Settings and inblipkit validate; it never hides the extension.
"oauth": { "linear": { "title": "Linear", "authorizeUrl": "https://linear.app/oauth/authorize", "tokenUrl": "https://api.linear.app/oauth/token", "revokeUrl": "https://api.linear.app/oauth/revoke", "clientId": "your-public-client-id", "scopes": ["read", "write"], "scopeSeparator": "," }}Wire protocol: host ⇄ runtime
Section titled “Wire protocol: host ⇄ runtime”The runtime speaks NDJSON JSON-RPC 2.0 on its stdin/stdout: one JSON object per line, UTF-8. Its stderr carries free-form diagnostics, which the host logs. Either side may send requests and notifications.
Host → runtime (requests)
Section titled “Host → runtime (requests)”| Method | Params | Result |
|---|---|---|
initialize |
{protocolVersion: 1, dataDir, locale, timeZone} |
{protocolVersion: 1, runtimeVersion, nodeVersion} |
extension.load |
{id, path, manifest, preferences}. manifest is the blipbar object plus version. preferences holds resolved values, secrets included. |
{} |
extension.unload |
{id} |
{} |
extension.run |
{id, trigger: {type: "launch" | "interval" | "manual" | "preferences"}} |
{nextRunAfter?: number} in seconds, for the next run only. A webhook event cuts a longer wait back to the manifest’s interval, so an extension with nothing live can sleep long. Resolves when update() settles. The host times out after 30s. |
extension.event |
{id, event, replyId?}. event is {type: "webhook", body, receivedAt} or {type: "file", paths: string[]} |
{} |
extension.action |
{id, blipId, actionId, input?, itemId?}. itemId is the list item whose button it was. |
{emitted?: {"<blipId>": seq}}: the blips onAction updated, and the seq of each one’s last update, sent only after those updates. The host shows a button pressed until this answer, and an item a dismisses action took out of its list stays out until the update with that seq (or, with none, the next one), which then decides. A failed or timed-out action brings the item back at once. |
tool.run |
{id, tool, action, values}. tool is the tool’s name; values is keyed by field id (see “Tools”). |
{blocks}. The host times out after 35s. |
tool.drop |
{id, tool, action, files}. files are absolute paths. |
{message, outputs, copiedText?} |
shutdown |
{} |
{}, after which the process exits |
Runtime → host (notifications)
Section titled “Runtime → host (notifications)”| Method | Params |
|---|---|
blip.update |
{extensionId, payload}. The payload is complete: id is "<extensionId>/<key>", and seq and emittedAt are stamped. |
blip.end |
{extensionId, id, seq, state?, dismissAfter?} |
blip.remove |
{extensionId, id} |
extension.status |
{extensionId, status: "loaded" | "running" | "idle" | "crashed", heapUsed?, cpuMs?, error?} |
extension.tools |
{extensionId, tools: ToolSpec[]}. Sent each time the extension’s worker loads its code (load, reload, crash restart), with every valid tool; invalid ones are logged and left out. |
webhook.respond |
{replyId, status, body}. Completes a held webhook request (see below). |
log |
{extensionId?, level: "debug" | "info" | "warn" | "error", message} |
Runtime → host (requests)
Section titled “Runtime → host (requests)”| Method | Params | Result |
|---|---|---|
host.openURL |
{url} |
{} |
host.notify |
{title, body?} |
{} |
host.secrets.get |
{extensionId, name} |
{value} (null when unset) |
host.secrets.set |
{extensionId, name, value} |
{} |
host.secrets.delete |
{extensionId, name} |
{} |
host.preferences.set |
{extensionId, name, value} |
{}. One of the extension’s own declared options, never a password, a value of its kind (a dropdown’s own choices); null clears it. Saved like a change in Settings, then update() runs with trigger preferences. |
host.oauth.connect |
{extensionId, provider?} |
{} once the service’s sign-in page is open (a pending sign-in for the same service is reopened). When the browser comes back, the app keeps the tokens, says “<title> is connected” under the notch, and runs the extension. An error when it can’t start (the sign-in port is taken). |
host.oauth.connections |
{extensionId, provider?} |
{connections: [{id, account?, label?, connectedAt, scopes?, needsReconnect}]}: never a token |
host.oauth.token |
{extensionId, provider?, connection?} |
{token, connection}, renewed first within five minutes of expiring (one renewal per connection at a time), or {token: null} when there’s none that works. An error when an expired token couldn’t be renewed for want of the network. |
host.oauth.describe |
{extensionId, provider?, connection, account, label} |
{connection}. Names it; the same account connected again keeps only the newest. (A sign-in that finishes replaces connections that need signing in again and were never named.) |
host.oauth.invalidate |
{extensionId, provider?, connection} |
{}. The service rejected its token: it needs signing in again. |
host.oauth.disconnect |
{extensionId, provider?, connection?} |
{}. Revoked where the service allows (best effort), and removed; the extension runs again. |
host.workingHours |
{} |
{start, end, weekdaysOnly} (minutes after midnight), or null when the person set none |
host.secrets.* back ctx.secrets, host.oauth.* back ctx.oauth, and host.preferences.set backs ctx.setPreference; the runtime stamps extensionId on all of them. provider may be left out when the manifest declares one sign-in. Tokens live in the Keychain under the extension’s own service, account oauth.<provider>. The runtime stamps extensionId with the calling worker’s own id, whatever the worker sent, and the app keeps each secret in the Keychain under that extension’s service (account secret.<name>, apart from password preferences). Names are 1–64 of A–Z a–z 0–9 . _ -; values at most 16KB.
- Validation: the runtime validates each payload against the SDK’s schema (
validateBlipPayload). It truncatesactionsto 4 (quick choices included),factsto 4, listitemsto 5 (and each item’sactionsto 3), a progress’sstagesto 6, a meter’smetersto 4, andseriesto 48 (keeping the newest), and drops any payload still over 4KB with an error log. Invalid payloads are dropped and logged, never forwarded. - Sequencing:
seqincreases per blip id, and the runtime persists the lastseqper id inData/<id>/seq.json, so it survives restarts. - Isolation: each worker gets
resourceLimits.maxOldGenerationSizeMb = 64. If a worker dies, the runtime reportsextension.status crashedand restarts it at most 3 times per 5 minutes. - Permissions: the global
fetchchecks every host it reaches (redirects included) againstpermissions.network, andctx.execchecks againstpermissions.exec. They’re the only ways out: before an extension’s code loads, its worker refuses Node’s own network and process modules (net,tls,dns,http,https,http2,child_process,worker_threadsand the like), whether reached byrequire,import()orprocess.getBuiltinModule, along withprocess.binding, native addons and a loader hook of its own. The globalWebSocketis held topermissions.networklikefetch. It’s a fence inside one shared process, not an operating-system sandbox, and file access isn’t limited. The manifest says what an extension reaches, and the app shows it to people before they install one. Tools run in the same worker, under exactly the same checks. - Tools-only extensions: an extension may export
toolsand noupdate().extension.runon it resolves{}without doing anything.
A tool is a small utility that opens in place inside the notch (the tool tray), like the built-in JSON or Color
tools. An extension declares its tools in code; the host draws them with the same kit as the built-ins, from a
declarative spec, so every tool looks native. The SDK’s types (ToolSpec, ToolBlock and the rest, exported
from @blipbar/api) describe this wire form.
Lifecycle
Section titled “Lifecycle”blipkit buildloads the built bundle, turns eachtoolsentry into a canonical spec, validates it, and writes the array todist/tools.json(removing a stale one when there are none). An invalid tool fails the build.- The host reads
dist/tools.jsonwhen it discovers the extension, so the tools appear in the tray (and in edit mode’s dock) without starting any extension code. Tool ids are<extensionId>/<name>. - When the worker loads, the runtime reports
extension.tools; those specs replace the discovered ones until the code changes again. - Running a tool loads the extension if it isn’t loaded, without its blip side: no interval, no
update(). A tool in the tray costs nothing until it runs. Concurrent runs share one load.
Spec (ToolSpec)
Section titled “Spec (ToolSpec)”Flat JSON. On decode, missing keys take the defaults shown; the canonical form (what blipkit build writes and
the host re-encodes) spells every default out.
{ "id": "lookup", "title": "npm", "icon": "shippingbox", "summary": "Look up any package on npm", "category": "developer", "fields": [{ "id": "name", "type": "text", "placeholder": "Package name" }], "actions": [{ "id": "lookup", "title": "Look Up", "role": "primary" }], "live": false, "remembersInputs": true, "dropActions": [], "clipboardKinds": []}| Key | Default | Notes |
|---|---|---|
id |
required | The tool’s name within the extension: 1–64 letters, digits, - or _. |
title |
required | At most 32 characters; it sits under an icon in the tray. |
icon |
required | An SF Symbol. |
summary |
"" |
At most 160 characters. |
category |
"everyday" |
image, video, pdf, developer, design, everyday. |
fields |
[] |
At most 8, unique ids. |
actions |
[] |
At most 4 {id, title, icon?, role?}, unique ids, at most one primary. role defaults to default. The first action is what Return runs. |
live |
false |
Runs the first action as inputs change (debounced). Only for fast, local work. |
remembersInputs |
true |
Keeps the last inputs between uses, in memory only. |
dropActions |
[] |
At most 4 {id, title, icon, accepts, minimumFiles?}; accepts are uniform type identifiers, minimumFiles defaults to 1. Handled by the tool’s drop(). |
clipboardKinds |
[] |
json, jwt, url, base64, timestamp, color, uuid, curl: clipboard contents to offer this tool for. |
Fields, one flat object each, {id, type, label?, placeholder?, default?, …}:
type |
Extra keys | default |
Value in values |
|---|---|---|---|
text |
string | string | |
code |
language? |
string | string |
file |
types? (default ["public.item"]), multiple? (default false) |
not allowed | absolute paths, string[] |
choice |
options: [{id, title}] (1–24, unique ids) |
an option id | an option id |
toggle |
boolean | boolean | |
number |
min?, max?, step?, unit? |
number within min…max |
number |
pairs |
[{name, value}] |
[{name, value}] |
Values (tool.run → values)
Section titled “Values (tool.run → values)”A flat object keyed by field id, in the plain JSON each value looks like:
{"url": "https://…", "method": "post", "follow": true, "timeout": 12, "attachments": ["/Users/…/a.png"], "headers": [{"name": "Accept", "value": "application/json"}]}.
The host sends what the user set; the runtime normalizes against the spec before calling run(), so run()
always gets every field, typed: a missing or mistyped value falls back to the field’s default, then to empty
("", min or 0, false, the first option, []). Numbers are clamped to min/max; a single-file field
keeps its first file; unknown keys are dropped.
Blocks (tool.run → {blocks})
Section titled “Blocks (tool.run → {blocks})”| Block | JSON |
|---|---|
| status | {"type": "status", "text": "200 OK · 84 ms", "tone": "positive"}. tone: neutral (default), positive, warning, critical; every tone but neutral is drawn with a shape. |
| text | {"type": "text", "value": "…"}: the same shape as a text typed value. |
| code | {"type": "code", "text": "…", "language": "json"} |
| table | {"type": "table", "rows": [{"name": "Downloads", "value": {"type": "number", "value": 157687813}}, {"name": "Repository", "value": "github.com/o/r", "url": "https://github.com/o/r"}]}. value is a typed value (a bare string or number too), formatted by the host for the viewer’s locale; dates read relative (“3 days ago”). url (http/https) makes the row a link. |
| files | {"type": "files", "paths": ["/abs/path"]} |
| image | {"type": "image", "path": "/abs/path.png"} |
| color | {"type": "color", "hex": "#7C3AED"} (3, 6 or 8 hex digits) |
A drop returns {"message": "Converted 3 images", "outputs": ["/abs/path"], "copiedText": "…"}.
- Validation: the runtime validates every reported spec and every result against the SDK’s tool schema
(
validateToolSpec,validateToolBlocks), and the host decodes structurally again. An invalid result fails the run with “<title> returned something Blipbar can’t show.” and the details go to the log. - Limits: output is truncated, not rejected: at most 24 blocks, 64 table rows and 64 files per block, and text or code past 100,000 characters is cut with “…”. A result still over 1 MB fails.
- Files: a path in
files,imageor a drop’soutputsmust resolve (symlinks included) inside the extension’s data directory,Data/<extension-id>/(ctx.dataDir), or be one of the files the user gave that run. Anything else is dropped and logged. The runtime enforces this and the host checks again. - Errors: a tool that throws fails the run with the error’s message (never its stack), shown to the user as a critical status. Write it for them: what happened and what to do.
- Timeouts: the worker stops waiting after 30s and aborts
ctx.signal(the job queue moves on, so a hung run never blocks the next one); the main thread gives up at 32s and the host at 35s. - Permissions: exactly as for blips:
fetchis limited topermissions.network,ctx.exectopermissions.exec. - Energy: nothing runs until a tool runs. A tool-loaded worker stays loaded (idle, no timers) until the extension is disabled, changes on disk, or the runtime restarts.
Webhooks
Section titled “Webhooks”The host’s HTTP server has its own page.
SDK surface (@blipbar/api)
Section titled “SDK surface (@blipbar/api)”import { defineExtension, stat, session, currency } from "@blipbar/api";
export default defineExtension({ async update(ctx) { // launch, interval, manual, or preferences change const res = await fetch("https://api.stripe.com/v1/balance", { headers: { … } }); ctx.emit(stat({ key: "revenue", title: "Revenue today", value: currency(1247, "USD") })); return { nextRunAfter: 30 }; // optional override of the manifest interval }, async onEvent(ctx, event) { … }, // webhook or file change; event.replyId when a reply is awaited async onAction(ctx, action) { … }, // {blipId, key, actionId, input, itemId}});Tools are declared in the same object (see “Tools” above and the guide):
import { defineExtension, tool, status, table, text, number } from "@blipbar/api";
export default defineExtension({ tools: { lookup: tool({ title: "npm", icon: "shippingbox", fields: [{ id: "name", type: "text", placeholder: "Package name" }], actions: [{ id: "lookup", title: "Look Up", role: "primary" }], async run(action, values, ctx) { // values.name: string, typed from `fields` return [status(values.name), text("…"), table({ Downloads: number(157687813) })]; }, }), },});A tool’s run and drop get a ToolContext: ctx plus dataDir (the only place a tool may write files it
returns) and signal (aborted on timeout; pass it to fetch).
ctx has these members:
| Member | What it is |
|---|---|
extensionId |
The extension’s id |
trigger |
What caused this run |
preferences |
Resolved preference values |
storage |
get, set, and delete, persisted to JSON |
emit(blip) |
Emits a full snapshot |
end(key, {state?, dismissAfter?}) |
Marks a blip finished |
remove(key) |
Removes a blip |
respond(replyId, body, status?) |
Completes a held webhook request |
openURL(url) |
Opens a URL |
notify(title, body?) |
Shows a line under the notch for a few seconds (title, and body beneath it). Not a system notification: no permission prompt, and it appears where the user is. |
setPreference(name, value) |
Changes one of its own options, as if in Settings |
oauth |
connect, connections, token, describe, invalidate and disconnect: the app’s sign-in (above) |
workingHours() |
The person’s working hours, or undefined |
exec(file, args, {cwd?, timeoutMs?}) |
Returns {stdout, stderr, code} |
log |
debug, info, warn, and error |
Block builders for tools: status(text, tone?), code(text, language?), table(rows | {name: value}),
files(paths), image(path), color(hex), and text(value), the value helper, which is also the text block.
The integration kit comes from the same import: TRIAGE, snoozeUntil,
sortSnoozed, sortDone, snoozedItem, underWay, news, probe, connectionBlip, backoff,
waitForReset, staleAfter, and the wording helpers (age, clip, names, joinLine, howLong, timeLeft…).
Builders (stat, progress, session, list, score, countdown, meter) return a BlipInput: the payload minus id, seq, emittedAt, and v, plus key. Value helpers (currency, number, percent, duration, date, text) build typed values. Dates can be Date objects, ISO strings, or epoch numbers (seconds, or milliseconds from 1e12 up), and staleAfter also takes a number under 1e9 as seconds from now.
CLI (blipkit)
Section titled “CLI (blipkit)”| Command | What it does |
|---|---|
blipkit build |
Uses esbuild to bundle src/index.ts into dist/index.js (CommonJS, platform node, target node22, @blipbar/api inlined), then validates the manifest and writes dist/tools.json for any tools. |
blipkit dev |
Builds in watch mode (rewriting dist/tools.json each time) and symlinks the folder into Extensions/. The host sees changes and reloads the extension. Runtime logs are streamed. |
blipkit validate |
Checks the manifest and tools, and runs the extension’s update() once with mock host calls, printing its payloads. --tool <name> [--action <id>] [--values '<json>'] also runs one tool and validates its blocks. |
blipkit send <file.json | -> |
POSTs a payload to the running app via webhook.json. |
blipkit new <name> |
Scaffolds a new extension from a template (--template tracker|payments|deploys|errors|traffic, --id). npm create blipbar-extension runs it. |
blipkit pack |
Builds, then writes <name>-<version>.blipbar, a zip of package.json, dist/ (no maps), README and licence, which the app installs after showing what the extension is and what it can reach. Refuses ids in dev.blipbar.. |