Webhooks and held responses
Set triggers.webhook: true and your extension receives every
POST /v1/hooks/<your-id> as an onEvent call with event.type === "webhook".
The distinctive move, and how “approve from the notch” answers a blocking hook
synchronously, is ?await=<seconds> on that request (up to 600s): the HTTP
response is held open until your onAction calls ctx.respond(event.replyId, …), or
the wait runs out (then a bare 204). Whoever’s on the other end of that webhook
(an agent’s permission hook, for Blipbar’s own Claude Code extension) is blocked the whole
time, waiting for your answer.
async onEvent(ctx, event) { if (event.type !== "webhook") return; const body = event.body as { sessionId?: string; message?: string }; if (!body.sessionId) { ctx.log.warn("webhook payload had no sessionId, ignoring"); return; }
ctx.emit( session({ key: body.sessionId, title: body.sessionId, subtitle: body.message ?? "Needs your input", state: "attention", startedAt: new Date(), actions: [ { id: "approve", label: "Approve", role: "primary" }, { id: "deny", label: "Deny", role: "destructive" }, ], }), );
// No replyId means the caller didn't pass ?await=, so there's nothing to hold open. if (event.replyId) { await ctx.storage.set(`pending:${body.sessionId}`, event.replyId); }},
async onAction(ctx, action) { if (action.actionId !== "approve" && action.actionId !== "deny") return; const replyId = await ctx.storage.get<string>(`pending:${action.key}`); if (!replyId) return; // already answered, or the wait already timed out ctx.respond(replyId, { decision: action.actionId === "approve" ? "allow" : "deny" }); await ctx.storage.delete(`pending:${action.key}`);},Storage is the right place to stash a replyId between the two calls: onEvent
and the onAction that eventually answers it are separate invocations, possibly
seconds or minutes apart, with no shared in-memory state between them.
Buttons must not outlive the wait. Nothing tells your extension when a held request
times out, so store when it arrived too, and once the ?await= window has passed,
re-emit the blip without Approve/Deny and say where to answer instead (the caller
has usually fallen back to its own prompt). Return { nextRunAfter } from update()
to wake right when that happens; Blipbar’s Claude Code extension does exactly this. And
when the caller moves on some other way (the tool ran, a new prompt came in), drop the
question then.
Watched files work the same way, without the reply mechanics:
async onEvent(ctx, event) { if (event.type === "file") { ctx.log.info(`watched paths changed: ${event.paths.join(", ")}`); // … re-read whatever changed and ctx.emit() the update. }},You can also push a blip from outside the runtime entirely, with no extension and no
manifest, straight to the app’s local HTTP server (POST /v1/blips, authorized with the
bearer token in ~/Library/Application Support/Blipbar/webhook.json). See “Webhooks” in
the runtime protocol for that path (a cron script, a git hook, a build on this Mac). The server only listens on this Mac, so a CI job elsewhere can’t reach it. It’s how
blipkit send (below) works too, with an extension’s update() output as the body
instead of a hand-written JSON file.