Skip to content

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.

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.

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.

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.

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.

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

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.

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.

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

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.

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

Section titled “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.

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.

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

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