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.
- 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.
- 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. - 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
Section titled “1. The app: instant presses”A press shows at once
Section titled “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)
Section titled “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)
Section titled “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)
Section titled “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
Section titled “Declared in the manifest”"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,tokenUrlandrevokeUrlmust behttps, and the token and revoke hosts must be inpermissions.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
Section titled “The flow”- The extension calls
ctx.oauth.connect()(from its Connect button). The app makes a PKCE verifier and astate, 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. - 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. 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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
…Allcalls), 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.
Payments
Section titled “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
Section titled “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
Section titled “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
Section titled “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.