Skip to content

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.