Skip to content

The SDK API

Everything below is exported from @blipbar/api.

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: <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’s actions. See Actions and replies.

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.

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

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.

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.