Building an extension: the how-to and the contracts, without the generated API reference # Start here > Make an extension and run it in Blipbar. Build extensions for [Blipbar](https://blipbar.app): the Mac’s notch as a tiny status bar for everything you keep checking. An extension is a little TypeScript that turns a service (your deploys, your sales, your errors, a game) into **blips**: live items the notch shows in its ears and its panel, with buttons that act right there. You write data; Blipbar draws it. Pick a layout (`stat`, `list`, `progress`, `session`, `score`, `countdown`, `meter`) and a state (`idle`, `running`, `attention`, `success`, `failure`, …) and the app’s native renderer does the rest, so your blips look like they shipped with it. ## Before you start You need Blipbar on macOS 15 or later, which you can download from [blipbar.app](https://blipbar.app), and Node 22 or later. Hover over the notch or press ⌃⌘B to open it, and right-click it for Settings. ## Start one ```sh npm create blipbar-extension@latest my-extension cd my-extension npm install npx blipkit dev ``` `blipkit dev` builds as you save and links the folder into Blipbar, which reloads it on every change. The first time, place it: right-click the notch, choose Edit Layout…, and drag it in from the dock underneath. A tool waits under More at the end of the tray, and an extension with only tools needs no placing. Hover over the notch or press ⌃⌘B to open it. Starters for common kinds of service: ```sh npm create blipbar-extension@latest my-shop -- --template payments # revenue, sales, MRR npm create blipbar-extension@latest my-host -- --template deploys # deploys with time left npm create blipbar-extension@latest my-errors -- --template errors # new and returning errors npm create blipbar-extension@latest my-site -- --template traffic # visitors, sources, a surge npm create blipbar-extension@latest my-tracker -- --template tracker # sign-in, your work, an inbox npm create blipbar-extension@latest my-tool -- --template tool # a tool in the tray, nothing on a schedule ``` ## What one looks like ```ts import { defineExtension, money, stat } from "@blipbar/api"; export default defineExtension({ async update(ctx) { const response = await fetch("https://api.example.com/revenue/today", { headers: { Authorization: `Bearer ${ctx.preferences.apiKey}` }, }); const { cents, yesterdayCents } = (await response.json()) as { cents: number; yesterdayCents: number }; ctx.emit( stat({ key: "revenue", title: "Revenue today", value: money(cents, "USD"), reference: money(yesterdayCents, "USD"), period: "vs yesterday", upIsGood: true, }), ); return { nextRunAfter: 300 }; // Blipbar schedules the next run; no timers of your own }, }); ``` The manifest lives in `package.json` under `"blipbar"`: an id (reverse-DNS, like `dev.yourname.weather`), a title, preferences (a `password` preference, for an API key, is kept in the Keychain), and the permissions it needs. `fetch` reaches only the hosts it names, and Node’s own network and process modules are off limits, so `fetch` and `ctx.exec` are the only ways out. ## The kit Beyond builders, the package carries what integrations share, so yours reads like Blipbar’s own: sign-in run by the app, Done/Snooze/Mute, a press shown under way, the connection’s trouble said plainly, and four domains with their arithmetic and wording done: **payments** (revenue against yesterday by now, sales, MRR, disputes, payouts, in every currency’s own decimals), **deploys** (time left from the usual, stuck builds, Roll back), **errors** (new, regressed, escalating; spikes), **traffic** (visitors, sources, a surge). ## blipkit | Command | Does | | ------------------ | -------------------------------------------------------------------------------------------------------------------- | | `blipkit dev` | Build on save, link into Blipbar, and print its log (`ctx.log` and errors) | | `blipkit build` | Bundle `src/index.ts` into `dist/index.js` | | `blipkit validate` | Run `update()` once against a mock host and check every blip it emits | | `blipkit pack` | Make `-.blipbar`, one file anyone can open to install it, and print its entry for Blipbar’s directory | | `blipkit send` | Send a blip straight to a running Blipbar | | `blipkit new` | Scaffold an extension (what `npm create blipbar-extension` runs) | ## Share it `blipkit pack` makes a `.blipbar` file. Opening it (or Settings › Blips › Install…) shows what the extension is and what it can reach before anything is installed. # Concepts **A blip** is one live item: a stat, a running agent session, a game score. An extension can emit several blips (each with its own `key`) from one `update()` call. Blipbar’s Claude Code extension emits one per agent session. **A layout** is the shape of a blip’s data: `stat`, `progress`, `session`, `list`, `score`, `countdown`, or `meter` ([Builders: one per layout](/docs/guide/the-sdk-api/#builders-one-per-layout)). You pick one per blip; the Mac app’s renderer owns every pixel. There’s no view to design: the same blip shows as an ear beside the camera, a row, a tile and an opened item, because it’s data, not a view. **A state** is what’s happening right now. Every layout supports every state, and the renderer decides how each looks (color, motion, whether it peeks). You never pick a color: you pick the state that’s actually true, and the look follows from that. | State | Means | How it shows | | ----------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------- | | `idle` | Nothing happening | Calm | | `running` | Work in progress | Calm, with progress or elapsed time | | `attention` | Needs a decision or input from the person | Amber. The only state that plays the notch’s attention animation, and it reminds again while it waits | | `stalled` | No progress for too long | Amber | | `success` | Finished well | Green, then settles back to calm after a few seconds | | `failure` | Finished badly, or something’s wrong | Red, then settles back to calm after a few seconds | | `stale` | The data is old | Dimmed, with when it was last updated | | `offline` | No connection | Dimmed, with when it was last updated | | `empty` | Nothing to show yet | Calm | Entering `attention`, `stalled`, `success` or `failure` can peek the notch open briefly. A state with a color also has a shape (!, ✓, ×), so color is never the only signal. **The host owns the lifecycle.** Your extension doesn’t run a server, a cron job, or a `setInterval`. The app decides *when* your code runs: on launch, on the manifest’s `interval`, when a webhook arrives, when a watched file changes, when someone presses an action. The Node runtime that hosts your code checks your declared permissions and validates everything you emit before it reaches the app. This is deliberate, not a limitation: it’s how Blipbar keeps idle CPU near zero, and why your manifest (which Blipbar shows people before they install) can say what your extension does without anyone reading your code. **A tool** is the other thing an extension can offer: a small utility the user opens from the notch’s tool tray and runs on demand (look up a package, decode a token). Tools declare their inputs and return typed output; the same kit draws them as Blipbar’s own. See [Tools](/docs/guide/tools/). **Full snapshots, not diffs.** `ctx.emit()` sends everything about a blip every time, not just what changed. The app can join late, drop a message, or reconnect and still render correctly, with no “catch up” logic anywhere. Emit a blip’s complete state, always, even the fields that didn’t change since last time. # Quickstart Anywhere, with Node 22 or later: ```sh npm create blipbar-extension@latest my-extension cd my-extension npm install npx blipkit dev ``` (Use `npm create blipbar-extension`, not `npx blipkit new`, to start one: `blipkit` on npm is someone else’s package. Inside a project `npx blipkit` finds `@blipbar/api`’s own.) `npm create blipbar-extension` runs `blipkit new`, which scaffolds `package.json` (with a manifest, TypeScript and Node’s types to check with), `src/index.ts`, and `tsconfig.json`. It also writes `AGENTS.md` (and a `CLAUDE.md` that points to it), which tells whichever AI helps build it where these guides are and the rules it tends to trip on, and fills in the manifest’s `author` from git. `--template tool` starts a tools-only one instead. Its id is `dev..`; pass `--id` to choose one (a domain of yours, reversed) before anyone installs it: the id is how Blipbar knows it, and its settings and saved keys go by it. `blipkit dev` builds in watch mode and symlinks the folder into `~/Library/Application Support/Blipbar/Extensions/`. The host watches that folder and reloads your extension on every change. That’s the whole loop: edit, save, watch it update live in the notch. The first time, place it, since an extension runs only while it’s placed: right-click the notch, choose Edit Layout…, drag your extension from the dock underneath into an ear or the panel, and press Done. A tool waits under More at the end of the tray, where + keeps it in the tray; an extension with only tools needs no placing. To open the notch, hover over it or press ⌃⌘B. The generated `src/index.ts`: ```ts import { defineExtension, number, stat } from "@blipbar/api"; export default defineExtension({ async update(ctx) { ctx.emit( stat({ key: "value", title: "My Extension", icon: "bolt.fill", value: number(0), }), ); return { nextRunAfter: 30 }; }, }); ``` Replace the `stat(...)` with real data (a `fetch`, a computed value, anything), pick whichever layout fits ([Builders: one per layout](/docs/guide/the-sdk-api/#builders-one-per-layout)), and you have a working extension. # The manifest Everything Blipbar needs to know about your extension lives under the `blipbar` key of its `package.json`. There’s no separate manifest file. ```json { "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": [], "permissions": { "network": ["api.stripe.com"], "files": [], "exec": [] } } } ``` | Field | Required | Notes | | ------------------ | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | yes | Reverse-DNS (`^[a-z0-9-]+(\.[a-z0-9-]+)+$`, case-insensitive: `dev.yourname.stripe`). Stable forever: it’s half of every blip’s `id` (`/`) and the folder name under `Extensions/`. Changing it once people have installed your extension orphans their blips, settings and saved keys. | | `title` | yes | Shown in Settings, in Edit Layout’s list and on the install sheet. | | `description` | no | One or two sentences, shown in Settings, in Edit Layout’s list and on the install sheet. | | `icon` | no | An SF Symbol name (`creditcard.fill`). Falls back to a default if omitted. Check the name exists in the SF Symbols app: an unrecognized name renders nothing. | | `author` | no | Shown in Settings and on the install sheet. | | `categories` | no | An array of strings. The first one groups your extension in Edit Layout’s list (under “Extensions” when there’s none). Match an existing category where one fits: Blipbar’s own extensions use `Developer Tools`, `Finance`, `Sports`, `Productivity`. | | `interval` | no | How often the host runs `update()` on a timer: `"30s"`, `"5m"`, `"1h"`, `"1d"`. **Minimum 10s**: the host rejects anything faster at load. Omit it if you’re driven entirely by `triggers.webhook`/`triggers.watch` instead. | | `triggers.webhook` | no | `true` if this extension should receive `POST /v1/hooks/` bodies as `onEvent` calls ([Webhooks and held responses](/docs/guide/webhooks-and-held-responses/)). | | `triggers.watch` | no | Paths whose changes deliver an `onEvent` `{type: "file"}` call. Every path here must also appear in `permissions.files`. | | `preferences` | no | See [Preferences](/docs/guide/preferences/). | | `permissions` | no | See [Permissions](/docs/guide/permissions/). Omitted arrays default to empty: no network through `fetch`, no files, no `ctx.exec`. | | `oauth` | no | Services the app signs in to for you, so there’s no key to paste and no sign-in code to write: see [Sign-in, run by the app](/docs/guide/building-on-the-kit/#sign-in-run-by-the-app). | The host watches your extension’s whole folder (bundled or in `~/Library/Application Support/Blipbar/Extensions/`, which can be a real folder or, as `blipkit dev` sets up, a symlink to your working copy) and reloads it on any change. That’s the entire hot-reload mechanism, with no separate watch config. # Preferences User-configurable settings, shown in Settings, resolved and handed to every `update()`/`onEvent`/`onAction` call as `ctx.preferences` (a plain object keyed by `name`). ```json "preferences": [ { "name": "apiKey", "title": "API key", "type": "password", "required": true, "placeholder": "sk_live_…", "description": "Used to authenticate with the upstream API.", "link": { "title": "Create a key", "url": "https://example.com/settings/keys" } }, { "name": "workspace", "title": "Workspace", "type": "textfield", "default": "default" }, { "name": "currency", "title": "Currency", "type": "dropdown", "default": "usd", "data": [{ "title": "US Dollar", "value": "usd" }, { "title": "Euro", "value": "eur" }] }, { "name": "includeRefunds", "title": "Include refunds", "type": "checkbox", "default": false }, { "name": "staleAfterMinutes", "title": "Stale after", "type": "number", "default": 10 } ] ``` | `type` | Renders as | Value in `ctx.preferences[name]` | | ----------- | ------------------------------------------------------------------------------------------------------------- | -------------------------------- | | `textfield` | A text field | `string` | | `password` | A text field whose value is stored in the Keychain, never in the manifest’s resolved-preferences JSON on disk | `string` | | `checkbox` | A toggle | `boolean` | | `dropdown` | A picker; needs a `data: [{title, value}]` array | `string` (the chosen `value`) | | `number` | A numeric field | `number` | `link` (optional, `https` only) puts a “where to get it” link under the field. Give every required key one: a field that asks for a token without saying where to get it is a dead end. Settings shows every preference. An item opened in the notch shows its source’s options too (everything but secrets), right there: `blips` says which of your blips a preference belongs to (keys, or key prefixes like `"project."`), so a sales option shows on Sales and not on Revenue. Left out, it shows on all of them. `"blips": []` keeps it to Settings: an address, an account, a region or a team is set once and doesn’t belong beside the blip. `required: true` blocks the extension from loading until it’s set. A preference change re-runs `update()` with `ctx.trigger = { type: "preferences" }`, so you can react to it (re-fetch with a new API key, reset cached state) rather than just reading the new value next time the interval fires. ```ts // ctx.preferences is untyped (Record): // narrow it yourself at the point you read it. const workspace = typeof ctx.preferences.workspace === "string" ? ctx.preferences.workspace : "default"; ``` # Permissions Declared once, in the manifest. The Node runtime checks the calls it hands you against them, so your code doesn’t have to police itself. ```json "permissions": { "network": ["api.example.com"], "files": ["~/.config/my-extension/state.json"], "exec": ["/usr/bin/say"] } ``` | Key | Gates | Notes | | --------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `network` | `fetch` | A list of hostnames (`*.example.com` matches any subdomain). A `fetch` to any other host is rejected before it leaves the process, and so is a redirect to one. Empty (or omitted) means `fetch` reaches nothing. `WebSocket` follows the same list. Node’s own `http`, `https`, `net`, `dns` and `child_process` modules aren’t available to extensions at all, so make every request with `fetch` and run programs with `ctx.exec`. | | `files` | `triggers.watch`, and generally any path your code reads | Paths, `~` allowed. **Not sandboxed**: your code *can* read more than it declares, but the manifest is what people see before they install it. Declare what you actually touch. | | `exec` | `ctx.exec(file, …)` | The exact executable paths you’re allowed to run. `ctx.exec` rejects (throws) a call for anything not listed here. Like `fetch`, this checks the call the runtime gives you, not Node’s own `child_process`: run programs only through `ctx.exec`. | Ask for the minimum you need. `permissions.network` and `permissions.exec` are the first thing people see on the install sheet, before anything is installed ([Sharing it](/docs/guide/sharing-it/)). # The SDK API Everything below is exported from `@blipbar/api`. ## `defineExtension` ```ts import { defineExtension } from "@blipbar/api"; export default defineExtension({ async update(ctx) { /* … */ }, // required, unless the extension only has tools async onEvent(ctx, event) { /* … */ }, // optional: webhook or watched-file events async onAction(ctx, action) { /* … */ }, // optional: someone pressed one of your actions // tools: { … } // optional: see Tools }); ``` It’s the identity function (`defineExtension(x)` returns `x`), used purely so `export default defineExtension({ … })` gets full type-checking and autocomplete on the object literal with no separate type annotation to write. It returns your object’s own type, so tests can call `extension.update(ctx)` or a tool’s `run` directly. * **`update(ctx)`** runs on launch, on the manifest’s `interval`, on a manual run (from Settings), and after a preferences change (`ctx.trigger` tells you which). Return `{ nextRunAfter: }` to override the manifest’s interval for just the next run (a shorter poll right after user action, a backoff after a failure, or a long sleep while there’s nothing live: a webhook event brings the interval back); omit it to keep using the manifest’s interval. * **`onEvent(ctx, event)`** runs for a webhook POST or a watched file changing, and only if the manifest declared that trigger. See [Webhooks and held responses](/docs/guide/webhooks-and-held-responses/). * **`onAction(ctx, action)`** runs when someone presses one of your blip’s `actions`. See [Actions and replies](/docs/guide/actions-and-replies/). ## `Context` (`ctx`) Every callback gets one of these, capability-gated by your manifest’s `permissions`. | Member | Signature | What it does | | --------------------------------- | --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `extensionId` | `string` | Your `blipbar.id`. | | `trigger` | `Trigger` | Why `update()` is running: `{type: "launch"\|"interval"\|"manual"\|"preferences"}`. | | `preferences` | `Preferences` | Resolved preference values ([Preferences](/docs/guide/preferences/)). | | `storage` | `Storage` | `get(key)`, `set(key, value)`, `delete(key)`: JSON, persisted per extension, survives restarts. | | `secrets` | `Secrets` | `get(name)`, `set(name, value)`, `delete(name)`: strings kept in the Keychain, for what the extension obtains itself (an OAuth token). Never put a token in `storage`, which is a plain file. What the user types belongs in a `password` preference. | | `emit(blip)` | `(BlipInput) => void` | Emits one full snapshot. Call once per changed blip, never a diff ([Concepts](/docs/guide/concepts/)). | | `end(key, opts?)` | `(string, {state?, dismissAfter?}) => void` | Marks a blip finished. The notch shows the final state, then dismisses it after `dismissAfter` seconds. | | `remove(key)` | `(string) => void` | Removes a blip immediately, with no final state shown. | | `respond(replyId, body, status?)` | `(string, unknown, number?) => void` | Completes a held webhook request ([Webhooks and held responses](/docs/guide/webhooks-and-held-responses/)). | | `openURL(url)` | `(string) => Promise` | Opens a URL with the user’s default handler. | | `notify(title, body?)` | `(string, string?) => Promise` | Shows a line under the notch for a few seconds: for the result of something the user just did (“Couldn’t start ENG-12”). Use a blip’s state for anything that lasts. | | `setPreference(name, value)` | `(string, string \| number \| boolean \| undefined) => Promise` | Changes one of your own options (never a `password`) as if it were changed in Settings: saved, and `update()` runs again with it. For a button that sets something up in one press (GitHub’s “Keep watching”). A dropdown only takes one of its choices. | | `oauth` | `OAuth` | The app’s sign-in to the services your manifest declares ([Sign-in, run by the app](/docs/guide/building-on-the-kit/#sign-in-run-by-the-app)): `connect()`, `connections()`, `token()`, `describe()`, `invalidate()`, `disconnect()`. | | `workingHours()` | `() => Promise` | The person’s working hours (Settings › General), or `undefined` when they set none. `snoozeUntil` takes it. | | `exec(file, args?, opts?)` | `(string, string[]?, {cwd?, timeoutMs?}) => Promise` | Runs an executable from `permissions.exec`; rejects otherwise. Returns `{stdout, stderr, code}`. | | `log` | `Logger` | `debug`/`info`/`warn`/`error(message: string)`, tagged with your extension id, visible in the host’s logs. | ## Builders: one per layout A builder takes a flat, convenient shape and returns a `BlipInput` ready for `ctx.emit()`. Every builder shares these fields (all optional except `key` and `title`): `subtitle`, `icon`, `state`, `staleAfter`, `relay` (`"allow"`, the default, or `"deny"`: whether the blip may be shared beyond this Mac; Blipbar shares nothing beyond the Mac today, so it has no effect), `facts` (at most 4 kept), `actions` (at most 4 kept), `url` (opened when the blip itself is clicked). Titles and subtitles (a list item’s too) show on one line, so line breaks in them become spaces; if you shorten text yourself, don’t cut between the two halves of an emoji (the runtime would show `�`). **`stat`**: a single number, like revenue, a price or followers. ```ts import { currency, number, stat } from "@blipbar/api"; ctx.emit( stat({ key: "revenue", title: "Revenue today", icon: "creditcard.fill", value: currency(1247, "USD"), reference: currency(1190, "USD"), // what the delta is measured against series: [1190, 1203, 1188, 1221, 1247], // sparkline, oldest first, ≤48 points kept period: "today", facts: [{ label: "Orders", value: number(38) }], actions: [{ id: "open", label: "Open Dashboard", role: "default", url: "https://dashboard.example.com" }], }), ); ``` **`progress`**: something with a known end, like a deploy, a download or a render. ```ts import { progress } from "@blipbar/api"; ctx.emit( progress({ key: "render", title: "Rendering final cut", subtitle: "hero-clip-v3.mov", state: "running", fraction: 0.62, // 0…1, or omit/null for an indeterminate (dashed) ring/bar step: "Encoding H.265", stepIndex: 3, stepCount: 4, startedAt: new Date(Date.now() - 8 * 60_000), eta: new Date(Date.now() + 4 * 60_000), actions: [{ id: "cancel", label: "Cancel", role: "destructive" }], }), ); ``` **Pipelines.** Give `progress` a `stages` array and it draws as a pipeline instead of a bar: a strip of stages in the ear, the row and a tile, and a stepper when opened (side by side for a few short ones, down the page for more or longer). GitHub uses it for a commit’s checks → develop → staging → production; anything with stages can. ```ts progress({ key: "deploy", title: "acme/web", subtitle: "Deploying to staging", // what's happening; the stages show where state: "running", // the most urgent stage's, so it peeks when one fails stages: [ { name: "Checks", state: "success", detail: "7 passed" }, { name: "staging", state: "running", detail: "Deploying · 1m", startedAt: started, url: logURL }, { name: "production", state: "idle", detail: "On 3e1f2a0" }, ], }); ``` A stage’s `state`: `idle` not reached yet, `running`, `success`, `failure`, `attention` (waiting on the person: an approval), `stalled` (held, or far slower than usual), `empty` (skipped). At most 6 are drawn and the rest are counted (“+2”). Keep names short (`staging`, not `staging-us-east-1-blue`) and `detail` to a few words: a stage says where it stands, the subtitle says what’s happening. Put the buttons for the stage that needs something (Approve, Re-run failed, View log) on the blip’s `actions`. > **Gotcha:** `startedAt`/`eta`/`target` (here and in `session`/`countdown` below) take a `Date`, an ISO string, or an **absolute** epoch number, never “N seconds from now” for a bare number. Only `staleAfter` gets that relative convenience. `eta: 240` does not mean “4 minutes from now”; it means 240 seconds after the Unix epoch, in 1970. Pass `new Date(Date.now() + 240_000)` instead. **`session`**: long-running work with no known end, like an AI agent or a stream. ```ts import { session } from "@blipbar/api"; ctx.emit( session({ key: "api-refactor", title: "Claude Code · api-refactor", subtitle: "Running the test suite", icon: "sparkle", state: "running", startedAt: new Date(Date.now() - 14 * 60_000), agent: "Claude Code", // shown in lists actions: [ { id: "stop", label: "Stop", role: "destructive" }, { id: "reply", label: "Reply", input: "text", placeholder: "Send a message…" }, ], }), ); ``` **`list`**: a few related items (at most 5 rendered). ```ts import { list } from "@blipbar/api"; ctx.emit( list({ key: "sessions", title: "Agent sessions", subtitle: "2 running · 1 needs you", state: "attention", items: [ { id: "api-refactor", title: "api-refactor", subtitle: "running tests", state: "running", icon: "hammer.fill" }, { id: "release-notes", title: "release-notes", subtitle: "approve permission?", state: "attention" }, { id: "flaky-fix", title: "flaky-fix", subtitle: "3 files changed", state: "success" }, ], total: 3, // when `items` is a truncated view of more than 5 }), ); ``` A list’s `subtitle` is what its row says (a summary: “1 failing · 2 in review”); without one, the row leads with its top item. Put what needs you first: the row, the ear’s dots and the peek go by the first items. The row, the ear and a tile show how many items there are. When that isn’t the point (a breakdown: a site’s sources, its pages), give `value` instead, and they show it: the kit’s `breakdownBlip` gives its top entry’s number, with the line naming it (“Hacker News · 90%”, 64). **Items with buttons.** An item can carry up to 3 `actions` of its own, with no `input` and no `quick` (give a third an `icon`: it shows as just that where room is short). The opened list shows them on hover, ↑/↓ walk the items, and Return does the item’s first button that doesn’t ask first (or opens its `url`). A list with no `actions` of its own offers its top item’s in its row, peek and ⌘1. Pressing one calls `onAction` with `itemId` set to the item’s `id`. The button shows pressed the moment it’s pressed, until your `onAction` returns; mark Done-like buttons `dismisses: true` (the kit’s `TRIAGE` does) and the item leaves the list at once, while your action runs. An item that arrives needing you (or failed, or succeeded), or whose state turns into one of those, peeks the notch and names itself, even when the list’s own state didn’t change; the same item in two of your blips peeks once. ```ts items: [ { id: pr.id, title: pr.title, subtitle: "test failed · web#42", state: "failure", value: text("Failed"), url: pr.url, actions: [ { id: "rerun", label: "Re-run failed", icon: "arrow.clockwise" }, { id: "log", label: "View log", url: logURL }, ], }, ], ``` **`score`**: two sides and a live state, like a game, a race or a comparison. ```ts import { score } from "@blipbar/api"; ctx.emit( score({ key: "warriors-lakers", title: "GSW @ LAL", subtitle: "NBA", state: "running", sides: [ { name: "Golden State Warriors", short: "GSW", score: 96 }, { name: "Los Angeles Lakers", short: "LAL", score: 101, active: true }, // serving / batting / in possession ], period: "4th", clock: "3:42", event: "3-pointer · James", }), ); ``` `score` can be non-numeric too (`score: "245/6"` for cricket): the field is `string | number`. **`countdown`**: a known future moment, like a meeting, a launch or market close. ```ts import { countdown, date, duration } from "@blipbar/api"; ctx.emit( countdown({ key: "standup", title: "Team standup", subtitle: "Zoom", icon: "video.fill", target: new Date(Date.now() + 15 * 60_000), // a DateInput, not date(): see the gotcha above startedAt: new Date(), facts: [ { label: "Duration", value: duration(900) }, // facts take a TypedValue: date() and duration() belong here { label: "Starts", value: date(new Date()) }, ], }), ); ``` A timer rather than an appointment sets `showsSeconds: true`: the ear reads a ticking `18:42` instead of `18m`. While it’s paused, send `pausedRemaining` (seconds left) and keep `startedAt…target` the full length so the ring holds still. `session` takes `showsSeconds` too, for a stopwatch. A target that ends something already under way (awake until 4:30, a deploy freeze until 9) sets `endsAtTarget: true`, so the time left reads `1h 59m left` rather than a meeting’s `in 1h 59m`. A `stat` whose value is `text("")` has nothing to watch, only something to do (a shortcut, a deploy button): its row shows its actions at rest and its tile reads like a button. **`meter`**: allowances that refill, like a plan’s usage limits, an API quota or a monthly budget. The first meter is the headline (the ear shows it as a ring and a percent); at most 4 render. ```ts import { currency, meter } from "@blipbar/api"; ctx.emit( meter({ key: "plan", title: "Cursor", subtitle: "Pro", // beside the title while calm; loud, it's the explanation meters: [ { label: "Month", fraction: 0.62, resetsAt: "2026-10-14T00:00:00Z" }, { label: "Premium", fraction: 0.9, used: currency(18, "USD"), limit: currency(20, "USD") }, ], }), ); ``` Leave `level` out and the renderer grades each meter by how full it is; set it when the service has its own reading (`"warning"` at its threshold). Set the blip `attention` when a limit is nearly used, with a `subtitle` that says which and when it resets. ## Value helpers Build a `TypedValue` instead of hand-writing the wire shape or, worse, formatting a number into a string yourself. The app formats a typed value for the viewer’s own locale. An extension that emits `"$1,247"` as plain text is doing the renderer’s job, badly: it can’t be formatted for anyone else’s locale, and it reads differently from every other blip. These helpers are for a `TypedValue` field (`value`, `reference`, a `Fact.value`, a `ListItem.value`), never for a builder’s `Date`-shaped field (`startedAt`, `target`, `eta`). | Helper | Renders as | Notes | | ------------------------------------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------- | | `currency(amount, currencyCode)` | `$1,247` / `1.247 €`, locale-formatted | `amount` is the major unit: `12.5` is $12.50, not 1250 cents. | | `number(value, {unit?, precision?})` | `2,481` / `2,481 req/s` | | | `percent(value, precision?)` | `42%` | `value` is a fraction: `0.42` → `42%`. | | `duration(seconds)` | `1h 24m` / `32s` | | | `date(value)` | Relative or absolute, as the app sees fit | `value` is itself a `DateInput`. | | `text(value)` | Verbatim | For values that are genuinely text (a status word, a username), not a workaround for number formatting. | A bare JS number or string also works as a shorthand (`value: 2481` ≡ `value: number(2481)`), but prefer the explicit helper so the renderer knows what it’s showing and formats it correctly. # Actions and replies At most **4** actions survive on a payload. The runtime keeps the first 4 and drops the rest, so put the important ones first: smaller spaces (an ear, a tile, a peek) show only the first one or two. ```ts actions: [ { id: "stop", label: "Stop", role: "destructive" }, { id: "reply", label: "Reply", input: "text", placeholder: "Send a message…" }, ] ``` | Field | Notes | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | Routed back to your `onAction` as `action.actionId`. Yours to name. | | `label` | Button text. | | `role` | `"default"`, `"primary"` (called out visually: use it for the one action you want someone to notice), or `"destructive"` (asks first, with a hold or a second press, so it never fires on a stray press). Keep `destructive` for what can’t be taken back (stopping an agent, cancelling a deploy): anything your extension can undo should act on one press. | | `icon` | An SF Symbol name. | | `input` | `"text"` swaps the button for a reply field; the typed text comes back as `action.input`. | | `placeholder` | Shown in that reply field. | | `url` | Opened by the host directly, with **no round trip to your `onAction`**. Use it for “open the dashboard”, not for anything that needs your code to run first. | | `quick` | `true` makes it one of a set of small choices (“5 min”, “+1 min”), shown only when the item is opened in place, as a line of chips under its buttons; never in a row, a tile or a peek. Counted in the 4 actions a payload keeps. | | `on` | Makes the first button a switch, and says whether it’s on (keep awake, mute, deploys paused). Its icon names the switch, not the press; a tile keeps it in reach and lights it while on, and quiets the item’s value while off. | | `confirm` | `true` asks first, a hold or a second press, like `destructive` but without its red: for what’s consequential rather than harmful (Merge, Publish). | | `dismisses` | On a list item’s button: `true` takes the item out of the list the moment it’s pressed (Done, Snooze, Mute). It stays out while your `onAction` runs; your next update decides, and it’s back at once if your action throws. Use the kit’s `TRIAGE` buttons rather than your own. | | `copy` | Text the host copies with no round trip to you, saying “Copied” under the notch (“Copy branch”). At most 1,000 characters; not with `url` or `input`. | Handling one in `onAction`: ```ts async onAction(ctx, action) { switch (action.actionId) { case "stop": ctx.end(action.key, { state: "failure", dismissAfter: 5 }); await ctx.notify("Blipbar", `Stopped ${action.key}`); return; case "reply": ctx.log.info(`reply for ${action.key}: ${action.input ?? ""}`); // … send action.input wherever it needs to go. return; } } ``` `action` is `{ blipId, key, actionId, input?, itemId? }`. `itemId` says which list item’s button it was (see “Items with buttons” in [Builders: one per layout](/docs/guide/the-sdk-api/#builders-one-per-layout)); `key` is the blip’s own key (the part of `blipId` after the `/`), which is what you pass back into `ctx.end`, `ctx.remove` or another `ctx.emit` for the same item. # Webhooks and held responses Set `triggers.webhook: true` and your extension receives every `POST /v1/hooks/` as an `onEvent` call with `event.type === "webhook"`. The distinctive move, and **how “approve from the notch” answers a blocking hook synchronously**, is `?await=` on that request (up to 600s): the HTTP response is held open until your `onAction` calls `ctx.respond(event.replyId, …)`, or the wait runs out (then a bare `204`). Whoever’s on the other end of that webhook (an agent’s permission hook, for Blipbar’s own Claude Code extension) is blocked the whole time, waiting for your answer. ```ts async onEvent(ctx, event) { if (event.type !== "webhook") return; const body = event.body as { sessionId?: string; message?: string }; if (!body.sessionId) { ctx.log.warn("webhook payload had no sessionId, ignoring"); return; } ctx.emit( session({ key: body.sessionId, title: body.sessionId, subtitle: body.message ?? "Needs your input", state: "attention", startedAt: new Date(), actions: [ { id: "approve", label: "Approve", role: "primary" }, { id: "deny", label: "Deny", role: "destructive" }, ], }), ); // No replyId means the caller didn't pass ?await=, so there's nothing to hold open. if (event.replyId) { await ctx.storage.set(`pending:${body.sessionId}`, event.replyId); } }, async onAction(ctx, action) { if (action.actionId !== "approve" && action.actionId !== "deny") return; const replyId = await ctx.storage.get(`pending:${action.key}`); if (!replyId) return; // already answered, or the wait already timed out ctx.respond(replyId, { decision: action.actionId === "approve" ? "allow" : "deny" }); await ctx.storage.delete(`pending:${action.key}`); }, ``` Storage is the right place to stash a `replyId` between the two calls: `onEvent` and the `onAction` that eventually answers it are separate invocations, possibly seconds or minutes apart, with no shared in-memory state between them. Buttons must not outlive the wait. Nothing tells your extension when a held request times out, so store when it arrived too, and once the `?await=` window has passed, re-emit the blip without Approve/Deny and say where to answer instead (the caller has usually fallen back to its own prompt). Return `{ nextRunAfter }` from `update()` to wake right when that happens; Blipbar’s Claude Code extension does exactly this. And when the caller moves on some other way (the tool ran, a new prompt came in), drop the question then. Watched files work the same way, without the reply mechanics: ```ts async onEvent(ctx, event) { if (event.type === "file") { ctx.log.info(`watched paths changed: ${event.paths.join(", ")}`); // … re-read whatever changed and ctx.emit() the update. } }, ``` You can also push a blip from *outside* the runtime entirely, with no extension and no manifest, straight to the app’s local HTTP server (`POST /v1/blips`, authorized with the bearer token in `~/Library/Application Support/Blipbar/webhook.json`). See “Webhooks” in [the runtime protocol](/docs/reference/runtime-protocol/) for that path (a cron script, a `git` hook, a build on this Mac). The server only listens on this Mac, so a CI job elsewhere can’t reach it. It’s how `blipkit send` (below) works too, with an extension’s `update()` output as the body instead of a hand-written JSON file. # Tools A **tool** is a small utility that opens in place inside the notch: the tool tray along the bottom of the panel holds the ones the user chose, next to Blipbar’s own (JSON, Color, HTTP…). Where a blip *comes to you*, a tool is something you *reach for*: look up a package, decode a token, convert a file. An extension can offer blips, tools, or both. | Build a… | When the thing is… | Runs | | -------- | --------------------------------------------------------------- | ----------------------------------------- | | Blip | Something you keep checking: a value, a status, a session | On the host’s schedule, while it’s placed | | Tool | Something you do on demand, with inputs: a lookup, a conversion | Only when the user presses its button | A tool **declares** its inputs (`fields`) and buttons (`actions`) and returns typed output **blocks**; Blipbar draws all of it with the same kit its built-in tools use. You never draw a pixel, which is why an extension’s tool looks like one that shipped with the app. ## A complete tool ```ts import { code, defineExtension, status, table, tool } from "@blipbar/api"; const WHO = ["Owner", "Group", "Everyone"] as const; /** 7 → "rwx", 5 → "r-x". */ function letters(digit: number): string { return `${digit & 4 ? "r" : "-"}${digit & 2 ? "w" : "-"}${digit & 1 ? "x" : "-"}`; } /** 7 → "Read, write, run", 0 → "Nothing". */ function words(digit: number): string { const can = [digit & 4 && "read", digit & 2 && "write", digit & 1 && "run"].filter(Boolean).join(", "); return can ? can[0]!.toUpperCase() + can.slice(1) : "Nothing"; } export default defineExtension({ tools: { chmod: tool({ title: "chmod", icon: "lock.shield", summary: "Read Unix file permissions", category: "developer", fields: [ { id: "mode", type: "text", placeholder: "755" }, { id: "show", type: "choice", options: [ { id: "letters", title: "rwx" }, { id: "words", title: "Words" }, ], default: "letters", }, ], actions: [{ id: "explain", title: "Explain", role: "primary" }], live: true, // local and instant, so it runs as you type async run(_action, values) { // Inferred from `fields`: values.mode is a string, values.show is "letters" | "words". const mode = values.mode.trim(); if (!/^[0-7]{3}$/.test(mode)) return [status("Type three digits from 0 to 7, like 755.", "warning")]; const digits = [...mode].map(Number); const everyoneCanWrite = ((digits[2] ?? 0) & 2) !== 0; const symbolic = digits.map(letters).join(""); return [ everyoneCanWrite ? status(`${symbolic} · anyone can change it`, "warning") : status(symbolic), table(digits.map((digit, i) => ({ name: WHO[i] ?? "", value: values.show === "letters" ? letters(digit) : words(digit) }))), code(`chmod ${mode} file`, "shell"), ]; }, }), }, }); ``` That’s the whole extension: `package.json` needs no `interval` or `update()`, and no permissions, since nothing leaves the Mac. `values` is typed from `fields` (a typo like `values.moed` doesn’t compile), and `action` from `actions`. What happens around it: * **`blipkit build`** writes `dist/tools.json` beside the bundle: each tool’s spec, validated. The app reads it when it finds your extension, so the tool shows up in the tray’s list without running any of your code. An invalid tool fails the build and names the problem (`tools.chmod.fields[1].default: must be one of the options' ids`). * **The tool’s id** is `/`, where the name is its key in `tools` (`dev.yourname.chmod/chmod`). Keep names stable: it’s what the user’s tray stores. * **Running it** loads your extension on demand, with its blip side off (no `interval`, no `update()`), so a tool in the tray costs nothing until it’s used. ## Fields | `type` | Draws as | Extra keys | `values[id]` is | | -------- | --------------------------------------------------- | ------------------------------------------------------------------ | -------------------------- | | `text` | One line | | `string` | | `code` | Several lines, monospaced | `language?` (`"json"`, `"http"`) | `string` | | `file` | A drop well with Choose… | `types?` (uniform type identifiers, default any file), `multiple?` | `string[]`, absolute paths | | `choice` | Segments (a menu past 5) | `options: [{ id, title }]` | the chosen option’s `id` | | `toggle` | A switch, label beside it | | `boolean` | | `number` | A value, with a slider when `min` and `max` are set | `min?`, `max?`, `step?`, `unit?` | `number` | | `pairs` | Name/value rows with add and remove | | `{ name, value }[]` | Every field takes `id`, `label?` (omit it for a tool’s single main input), `placeholder?` and `default?`. `values` is always complete: a field the user left alone arrives as its `default`, or empty (`""`, `min` or `0`, `false`, the first option, `[]`). Spec-level options: `summary` (one line: what it does for you), `category` (`image`, `video`, `pdf`, `developer`, `design`, `everyday`), `live` (run the first action as the inputs change; only for instant, local work, never the network), `remembersInputs` (default `true`), and `clipboardKinds` (offer this tool when the clipboard holds `json`, `jwt`, `url`, `base64`, `timestamp`, `color`, `uuid` or `curl`). ## Blocks | Builder | Draws as | | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `status(text, tone?)` | The headline. `tone` is `neutral` (default), `positive` ✓, `warning` !, or `critical` ×: the shape carries the meaning, never color alone. | | `text(value)` | Plain, selectable text. The same `text()` you use for typed values. | | `code(text, language?)` | A monospaced well with Copy, scrolling past a few lines. | | `table(rows)` | Label/value rows with Copy. Pass `[{ name, value, url? }]`, or an object (`table({ License: pkg.license })`) whose `undefined` values are skipped. A row with a `url` (http/https) becomes a link. | | `files(paths)` | Files you produced: revealed in Finder or dragged out. | | `image(path)` | An image file: a QR code, a preview. | | `color(hex)` | A swatch with its hex. | **Table values are typed**, exactly like a blip’s: `number(157687813)`, `date(publishedAt)`, `currency(12.5, "USD")`, `number(75429, { unit: "bytes" })`. The app formats them for the viewer’s locale (and dates read relative, “3 days ago”), so never format a number or a date into a string yourself. ## Errors, files, the network `run()` gets a `ToolContext`: the usual `ctx` (`preferences`, `storage`, `log`, `exec`…) plus two things tools need. * **Throw to fail**, with a message for the user: what happened and what to do. The app shows exactly your message (never a stack trace) as a failed status. Something that *isn’t* a failure, like “no package with that name”, is better as a `warning` status you return. * **`ctx.signal`** is aborted when a run takes over 30 seconds. Pass it to `fetch`, and declare every host in `permissions.network`, exactly as for blips. * **`ctx.dataDir`** is a folder only your extension writes to. A file you return in `files`, `image` or a drop’s `outputs` must be inside it, or be one the user gave the tool in this run; anything else is dropped from the output. Never write next to, or over, the user’s own files. ```ts async run(_action, values, ctx) { let response: Response; try { response = await fetch(`https://api.example.com/items/${encodeURIComponent(values.id)}`, { signal: ctx.signal }); } catch { throw new Error("Can't reach Example. Check your connection and try again."); } if (response.status === 404) return [status(`No item “${values.id}”`, "warning")]; // … } ``` Blipbar’s own npm tool is built this way: three requests in parallel, typed facts, links, and deprecated or missing packages as warnings. Test yours against responses you captured once, not against the live service. **Drop actions** make a tool a drop target: drag files onto the notch and it offers them, no questions asked. Declare `dropActions: [{ id, title, icon, accepts }]` and a `drop(action, files, ctx)` that returns `{ message, outputs?, copiedText? }`, writing its outputs into `ctx.dataDir`. ## Testing a tool * `npx blipkit validate --tool chmod --values '{"mode": "644"}'` runs the tool once (the first action unless you pass `--action`) and validates what it returns. Add `--json` for just the blocks. * Keep the logic in plain functions (data in, blocks out) and unit-test those; call `extension.tools.chmod.run("explain", values, ctx)` directly for the rest. `defineExtension` keeps your object’s type, so that call is fully typed. ## Taste The tool sits in a black notch beside the user’s work. The kit keeps it native; these keep it calm. * **One job, one screen.** A title of one or two words (it sits under an icon), one main input, and at most one primary action with a verb: “Look Up”, “Convert”. * **Fit without scrolling.** The panel tops out around 440 points. Show the four to six facts that answer the question; link to the rest. * **Headline first.** Start the output with a `status` that answers it (“express 5.2.1”, “rwxr-xr-x”), then detail. * **Say what to do.** “Can’t reach npm. Check your connection and try again.”, not “Error: fetch failed”. Sentence case, no exclamation marks. * **Tones mean state.** `positive` for success, `warning` for “look at this”, `critical` for failure; `neutral` for everything else, which is most things. * **Typed values, not strings.** Numbers, sizes, money and dates go through the value helpers so they read right in every locale. * **No network in `live` tools**, and nothing leaves the Mac that the user didn’t ask to send. # The blipkit CLI | Command | What it does | | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `blipkit new ` | Scaffolds `package.json`, `src/index.ts`, `tsconfig.json`, and a `README.md` for a new extension. | | `blipkit dev` | Builds in watch mode (esbuild) and symlinks the folder into Blipbar’s `Extensions/` folder. The host reloads your extension on every rebuild, and its log (`ctx.log` lines and errors) prints in the terminal as `[log]`. Runs until you `Ctrl+C`. | | `blipkit build` | Bundles `src/index.ts` → `dist/index.js` (esbuild: CommonJS, `node22` target, `@blipbar/api` inlined), validates the manifest shape, and writes `dist/tools.json` if you have tools ([Tools](/docs/guide/tools/)). This is what actually ships: `package.json` plus `dist/` is the whole extension. | | `blipkit validate` | Builds (validating your tools), then runs your `update()` **once** against a mock host (in-memory storage, recorded `emit`/`end`/`notify`/…, real `exec` gated by your declared `permissions.exec`), stamps each emitted blip the way the runtime would, and validates every one against the schema. Prints what it would emit. `--prefs ''` sets preferences for the run, and `--tool --values ''` runs one tool too ([Testing a tool](/docs/guide/tools/#testing-a-tool)). Like the app, it won’t run without the preferences marked `required`, and it holds `fetch` to `permissions.network`: a refused request fails the check even when your code catches it. Use it constantly while developing: it’s the fastest “did I break the shape of my payload” check, with no app required. | | `blipkit send ` | POSTs a payload (or a JSON array of several) straight to a *running* Blipbar app’s `/v1/blips`, reading the port and token from `webhook.json`. Useful for testing how a specific payload actually renders, separate from your extension’s own update logic. | | `blipkit pack` | Builds, then writes `-.blipbar`: a zip of `package.json`, `dist/` (without source maps) and your README and licence. One file to share ([A `.blipbar` file](/docs/guide/sharing-it/#a-blipbar-file)). It prints the file’s SHA-256 and the entry that lists it in the directory ([The directory](/docs/guide/sharing-it/#the-directory)), with the repo taken from `package.json`’s `repository`. | `blipkit validate` only exercises `update()` (and, with `--tool`, one tool, [Testing a tool](/docs/guide/tools/#testing-a-tool)). It has no mock webhook or action to drive `onEvent`/`onAction` through. Test those with real unit tests instead ([Testing](/docs/guide/testing/)). # Testing **Fast loop:** `npx blipkit validate` after every meaningful change. It catches schema violations (a field with the wrong type, a missing required field, a link that isn’t http or https) in milliseconds, without the app running. It checks shape, not limits: extra actions or facts aren’t flagged (the runtime cuts them later), and a date field fed a relative number is still a valid date, so [Builders: one per layout](/docs/guide/the-sdk-api/#builders-one-per-layout)’s gotcha is yours to catch. **Unit-test the pure logic.** `onEvent`/`onAction` aren’t covered by `validate`, and neither is anything conditional on preferences or prior state. The pattern Blipbar’s own extensions use: keep your actual decision logic (what state a session should move to given an event, what a webhook body means) in plain, exported functions that take data in and return data out, with no `ctx`, and re-export them below your `defineExtension` call for tests to import directly: ```ts export default defineExtension({ /* … */ }); // Re-exported so tests can exercise the pure pieces directly without going through // defineExtension's Context-shaped surface. export { applyEvent, sweepStaleness } from "./state-machine"; ``` Then test with Node’s built-in runner (no extra dependency): src/test/state-machine.test.ts ```ts import assert from "node:assert/strict"; import { test } from "node:test"; import { applyEvent } from "../state-machine"; test("a permission-request event moves a session to attention", () => { const next = applyEvent(undefined, fixtureEvent, "2026-09-25T00:00:00Z", "reply-123"); assert.equal(next.state, "attention"); }); ``` A setup that scales: fixture JSON files of real payloads beside the tests, a mock `Context` for driving `onAction` end to end, and a `test` script that compiles the tests with `tsc` into a folder of their own and runs `node --test` on the output. **Before you build against a mock, check the real shape once.** If you’re integrating with an external hook system or API, log the raw payload (`ctx.log.debug(JSON.stringify(event.body))`) the first time it fires for real and compare it against whatever you assumed. Field names and nesting from a vendor’s docs are exactly the kind of thing that drifts. # Sharing it ## A `.blipbar` file `blipkit pack` makes one file anyone can install: they open it (double-click, or Settings › Blips › Install…), and Blipbar shows what it is before anything is installed: its title, version, author and id, and everything its manifest lets it do, the hosts it can reach first, and running programs or touching files in orange, since that goes beyond a network extension. Install copies it in, places it in the panel and selects it in Settings. Opening a newer version later replaces it (settings and saved keys stay, since they go by id); if the author differs from the one installed, the install sheet says so in orange, because the new one would take over those keys. Two things Blipbar refuses: * **An id in Blipbar’s own space** (`dev.blipbar.…`). Settings and saved keys go by id, so an extension claiming `dev.blipbar.stripe` would read Stripe’s key. Only the bundled extensions use it (and `blipkit dev`’s link while working on one of them); `blipkit pack` won’t pack one. * **A file that isn’t just an extension:** an entry that would land outside its folder, a link, more than 2,000 entries or 100 MB unpacked. Blipbar reads the zip’s table of contents before writing anything. Put the file anywhere people can download it (a GitHub release is the usual place). ## Before you share it People install an extension expecting it to look and behave like the rest of Blipbar, and the install sheet is where they decide whether to trust it. Check yours for the ways an extension can break that: * **Manifest hygiene.** `id` is reverse-DNS, in a space of your own (a domain you own, reversed, or `dev.yourname.`). `title` and `description` read like the ones in [The manifest](/docs/guide/the-manifest/)’s table, not a placeholder. `icon` is a real SF Symbol. `categories` matches an existing one where possible. * **Least-privilege permissions.** `network` lists exactly the hosts you call, not a wildcard for everything. `exec` is empty unless you genuinely shell out. `files` covers only paths you actually touch. Remember none of this is a sandbox ([Permissions](/docs/guide/permissions/)), so the person installing your extension is trusting your manifest to be honest. * **No formatting workarounds.** Numbers, currency, dates, and durations go through the value helpers ([Value helpers](/docs/guide/the-sdk-api/#value-helpers)), never pre-formatted into a string. A `value: "$1,247"` or a hand-built `"2h 14m"` string can’t be formatted for the viewer’s locale, and it makes your blip read differently from every other one. * **Layout fits the data**, not the other way around. Don’t force a `list` because you want five lines of text: pick `stat`/`progress`/`session`/`score`/`countdown`/`meter` if one of those is the actual shape of what you’re showing ([Builders: one per layout](/docs/guide/the-sdk-api/#builders-one-per-layout) says what each is for). * **States mean what they say.** `attention` is for something that needs a decision, not “any update at all”: it’s the one state that plays the notch’s attention animation and reminds again while it waits. `stale`/`offline` show the last known value, dimmed, with when it was last updated: never a live-looking number that’s actually old data from a dead connection. `offline` is for the network; a wrong setting (a 404, an unknown symbol or league, a link to a web page instead of a feed) is `failure`, with a subtitle that says what to fix, since retrying will never fix it. * **At most 4 actions, in order** (small spaces show the first one or two), **and destructive is really destructive.** `role: "destructive"` asks first automatically: don’t use it for something reversible just to make it look serious, and don’t skip it (by choosing a different role) for something that actually can’t be undone. * **Stay under 4KB.** A payload over 4KB after truncation is dropped. `series` (≤48 points), `facts` (≤4), and `list.items` (≤5) are cut to size for you, but you can still build a payload that’s too large in other fields (a long `subtitle`, a huge `event` string). `blipkit validate` shows you the real serialized payload; eyeball its size for anything text-heavy. * **`interval` matches how often the data actually changes.** Polling every 10 seconds for a value that changes hourly spends battery for nothing, and battery drain is the usual reason people remove a notch app. * **Accessibility isn’t optional.** This one is entirely the renderer’s job once you’ve picked a real layout and real typed values: you get it for free by not fighting the SDK (no raw `Text` for a number, no custom view). If you find yourself reaching for something the SDK doesn’t expose to get a look you want, that’s the signal to simplify the blip, not to work around the constraint. If your extension needs something the SDK doesn’t support (a permission, an event type, a layout), ask in the Discord () rather than routing around the constraint from inside your extension. ## The directory Settings › Blips › Browse… lists the extensions in Blipbar’s directory, [github.com/blipbar/extensions](https://github.com/blipbar/extensions). To add yours, make a GitHub release of the `.blipbar` file from a public repo, then open a pull request there that adds one small entry: your id, the repo, the version, the file’s release URL and its SHA-256 (`blipkit pack` prints the whole entry). A check downloads the file and verifies the hash, then a person reads the source before it’s merged. Every update is a new entry version, reviewed the same way. Blipbar checks each download against the entry’s hash before the install sheet opens, so what installs is what was reviewed. # Building on the kit An integration with a service people live in (an issue tracker, an error tracker, an on-call tool) has the same shape every time: things assigned to you, an inbox that needs triage, a sign-in, and a status line for when it can’t show anything. The kit is that shape, shared: use it, and your integration behaves like Blipbar’s own GitHub and Linear extensions without writing them again. The design and its edge cases are in [the integration kit](/docs/reference/integration-kit/). ## Sign-in, run by the app Declare the service in the manifest and never write a sign-in: ```json "oauth": { "acme": { "title": "Acme", "authorizeUrl": "https://acme.com/oauth/authorize", "tokenUrl": "https://api.acme.com/oauth/token", "revokeUrl": "https://api.acme.com/oauth/revoke", "clientId": "your-public-client-id", "scopes": ["read", "write"] } }, "permissions": { "network": ["api.acme.com"] } ``` Register `http://127.0.0.1:47812/oauth/callback` as the redirect in Acme’s developer settings, exactly. The app runs OAuth 2.0 with PKCE (no client secret: a secret inside an app anyone can download isn’t secret), keeps the tokens in the Keychain, renews them, and lists each account in Settings with Disconnect. ```ts // A Connect button (the kit's CONNECT, on your status blip) runs the sign-in: case "connect": return ctx.oauth.connect(); // Each run, every account signed in: for (const connection of await ctx.oauth.connections()) { if (connection.needsReconnect) continue; // offer Reconnect instead const token = await ctx.oauth.token({ connection: connection.id }); // renewed for you if (!token) continue; // stopped working: Reconnect const me = await whoAmI(token.accessToken); // Name it for Settings, once: "acme-inc", and the same workspace connected twice stays one. if (connection.account !== me.orgId) await ctx.oauth.describe(connection.id, { account: me.orgId, label: me.orgName }); } // The service rejected a token (revoked on the web): it needs signing in again. await ctx.oauth.invalidate(connection.id); ``` One account is simpler: `await ctx.oauth.token()` is the first connection that works (one that stopped working may still be listed beside it until the next sign-in replaces it), and undefined means Connect, or Reconnect when `connections()` isn’t empty. `token()` rejects only when an expired token couldn’t be renewed for want of the network: show that as offline, never as signed out. ## Triage: Done, Snooze, Mute ```ts import { TRIAGE, markDone, snooze, snoozeUntil, snoozedItem, sortDone, sortSnoozed, nextWake } from "@blipbar/api"; // Each item with the buttons that mean something for it: item.actions = [TRIAGE.done, TRIAGE.snooze, ...(canMute ? [TRIAGE.mute] : [])]; ``` `TRIAGE` buttons dismiss (the row leaves at once) and mean the same everywhere. If the service has its own done or snooze, call it from `onAction` and refresh. If it doesn’t, keep a `DoneBook` and a `SnoozeBook` in your storage, with each item’s `signature` (what it is now: its latest comment, its state), and sort each run’s items through them: ```ts const { shown, book: done } = sortDone(entries, memory.done, now); // done here, until it changes const { awake, asleep, book: snoozed } = sortSnoozed(shown, memory.snoozed, now); const items = awake.map((e) => e.item); if (asleep.length > 0) items.push(snoozedItem(asleep.length, { until: nextWake(snoozed), now })); // "2 snoozed · Wake all" // In onAction: case "snooze": memory.snoozed = snooze(memory.snoozed, action.itemId, signatures[action.itemId], snoozeUntil(now, await ctx.workingHours())); case "done": memory.done = markDone(memory.done, action.itemId, signatures[action.itemId], now); case "wake": memory.snoozed = {}; ``` `snoozeUntil` is the next working morning: Friday evening means Monday. ## A press under way After a button that changes something (Start, Re-run), record it and pass items through `underWay` until the service shows it happened, so the item says “Starting ENG-12” instead of looking unchanged: ```ts memory.pressed[itemId] = { what: "start", at: now.toISOString(), doing: `Starting ${key}`, word: "Starting" }; // each run: memory.pressed = pruneUnderWay(memory.pressed, now, (id) => started(id)); items = items.map((item) => underWay(item, memory.pressed[item.id], now)); ``` ## When there’s nothing to show ```ts ctx.emit(connectionBlip({ key: "issues", service: "Acme", trouble: "signed-out", canConnect: true })); ``` `signed-out`, `rejected`, `reconnect`, `offline`, `rate-limited` and `expiring` (a key about to run out, said days ahead), in the same words as every other service. Emit it under your main blip’s key, so it takes that blip’s place. For network trouble, leave what’s on screen alone (it dims to stale by itself) and back off with `backoff(failures)`; show `offline` only right after launch, when the notch has nothing of yours yet. ## Fresh without a server Between full fetches, a cheap look: `probe(targets, etags, fetch)` sends conditional requests (`If-None-Match`), which cost nothing on services that answer `304`. Fetch fully only when it says `changed`, and every ten minutes regardless. A GraphQL service can’t answer `304`: ask a small query for what changes (ids and `updatedAt`) and compare its digest, as Linear does. If the look itself fails, fetch fully: that’s how trouble shows at once rather than ten minutes later. ## Words `age`, `clip`, `plural`, `names`, `firstName`, `lowerFirst`, `joinLine`, `howLong`, `timeLeft`, `dayWords`: how every integration says how long ago, how long left, which day, who and how many. `blipkit new --template tracker` starts an integration with all of this in place. ## Payments, deploys, errors, traffic Four kinds of service share more than a shape: the arithmetic, the words, what’s news. Map your service onto the kit’s model and use the rest: ```ts // Payments (Stripe; Lemon Squeezy, Paddle, Polar): amounts in minor units, in the currency the account is paid in. const { today, yesterday } = dayWindows(now); ctx.emit(revenueBlip({ key: "revenue", currency, today: revenueWindow(ledger, today.from, today.to, currency), yesterday: revenueWindow(ledger, yesterday.from, yesterday.to, currency) })); ctx.emit(salesBlip({ key: "sales", sales, fresh, now })); // fresh: news(ids).fresh, through saleAlerts ctx.emit(mrrBlip({ key: "mrr", currency, revenue: recurringRevenue(subscriptions, currency), history, now })); // Deploys (Vercel; Netlify, Render, Cloudflare Pages): the newest of each line, time left from the usual. const lines = newestPerLine(deploys); ctx.emit(deploysBlip({ key: "deploys", items: lines.map((d) => deployItem(d, { now, usual: usualSeconds(deploys, (x) => x.project === d.project) })) })); // Errors (Sentry; Bugsnag, Rollbar, Honeybadger): Resolve and Archive are Done and Mute, in the trackers' words. ctx.emit(needsYouBlip({ key: "issues", items: issues.map((i) => errorItem(i, now, { alert: errorAlerts(i, "important"), actions: [ERROR_ACTIONS.resolve, ERROR_ACTIONS.archive] })) })); // Traffic (PostHog; Plausible, Umami, Fathom): visitors against yesterday by now, a surge called out. const sources = mergeSources(rawSources); // news.ycombinator.com reads as Hacker News ctx.emit(visitorsBlip({ key: "visitors", today: { visitors, newByHour }, yesterday: { visitors: yesterdayByNow }, live, surge: sources.find(surging) })); ctx.emit(breakdownBlip({ key: "sources", title: "Sources today", rows: sources, total: visitors })); ``` Give calm lines no state: a list row’s dots are for what’s news or needs you. The pieces and their edge cases are in [the integration kit](/docs/reference/integration-kit/), and `blipkit new <name> --template payments|deploys|errors|traffic` starts one. # Webhooks > Send blips to Blipbar from a script or app on this Mac. The host listens on `127.0.0.1:`, with the port in `webhook.json` (47811 by default, falling back if taken). Every request needs the header `Authorization: Bearer `. Browsers can’t send that header cross-origin without a preflight, and the server never answers preflights, so web pages can’t reach it. | Route | Body | Effect | | ------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET /v1/health` | none | `{ok: true, version}` | | `POST /v1/blips` | a payload, or an array of payloads | Pushed straight into the store as source `webhook`. An `id` without a `/` gets the `webhook/` prefix (a missing one is made from the `title`: `webhook/hello-blipbar`), and a missing `seq` is stamped. All or nothing: one bad payload is a `400` naming it and its field (`{"error": "invalid payload at [1]: \"data.value.amount\" should be a number"}`). | | `POST /v1/blips//end` | `{state?, dismissAfter?}` | ends the blip | | `DELETE /v1/blips/` | none | removes the blip | | `POST /v1/hooks/` | any JSON | → `extension.event {type: "webhook"}`. With `?await=` (at most 600), the response is held until the extension calls `ctx.respond(...)` or the wait runs out (then `204`). This is how “approve from the notch” works for agent permission hooks. | `webhook.json` lives in `~/Library/Application Support/Blipbar/`. Blipbar writes a new token each time it launches, so read the file on every run instead of copying the token. A pushed blip needs a `title`, a `layout` and that layout’s `data`, the same shapes the SDK’s builders make. `v`, `emittedAt` and `seq` are filled in when they’re left out. Post the same `id` again to update it. The `end` and `DELETE` routes take the id either way (`backup` or `webhook/backup`). While the notch is open, a pushed blip shows at the end of the panel. ```sh config=~/Library/Application\ Support/Blipbar/webhook.json port=$(plutil -extract port raw -o - "$config") token=$(plutil -extract token raw -o - "$config") # Show a backup running, 40% through. Post again with the same id to move it on. curl -s "http://127.0.0.1:$port/v1/blips" \ -H "Authorization: Bearer $token" -H "Content-Type: application/json" \ -d '{"id": "backup", "title": "Backup", "icon": "externaldrive.fill", "state": "running", "layout": "progress", "data": {"fraction": 0.4, "step": "Copying Photos"}}' # When it's done. curl -s -X POST "http://127.0.0.1:$port/v1/blips/webhook/backup/end" \ -H "Authorization: Bearer $token" -H "Content-Type: application/json" \ -d '{"state": "success"}' ``` # Runtime protocol Version 1 of the contract between the Blipbar app, the Node runtime inside it, the SDK (`@blipbar/api`), and extensions. ## 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 ```plaintext ~/Library/Application Support/Blipbar/ Extensions// user-installed or dev-linked extensions (a folder or a symlink) Data// per-extension storage, owned by the runtime webhook.json {"port": 47811, "token": ""} (mode 0600, rewritten at launch) bin/blipbar-hook helper that agent hooks call (installed by the host) Blipbar.app/Contents/Resources/Extensions// 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. ## Manifest (`package.json` → `blipbar`) ```json { "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/` 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](/docs/reference/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. ```json "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 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) | 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?: {"": 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) | Method | Params | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `blip.update` | `{extensionId, payload}`. The payload is complete: `id` is `"/"`, 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) | 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 “\ 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. ### Rules * **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. ## Tools 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 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. ### 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. ```json { "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`) 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}`) | 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": "…"}`. ### Rules * **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. ## Webhooks The host’s HTTP server has [its own page](/docs/reference/webhooks/). ## SDK surface (`@blipbar/api`) ```ts 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](/docs/guide/concepts/)): ```ts 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](/docs/reference/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`) | 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.`. | # The integration kit Most services people live in (an issue tracker, an error tracker, an on-call tool) have the same shape: things assigned to you, and an inbox that needs triage. The kit is what they share, so an integration built on it behaves like Blipbar’s own GitHub and Linear extensions without writing them again. The renderer is already shared: any extension that sends list items with buttons, stages or meters gets the same visuals, keyboard walk and peeks. The kit shares behavior, in three layers. 1. **The app** does what must be done once and done safely: sign-in (the browser flow, tokens in the Keychain, refresh, revoke), and what makes every button feel instant. 2. **The SDK** (`@blipbar/api`) holds the patterns, as pure functions an extension calls with its own memory: the triage vocabulary, snoozing, presses under way, news, change checks, timing, wording. 3. **Each extension** keeps only what’s truly its own: the service’s queries, what its states mean, and the calls behind each button. ## 1. The app: instant presses ### A press shows at once An extension’s button (anything but a link or a copy) looks pressed the moment it’s pressed: dimmed, and a second press does nothing, until the extension has answered, at most 15 seconds. Nothing spins: motion only on change is a design rule, and a press is a change, not a loop. Then the extension’s own next update says what happened. ### Dismissing actions (`BlipAction.dismisses`) Done, Snooze and Mute take an item out of the list. Waiting a second for the extension to fetch again, with the item still sitting there, reads as “didn’t work”. So an item action marked `dismisses: true` takes the item out of the list the moment it’s pressed: * The row goes (with the list’s usual transition), the list’s count drops by one, the keyboard selection moves to the next item, and a list left empty says so. * It stays out while the action runs, even if an update the extension sent *before* the press lands meanwhile (a poll already in flight). * Once the action has finished, the source’s next update decides: normally the item is gone for real; if the extension kept it (the service refused), it’s back, with the extension’s own line about why. * If the action fails (the extension threw, timed out, or crashed), the item comes back at once. * If no update comes within 20 seconds of the action finishing, the item comes back as last sent: the notch never hides something the source still shows. ### Copying (`BlipAction.copy`) A button that copies text (a branch name, a code, a tracking number) with no round trip: the app copies it and says “Copied” under the notch. At most 1,000 characters. ## 2. The app: sign-in (`ctx.oauth`) Sign-in is security work, and every service does it the same way (OAuth 2.0 authorization code with PKCE, RFC 7636, and a loopback redirect, RFC 8252). So the app does it once, for every extension, instead of each extension running its own server. ### Declared in the manifest ```json "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": ",", "params": { "prompt": "consent" } } } ``` * Public clients only: there’s no field for a client secret, because a secret inside an app anyone can download isn’t secret. A service whose token exchange needs one can’t use this (GitHub’s own sign-in uses the device flow for that reason). * `authorizeUrl`, `tokenUrl` and `revokeUrl` must be `https`, and the token and revoke hosts must be in `permissions.network`: an extension can’t send your code anywhere it doesn’t already declare. * The redirect is always `http://127.0.0.1:47812/oauth/callback`, the app’s, and the service’s app must register exactly that. ### The flow 1. The extension calls `ctx.oauth.connect()` (from its Connect button). The app makes a PKCE verifier and a `state`, starts listening on 127.0.0.1:47812 (loopback only), opens the authorize page in the default browser, and answers right away: a sign-in takes as long as the person needs, and nothing waits on it. 2. The browser comes back with a code. The app checks the `state`, exchanges the code (with the verifier, never a secret), keeps the tokens in the Keychain under the extension’s own service, says “Linear is connected” under the notch, answers the browser with a page that says so, and runs the extension at once. 3. `ctx.oauth.token()` hands the extension an access token, refreshed first when it’s within five minutes of expiring. The refresh token never leaves the app. ### Several accounts Every connection is its own entry: two Linear workspaces, a work and a personal Jira. `ctx.oauth.connections()` lists them. The app can’t know who a token belongs to, so the extension says so once it has asked the service: `ctx.oauth.describe(connection, { account, label })`. Connecting the same account again replaces the older entry (the newer tokens win) instead of adding a duplicate. A connection that was never described and stopped working is replaced by the next sign-in straight away: an extension that doesn’t tell accounts apart has one account, and Reconnect should leave exactly one entry behind. While a working connection and one that needs signing in again sit side by side, a workspace that’s showing through the working one gets no “signed out” line. ### Settings An extension with a sign-in gets an Accounts section: each connection by its label (“Acme”), when it was connected, Disconnect, and Connect (or Connect Another). A connection that needs signing in again says Reconnect. ### Edge cases | What happens | What the app does | | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | | Connect pressed twice | The pending sign-in (under 10 minutes old) is reused: the same page opens again, and whichever tab finishes wins | | The browser never comes back | The sign-in expires after 10 minutes; the port closes when nothing is pending | | A stale or foreign `state` arrives | “This sign-in link has expired. Start again from Blipbar.” Nothing is stored | | The person declines | “Cancelled. You can close this tab.” No line under the notch: they know | | The token exchange fails | The page and the line under the notch say why (“Linear refused the code”) | | Port 47812 is taken | Connect answers “Another app is using Blipbar’s sign-in port”, and nothing opens | | The app quits mid-sign-in | The browser can’t reach the app; the next Connect starts afresh | | Two refreshes at once | One refresh per connection at a time, the rest wait for it, so a rotating refresh token is never used twice | | The refresh is refused (`invalid_grant`) | The connection is kept but marked as needing sign-in again; `token()` returns nothing; Settings and the extension say Reconnect | | The refresh can’t reach the service | The current token if it’s still valid, else an error the extension shows as offline (not as signed out) | | No expiry or refresh token given | The token is used until the service rejects it; the extension then calls `ctx.oauth.invalidate(connection)` and it needs signing in again | | The service rejects a token early (revoked on the web) | Same: `invalidate`, then Reconnect | | Disconnect | The token is revoked where the service allows it (best effort), removed, and the extension runs again | | Keychain item from another build | Reads as missing (never prompts), so it asks to connect again, like any secret | | A new version asks for more scopes | Each connection keeps the scopes granted; the extension compares and can ask to reconnect | | The extension is uninstalled | Its connections are signed out, revoked where the service allows, and removed | ## 3. The SDK kit Pure functions and small types, exported from `@blipbar/api`. Memory is the extension’s (`ctx.storage`); the kit never stores anything itself, so every decision is testable as data in, data out. | Module | What it gives | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Triage** | `TRIAGE.done`, `TRIAGE.snooze`, `TRIAGE.mute`: one set of ids, labels, icons and `dismisses` for every extension. `snoozeUntil(now, workingHours)`: the next start of working hours (9:00 without them), never less than an hour away. `SnoozeBook` and `DoneBook` for services without their own snooze or done: hidden until then, or until the item changes (its `signature`). `snoozedItem(count, { until, now })`: the “2 snoozed · Wake all” item | | **Under way** | `Pressed` and `underWay(item, pressed, now)`: the moment something’s pressed, the item says what’s happening (“Starting ENG-12”) and its buttons step aside, until the service shows it or three minutes pass | | **News** | `news(keys, memory)`: what’s new since last time, in bounded memory; a first look catches up and announces nothing | | **Change checks** | `probe(targets, etags, fetch)`: conditional requests (`If-None-Match`, with `Cache-Control: max-age=0`: without it Node’s `fetch` sends `no-cache`, which Vercel reads as “send it all”), free on services that answer `304`, to fetch fully only when something changed | | **Timing** | `backoff(failures)`, `waitForReset(resetAt, now)`, `staleAfter(nextRunAfter, now)` | | **Connection** | `connectionBlip(...)`: signed out, rejected, needs signing in again, offline, rate limited, and a key about to expire (said days ahead), in the same words and with the same Connect for every service | | **Needs you** | `needsYouBlip(...)`, `byUrgency(items)`: what needs you first, the list lit by its worst item, and calm (“Nothing needs you”) when empty, so it can sit in an ear | | **Wording** | `age`, `clip`, `plural`, `names`, `firstName`, `lowerFirst`, `joinLine`, `howLong`, `timeLeft`, `dayWords` | `ctx.workingHours()` tells an extension the person’s working hours (Settings › General), so “snooze until the morning” on a Friday evening means Monday. ## 4. What stays in each extension The kit holds the shared behavior; each extension still decides what its service’s data means. Blipbar’s Linear extension is a worked example. It signs in through the app (an API key still works), with several workspaces. It offers Start on each to-do issue and Copy branch on each started one. Its inbox has Done (archived in Linear), Snooze (Linear’s own snooze, so the Linear app agrees) and Mute (unsubscribed from the issue), with “2 snoozed · Wake all”. Start shows “Starting…” until Linear shows it. A tiny check runs every minute; the full fetch runs only when something changed, and every 10 minutes regardless. Signed out, offline and rate limited use the kit’s blips, the same as every service. The edge cases it handles, beyond the kit’s, are the kind yours will meet: * **Workspaces:** each is fetched on its own and merged; an item names its workspace only when there’s more than one; the same workspace reached by a key and a sign-in counts once. * **Grouped notifications:** Done and Snooze act on every notification about that issue (Linear’s `…All` calls), as the Linear inbox does. * **Mute** is offered only where it means something: an issue that isn’t yours. Your own issue would keep notifying you as its assignee. * **A read-only API key** can’t Start or triage: the first refusal is remembered and the buttons go, rather than failing on every press. * **Start** still re-reads the issue first, so a stale list never drags a finished or reassigned issue back into progress. * **A look that fails** fetches fully: a key Linear stopped accepting, or the network going, is handled (and backed off from) at once, not at the next ten-minute fetch. * **One workspace unreachable** while the others answer: the notch is left alone for a few runs (it’s usually the network, for all of them), then shows the rest with a “Can’t reach Acme” line. ## 5. Domains: payments, deploys, errors, traffic Beyond trackers, four kinds of service come up again and again, and each shares far more than its shape: the same arithmetic, the same words, the same idea of what’s news. Each domain is a part of the kit with a model an extension maps its service onto, and the rest built on it. Blipbar’s own Stripe and Paddle, Vercel and Cloudflare, Sentry, and PostHog extensions are built on them; Lemon Squeezy, Polar, Netlify, Render, Bugsnag, Rollbar, Plausible or Umami are a mapping away (`blipkit new --template payments|deploys|errors|traffic` starts one). Calm lines carry no state in any of them: a list row’s dots are for what’s news or needs you, not for every item. ### Payments | Piece | What it does | | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **Money** | `fromMinor`, `toMinor`, `money(minor, currency)`: amounts stay in minor units until shown, with each currency’s own decimals (a yen has none, a Kuwaiti dinar three), and a service’s exceptions (Stripe sends krónur in hundredths) | | **The ledger** | `LedgerEntry` (a sale adds, a refund or dispute takes away, in the currency the account is paid in), `dayWindows(now)` (today so far, and yesterday up to the same time on the clock, daylight saving included), `revenueWindow` (net, or gross before refunds, hour by hour, and what’s in other currencies counted), `revenueBlip` (its line is the day’s running total, which climbs as sales come in; each hour on its own would dip to nothing in a quiet hour and read as a fall; “+$20 vs yesterday”) | | **Sales** | `Sale`, `saleItem` (what, a first name, never an address, and the amount in the value column), `saleAlerts` (a new customer’s payment peeks; a renewal every month doesn’t, unless you ask), `salesBlip` (“Sales today”: the row’s number is today’s count, 0 on a day without, and its line the newest sale, “Ravi · 3 × Acme Pro · 5m”, not the count again) | | **MRR** | `monthlyAmount` (a yearly plan is a twelfth, a weekly one 52 twelfths), `recurringRevenue` (past due counts, trials and canceled don’t), `recordDaily`/`valueAgo` (a daily series kept locally, so the change over 30 days needs no history from the service, honest about its window while young), `mrrBlip` | | **Disputes** | `Dispute`, `dueWords` (“respond in 6d”, “past the deadline”), `disputeItem` (needs you until answered, a failure once too late) | | **Payouts** | `Payout`, `payoutBlip` (“Next payout · Arrives Tuesday”, then “Payout · Arrived today” as a success for a day; one marked paid before its day is still on its way), `payoutFailedItem` | What’s one service’s own stays in its extension. For Blipbar’s Stripe extension, that’s Stripe’s read allowance, early fraud warnings, restricted-key permissions, and the events feed as the change check. For Paddle, it’s Paddle’s own event feed as the change check, the merchant of record’s revenue (before tax, before or after its fee), Paddle’s own MRR metric, and payouts and key expiry read from events. ### Deploys | Piece | What it does | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **The model** | `Deploy`: queued, building, ready, failed or canceled; production or a branch’s preview; its commit, its own address, where people see it, the host’s page with the log, why it failed, and staged (built for production, waiting for Promote) | | **Lines** | `deployLine`, `newestPerLine`: a newer deploy on the same line (production, or one branch’s previews) replaces an older one, so a failure stays until the next deploy there | | **Timing** | `usualSeconds` (the median of the recent ones that went live), `deployProgress` (time left while it’s on course, “Taking longer than usual (usually 1m)” past twice the usual and ten minutes, a queue held too long), live as news for ten minutes | | **Items and blips** | `deployItem` (“web · main · 2m left”, with the word Building, Live, Staged, Failed in the value column, which a live line doesn’t repeat: “web · production · 12m ago”), `deploysSummary` (“1 building · 1 failed”, and when all’s calm, “web went live 11m ago”), `deploysBlip` (failures first), `projectBlip` (a project’s production as Build then Production, what’s live meanwhile: “Still on a1b2c3d”) | | **Buttons** | `DEPLOY_ACTIONS` (Redeploy, Cancel and Roll back and Promote asking first, Keep watching), `visitAction`, `logsAction`, `copyURLAction` (a preview’s address, copied with no round trip) | A second host can differ and still fit. For Cloudflare, Pages stages map onto the same five statuses, a Worker’s deployment is live the moment it’s made, and a Pages roll back doesn’t stop the next deploy going live, so there the kit’s “staged” isn’t used and the live deploy offers Undo roll back instead. A watched Worker’s errors use the errors part (`errorRateBlip`). ### Errors | Piece | What it does | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **The model** | `ErrorIssue`: new, regressed (back after it was resolved), escalating (suddenly far more frequent) or ongoing; its priority, events and users, first and last seen, and whose it is | | **Buttons** | `ERROR_ACTIONS`: Resolve and Archive are the kit’s Done and Mute in the trackers’ words, so a press means the same thing everywhere; Snooze; Assign to me | | **What peeks** | `errorAlerts`: `important` (high priority, regressions, escalations), `new`, `mine`, `none` | | **Items and blips** | `errorItem` (the error, then where, who it touches and when, short enough to read whole: “submitOrder · 3 users · 5m”), `errorsSummary` (“2 new · 1 regressed”), `spike` (the last hour against the usual one, with a floor so a quiet project’s blip isn’t one), `errorRateBlip` (a project’s day hour by hour against the day before, “37 errors”, “+37 errors vs day before”: a number says what it counts; more is bad news here) | ### Traffic | Piece | What it does | | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **The model** | `TrafficDay` (visitors, pageviews, and people seen for the first time each hour since midnight, which summed are the day so far), `Breakdown` (a source or a page: its visitors today, its last hour, its usual hour) | | **Visitors** | `visitorsBlip`: today against yesterday up to the same time (“+66 vs yesterday”), the day’s running total as its line, who’s on the site now (“6 on the site now”), and the day’s pageviews, top source and top page as facts when it’s opened | | **A surge** | `surging`: a source’s last hour at least 25 visitors and four times its usual hour. A Hacker News post is one; Google going from 2 to 6 on a quiet site isn’t. The visitors blip’s line says it (“Hacker News · 64 in the last hour”) and peeks, once | | **Sources and pages** | `sourceName` and `mergeSources` (news.ycombinator.com is Hacker News, x.com and t.co are both X, no referrer is Direct), `breakdownItem` (“8% of visitors”, or a surge’s own line), `breakdownBlip` (the row names the top entry, “Hacker News · 90%”, and shows its visitors as the number, the list layout’s `value`: how many rows there are says nothing) | | **A goal** | `goalBlip`: an event worth counting on its own (signups, purchases) against yesterday by now | Blipbar’s PostHog extension is built on it: one HogQL query a look, within PostHog’s hourly read budget.