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.
Sign-in, run by the app
Section titled “Sign-in, run by the app”Declare the service in the manifest and never write a sign-in:
"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.
// 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
Section titled “Triage: Done, Snooze, Mute”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:
const { shown, book: done } = sortDone(entries, memory.done, now); // done here, until it changesconst { 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
Section titled “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:
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
Section titled “When there’s nothing to show”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
Section titled “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.
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 <name> --template tracker starts an integration with all of this in place.
Payments, deploys, errors, traffic
Section titled “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:
// 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 saleAlertsctx.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 Newsctx.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, and blipkit new <name> --template payments|deploys|errors|traffic starts one.