The SDK API
Everything below is exported from @blipbar/api.
defineExtension
Section titled “defineExtension”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’sinterval, on a manual run (from Settings), and after a preferences change (ctx.triggertells you which). Return{ nextRunAfter: <seconds> }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.onAction(ctx, action)runs when someone presses one of your blip’sactions. See Actions and replies.
Context (ctx)
Section titled “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). |
storage |
Storage |
get<T>(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). |
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). |
openURL(url) |
(string) => Promise<void> |
Opens a URL with the user’s default handler. |
notify(title, body?) |
(string, string?) => Promise<void> |
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<void> |
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): connect(), connections(), token(), describe(), invalidate(), disconnect(). |
workingHours() |
() => Promise<WorkingHours | undefined> |
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<ExecResult> |
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
Section titled “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.
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.
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.
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 insession/countdownbelow) take aDate, an ISO string, or an absolute epoch number, never “N seconds from now” for a bare number. OnlystaleAftergets that relative convenience.eta: 240does not mean “4 minutes from now”; it means 240 seconds after the Unix epoch, in 1970. Passnew Date(Date.now() + 240_000)instead.
session: long-running work with no known end, like an AI agent or a stream.
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).
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.
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.
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.
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.
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
Section titled “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.