Skip to content

Runtime protocol

Version 1 of the contract between the Blipbar app, the Node runtime inside it, the SDK (@blipbar/api), and extensions.

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.

~/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 app

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

{
"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, or number. A textfield with multiline: true holds 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 hostnames fetch may reach (*.example.com is allowed). Empty means fetch reaches nothing.
  • permissions.files: paths (with ~) the extension may read or watch.
  • permissions.exec: the executables ctx.exec may run.
  • triggers.webhook: the extension receives POST /v1/hooks/<id> bodies as events.
  • triggers.watch: paths whose changes are delivered as events. They must also be listed in permissions.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 address https, and the token and revoke hosts in permissions.network. The redirect is always http://127.0.0.1:47812/oauth/callback, which the service’s app must register exactly. A mistake here shows in Settings and in blipkit 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": ","
}
}

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.

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
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}
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 truncates actions to 4 (quick choices included), facts to 4, list items to 5 (and each item’s actions to 3), a progress’s stages to 6, a meter’s meters to 4, and series to 48 (keeping the newest), and drops any payload still over 4KB with an error log. Invalid payloads are dropped and logged, never forwarded.
  • Sequencing: seq increases per blip id, and the runtime persists the last seq per id in Data/<id>/seq.json, so it survives restarts.
  • Isolation: each worker gets resourceLimits.maxOldGenerationSizeMb = 64. If a worker dies, the runtime reports extension.status crashed and restarts it at most 3 times per 5 minutes.
  • Permissions: the global fetch checks every host it reaches (redirects included) against permissions.network, and ctx.exec checks against permissions.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_threads and the like), whether reached by require, import() or process.getBuiltinModule, along with process.binding, native addons and a loader hook of its own. The global WebSocket is held to permissions.network like fetch. 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 tools and no update(). extension.run on 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.

  1. blipkit build loads the built bundle, turns each tools entry into a canonical spec, validates it, and writes the array to dist/tools.json (removing a stale one when there are none). An invalid tool fails the build.
  2. The host reads dist/tools.json when 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>.
  3. When the worker loads, the runtime reports extension.tools; those specs replace the discovered ones until the code changes again.
  4. 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.

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

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.

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, image or a drop’s outputs must 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: fetch is limited to permissions.network, ctx.exec to permissions.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.

The host’s HTTP server has its own page.

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.

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