A Herdr plugin that forwards agent notifications to the webhook service you already use: Slack, Discord, Microsoft Teams, Google Chat, Feishu, Lark, DingTalk, WeCom, Telegram, ntfy, or any custom HTTP endpoint.
中文说明 | Provider guides | Contributing
herdr plugin install zgxme/herdr-webhook-notify
herdr plugin action invoke herdr-webhook-notify.init
herdr plugin action invoke herdr-webhook-notify.test
Herdr already shows in-app toasts, but they only exist inside Herdr. This plugin pushes notifications to where you actually are: your phone, your team chat, or your own alerting gateway.
It also fixes the two things that make naive done-only hooks feel broken:
- Watched panes. Herdr reports a finished turn as
doneonly while the completion is unseen. When the pane sits in the tab you are looking at, the same event arrives asidle, so a hook that only matchesdonesilently drops it. This plugin keeps the previous status per pane and treatsworking|blocked -> idleas a completion too. - Missing context. Event payloads carry no task title. The plugin reads the pane through the Herdr CLI to fill in the task summary (the agent's terminal title), workspace, tab, directory and git branch.
- Herdr 0.9.0 or newer
- Python 3.9 or newer (
python3onPATH). No pip packages are needed: TOML parsing falls back to a vendored copy of tomli on Python 3.9 and 3.10.
herdr plugin install zgxme/herdr-webhook-notify
herdr plugin config-dir herdr-webhook-notify # prints the config directory
herdr plugin action invoke herdr-webhook-notify.initinit writes a commented config.toml filled with every supported provider.
Enable one, add your secret, and test it:
# config.toml
[providers.slack]
enabled = true
webhook_url = "${SLACK_WEBHOOK}"
# .env in the same directory
SLACK_WEBHOOK=https://hooks.slack.com/services/...herdr plugin action invoke herdr-webhook-notify.test
herdr plugin action invoke herdr-webhook-notify.statusFor local development:
git clone https://github.com/zgxme/herdr-webhook-notify
herdr plugin link ./herdr-webhook-notify| provider | setup | notes |
|---|---|---|
feishu |
custom bot webhook, optional signing secret | interactive card by default, format = "text" available |
lark |
same custom bot protocol as Feishu, hosted on open.larksuite.com |
interactive card, optional signing secret |
dingtalk |
custom robot webhook, optional secret for signing |
markdown message |
wecom |
group robot key or full webhook URL |
markdown message |
slack |
incoming webhook URL | Block Kit, mrkdwn |
discord |
channel webhook URL | embed, optional username, content mention |
teams |
incoming webhook URL | MessageCard |
google_chat |
space webhook URL | text message |
telegram |
bot_token + chat_id |
optional parse_mode = "HTML" or "MarkdownV2" |
ntfy |
topic, optional self-hosted url and token |
priority mapped from event kind |
generic |
any url |
full control over method, headers and body |
Per-provider details, including how to obtain each webhook, live in docs/providers.md.
| Situation | Event status | Kind | Default |
|---|---|---|---|
| Turn finished while you were elsewhere | done |
done |
notify |
| Turn finished while you were watching that pane | idle |
done |
notify |
| Agent waits for approval or an answer | blocked |
blocked |
notify |
| Herdr loses track of the agent mid-turn | unknown |
unknown |
notify |
| Agent process exits, or its pane is closed | pane.exited, pane.closed |
exited |
notify |
| Agent starts working | working |
- | ignored |
| Agent becomes idle at startup | idle |
- | ignored |
Herdr has no explicit "failed" state. unknown and exited are the closest
signals it exposes, and both are reported by default.
The payload each hook receives, and how the plugin turns it into a notification, is documented in docs/events.md.
The config file lives in the plugin config directory printed by
herdr plugin config-dir herdr-webhook-notify. Secrets can stay in a .env
file next to it and be referenced as ${NAME}; a variable that is already set
in the environment wins, so CI and secrets managers keep working.
The plugin looks for config.toml in this order:
HERDR_WEBHOOK_NOTIFY_CONFIG— path to an explicit file.HERDR_PLUGIN_CONFIG_DIR+/config.toml— the plugin config directory.config.tomlin the plugin directory itself.
.env is read from HERDR_PLUGIN_CONFIG_DIR first, then from the plugin
directory. HERDR_PLUGIN_STATE_DIR moves state.json; Herdr points it at
~/.local/state/herdr/plugins/<plugin-id>, which is where the cooldowns, turn
timers and the retry queue actually live when the plugin runs as a hook.
HERDR_BIN_PATH tells the plugin which Herdr binary to call for pane,
workspace and tab labels.
Two list semantics are easy to get wrong:
notify.events = []does not mean "notify nothing". An empty list falls back to all four kinds; remove the kinds you do not want instead.providers.<name>.events = []means "no per-provider filter" for the same reason, not "never notify".
language = "en" # en | zh-CN
[notify]
events = ["done", "blocked", "unknown", "exited"]
notify_when_focused = true # false = only background panes
min_turn_seconds = 0 # skip short turns
cooldown_seconds = 5 # de-duplicate per pane and kind
blocked_delay_seconds = 10 # wait out a self-approving agent
quiet_hours = ["22:00-08:00"] # local time, supports overnight ranges
quiet_hours_exempt = ["blocked"] # kinds that ignore quiet hours
include_workspaces = [] # * wildcards, case-insensitive
exclude_workspaces = []
include_tabs = []
exclude_tabs = []
include_agents = []
exclude_agents = []
[http]
timeout_seconds = 5
retries = 1 # retried on 408/425/429/5xx
[message]
title = "Herdr {status_label}: {task}"
body = """
**Status**: {status_label}
**Workspace**: {workspace}
**Task**: {task}
"""
[messages]
"status.done" = "All done"
[providers.slack]
enabled = true
webhook_url = "${SLACK_WEBHOOK}"
events = ["blocked"] # optional per-provider override
language = "en" # optional per-provider language| key | type | default | meaning |
|---|---|---|---|
language |
string | "en" |
Language of the built-in text: en or zh-CN. Can be overridden per provider. Any unknown value is a startup error, so typos fail loudly. |
| key | type | default | meaning |
|---|---|---|---|
events |
list | ["done", "blocked", "unknown", "exited"] |
Kinds allowed to notify. done covers both background completions and completions that happened while you watched the pane. |
notify_when_focused |
bool | true |
true also notifies for the pane you are looking at; false mirrors Herdr and stays quiet for it. |
min_turn_seconds |
number | 0 |
Ignore turns shorter than this many seconds. 0 disables the check. Applies to done, blocked and unknown. |
cooldown_seconds |
number | 5 |
Suppress another notification for the same pane and kind inside this window, which collapses repeated done events for one pane into one message. Kinds are counted separately, so a completion plus a pane exit (done + exited) are still two messages. 0 disables. |
blocked_delay_seconds |
number | 10 |
Wait this long before reporting a blocked pane, then re-check it: if the agent approved its own prompt and moved on, nothing is sent. 0 notifies immediately. |
quiet_hours |
list of "HH:MM-HH:MM" |
[] |
Local-time silent window; overnight ranges like 22:00-08:00 work. |
quiet_hours_exempt |
list | ["blocked"] |
Kinds that ignore quiet_hours, so approval requests still reach you at night. |
include_workspaces |
list of globs | [] |
Only notify for these workspace labels, e.g. ["external-*"]. Empty means all. |
exclude_workspaces |
list of globs | [] |
Never notify for these workspace labels. Exclude wins over include. |
include_tabs / exclude_tabs |
list of globs | [] |
Same rules, matched against the tab label or number. |
include_agents / exclude_agents |
list of globs | [] |
Same rules, matched against the agent kind (codex, claude, gemini, ...). |
Globs are case-insensitive and use */? wildcards.
| key | type | default | meaning |
|---|---|---|---|
timeout_seconds |
number | 5 |
Per-request timeout, minimum 0.1. |
retries |
integer | 1 |
Extra attempts on network errors and 408/425/429/5xx. 0 means a single attempt. |
| key | type | default | meaning |
|---|---|---|---|
message.title |
string | localized | Template for the notification title. |
message.body |
string | localized | Template for the message body; multi-line TOML strings are fine. |
messages."status.done" |
string | built-in | Replace one built-in string. Available keys: status.done, status.blocked, status.unknown, status.exited, status.test, title, body. Quote the key in TOML because of the dot. |
Every provider accepts these three keys:
| key | type | default | meaning |
|---|---|---|---|
enabled |
bool | false |
Turn this provider on. A disabled provider is not validated. |
events |
list | inherit | Optional per-provider subset, e.g. ["blocked"] to only get approvals in Slack. |
language |
string | inherit | Optional per-provider language, useful when your team chats mix languages. |
timeout_seconds |
number | inherit [http] |
Optional per-provider timeout, for a slow gateway. |
retries |
integer | inherit [http] |
Optional per-provider retry count. |
| provider | required | optional |
|---|---|---|
feishu |
webhook_url |
secret (signature), format (card/text, default card), card_title_prefix |
lark |
webhook_url |
same as feishu |
dingtalk |
webhook_url |
secret (signature) |
wecom |
key or webhook_url |
base_url |
slack |
webhook_url |
- |
discord |
webhook_url |
username, avatar_url, content (for mentions) |
teams |
webhook_url |
- |
google_chat |
webhook_url |
thread_key |
telegram |
bot_token, chat_id |
parse_mode (HTML/MarkdownV2), message_thread_id, disable_notification, api_base |
ntfy |
topic |
url, token, priority, tags, click |
generic |
url |
method (POST/PUT/PATCH), headers, body (template), content_type, flavor (markdown/slack/plain) |
How to obtain each webhook is documented in docs/providers.md.
Use these in message.title, message.body and in generic's body template:
| placeholder | meaning |
|---|---|
{status} |
Raw Herdr status of the event: done, idle, blocked, unknown or exited. A completion you watched arrives as idle. |
{status_label} |
Localized label of the notification kind, e.g. Task finished. |
{kind} |
Normalized kind: done, blocked, unknown, exited or test. |
{session} |
Herdr session name, default for the default session. |
{workspace} |
Workspace label, e.g. external-fuzzer; falls back to the workspace id. |
{workspace_id} |
Workspace id, e.g. wD. |
{tab} |
Tab label or number, e.g. 2. |
{tab_id} |
Tab id, e.g. wD:t2. |
{task} |
Task summary: the pane terminal title, which agents set to their conversation title. Falls back to the pane id. |
{agent} |
Detected agent kind, e.g. codex, claude, gemini. |
{pane_id} |
Pane id, e.g. wD:p1. |
{cwd} |
Working directory of the pane. |
{branch} |
Current git branch of {cwd}; empty outside a repository. |
{repo} |
Repository name of the workspace worktree; empty outside a worktree workspace. |
{worktree} |
Checkout path of the workspace worktree; empty outside a worktree workspace. |
{duration} |
Turn duration, human formatted, e.g. 2m05s. Measured from the moment the pane entered working, so time spent waiting for approval is not counted. Empty when unknown. |
{duration_seconds} |
The same duration as a raw number of seconds, for custom formatting. |
{host} |
Hostname of the machine running Herdr. |
{time} |
Local timestamp, YYYY-MM-DD HH:MM:SS. |
Unknown placeholders are kept as-is, so typos are visible instead of silently
disappearing. Lines whose only value resolves to empty are dropped, which is
why optional fields such as {branch} do not leave a dangling label. The same
emptiness makes min_turn_seconds skip its check, because there is no duration
to compare.
English is the default. Set language = "zh-CN" globally or per provider, or
override individual strings with the [messages] table. Adding a locale is a
small pull request: copy a block in herdr_webhook_notify/i18n.py.
| action | what it does |
|---|---|
herdr-webhook-notify.init |
write the commented starter config.toml |
herdr-webhook-notify.test |
send a sample notification to every enabled provider; python3 run.py test [provider] [kind] narrows it down |
herdr-webhook-notify.preview |
print the request each provider would send, without sending it |
herdr-webhook-notify.status |
show config path, providers, filters and queue |
herdr-webhook-notify.mute / .resume |
silence or resume without editing config |
Bind one to a key if you like:
[[keys.command]]
key = "prefix+n"
type = "plugin_action"
command = "herdr-webhook-notify.test"
description = "test webhook notify"- Hook runs never block Herdr: short timeout, exit code 0, errors on stderr.
- Requests are retried on 408/425/429/5xx with a small backoff.
- Notifications that still fail are stored in the plugin state directory and retried on the next event, up to three attempts.
- Token-ish parts of webhook URLs are redacted in logs and in
statusoutput.
Inspect what actually ran:
herdr plugin log list --plugin herdr-webhook-notifytest reports "No provider is enabled" — run init, then set enabled = true.
A webhook is never hit — run status, it prints unresolved ${...} variables
as warnings, and herdr plugin log list shows the hook's stderr.
A setting seems ignored — look for unknown config key in status output or in
the hook log. Keys the plugin does not know are reported there with a suggested
spelling instead of being silently ignored.
Nothing is sent while you watch a pane — check notify_when_focused; with
false the plugin deliberately mirrors Herdr and stays quiet for the pane you
are looking at.
Everything is sent twice — Herdr emits both a status change and a process exit
when a pane disappears, and those are two different kinds (done or unknown,
plus exited). cooldown_seconds only suppresses repeats of the same kind, so
it cannot merge the pair. Remove exited from notify.events (or from a
provider's events list) if you only care about completions.
python3 -m pytest -q # 70 tests, no network access required
python3 -m ruff check .See CONTRIBUTING.md for adding a provider.
MIT. The vendored tomli copy keeps its own MIT license in
vendor/tomli/LICENSE.