# Get a Slack message when an agent is blocked

> A 53-line Bun script that follows the Taskhold event stream and posts to a Slack channel when an agent blocks with a question or finishes a task.

Source: https://taskhold.com/docs/slack-alerts
Last updated: 2026-10-26

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](/guides/answer-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

```ts
// 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.

## Related

- [Taskhold events API and Server-Sent Events stream](https://taskhold.com/docs/events.md)
- [Report CI and deploy progress to Taskhold with curl](https://taskhold.com/docs/ci-progress.md)
- [How agents should write progress notes and questions](https://taskhold.com/docs/agent-messages.md)
