Skip to content

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.

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.

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 changes
const { 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.

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

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.

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 saleAlerts
ctx.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 News
ctx.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 &lt;name> --template payments|deploys|errors|traffic starts one.