Docs / Recipes / How-to
Get a Slack message when an agent is blocked
The live board can send desktop notifications, but only while it's open. This script runs anywhere you keep a process alive (a spare terminal, a small server), follows the event stream, and posts to Slack through an incoming webhook when an agent blocks a task or completes one.
For the no-code way to get notified and answer from your phone, see answering a blocked coding agent.
Before you begin
- Bun 1.1 or later.
- A Slack incoming webhook URL: create a Slack app, turn on Incoming Webhooks, add one for the channel. It starts with https://hooks.slack.com/services/.
- A Taskhold API key.
The script
// taskhold-slack.ts: post to Slack when an agent blocks or finishes a task.
// Run: TASKHOLD_API_KEY=atd_sk_... SLACK_WEBHOOK_URL=https://hooks.slack.com/... bun taskhold-slack.ts
const base = process.env.TASKHOLD_API_URL ?? "https://taskhold.com/api";
const app = base.replace(/\/api$/, "");
const key = process.env.TASKHOLD_API_KEY!;
const hook = process.env.SLACK_WEBHOOK_URL!;
let lastId = "";
async function post(e: any) {
const blocked = e.data?.to === "blocked";
const head = blocked ? `${e.actor_name ?? "An agent"} needs you` : `${e.actor_name ?? "An agent"} finished a task`;
const detail = blocked ? e.data.blocked_reason : e.data.completed_summary;
await fetch(hook, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text: `${head}: ${e.task_title}`,
blocks: [
{ type: "section", text: { type: "mrkdwn", text: `*${head}*\n${e.task_title}${detail ? `\n>${detail}` : ""}` } },
{ type: "actions", elements: [{ type: "button", text: { type: "plain_text", text: blocked ? "Answer on the board" : "Open the board" }, url: `${app}/dashboard/live` }] },
],
}),
});
}
for (;;) {
try {
const res = await fetch(`${base}/events/stream`, {
headers: { Authorization: `Bearer ${key}`, ...(lastId ? { "Last-Event-ID": lastId } : {}) },
});
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
let buf = "";
for await (const chunk of res.body.pipeThrough(new TextDecoderStream())) {
buf += chunk;
let end;
while ((end = buf.indexOf("\n\n")) >= 0) {
const frame = buf.slice(0, end);
buf = buf.slice(end + 2);
const id = frame.match(/^id: (\d+)/m)?.[1];
const data = frame.match(/^data: (.*)$/m)?.[1];
if (id) lastId = id;
if (!data || !/^event: task$/m.test(frame)) continue;
const e = JSON.parse(data);
if (e.type !== "task.status" || e.actor !== "agent") continue;
const topLevel = e.task_id === e.root_task_id; // steps finish often; post only whole tasks
if (e.data?.to === "blocked" || (e.data?.to === "done" && topLevel)) await post(e);
}
}
} catch (err) {
console.error("stream dropped:", (err as Error).message);
}
await Bun.sleep(3000); // the server ends each stream after 10 minutes; reconnect from lastId
}It reconnects with the Last-Event-ID header, so events that happen while it reconnects are delivered once, in order. Change the filter line to post other events, for example every task.created from an agent.
What gets posted
One message per event: a bold line naming the agent ("Claude Code in api-server needs you"), the task title, the question or summary as a quote, and a button that opens the live board.
Read next
Updated 2026-10-26 · Markdown for agents · Questions: hello@greatwork.company