Notify External Services
The most common plugin archetype: watch agent state, push a message to Telegram, a webhook, ntfy, or Slack. This page shows the shape that scales past a first version: event selection with debounce, a Sink trait so channels are adapters, secrets in the config dir, and messages that actually tell you what happened.
Event selection and debounce
Subscribe to pane.agent_status_changed and filter by status. Notify on the states that need a human: blocked and done. Everything else is noise.
[[events]]
on = "pane.agent_status_changed"
command = ["sh", "-c", "exec \"$HERDR_PLUGIN_ROOT/target/release/notify\" event"]
Debounce in-process: agents can flap (working → blocked → working within seconds), and a burst of events can each spawn a handler. Keep a small state file of last-notified timestamps and skip repeats inside the window:
const DEBOUNCE: std::time::Duration = std::time::Duration::from_secs(30);
fn should_notify(state: &mut NotifyState, pane_id: &str, status: &str, now_ms: u64) -> bool {
if !matches!(status, "blocked" | "done") {
return false;
}
let key = format!("{pane_id}:{status}");
if let Some(last) = state.last_sent.get(&key) {
if now_ms.saturating_sub(*last) < DEBOUNCE.as_millis() as u64 {
return false;
}
}
state.last_sent.insert(key, now_ms);
true
}
The Sink trait
Define the delivery port first, then add channels as adapters:
use anyhow::Result;
pub struct Message {
pub agent: String,
pub workspace: String,
pub tab: String,
pub topic: String, // terminal_title_stripped
pub cwd: String,
pub status: String, // blocked | done
pub timestamp: String,
}
impl Message {
pub fn render(&self) -> String {
format!(
"{} [{}] {} is {}\n{} ({})\n{}",
icon(&self.status), self.workspace, self.agent, self.status,
self.topic, self.tab, self.cwd,
)
}
}
pub trait Sink: Send {
fn send(&self, msg: &Message) -> Result<()>;
fn name(&self) -> &'static str;
}
Slack, email, or ntfy become one adapter each. Routing config picks which sinks get which workspaces:
# $HERDR_PLUGIN_CONFIG_DIR/notify.toml
default_sinks = ["telegram"]
[[routing]]
workspace = "side-project"
sinks = ["webhook"] # work notifications go to the team channel only
[[routing]]
workspace = "personal"
sinks = ["telegram"]
fn sinks_for<'a>(cfg: &Config, workspace: &str, all: &'a [Box<dyn Sink>]) -> Vec<&'a dyn Sink> {
let names = cfg.routing.iter()
.find(|r| r.workspace == workspace)
.map(|r| r.sinks.clone())
.unwrap_or_else(|| cfg.default_sinks.clone());
all.iter().filter(|s| names.contains(&s.name().to_string()))
.map(|s| s.as_ref())
.collect()
}
Telegram adapter
The bot API is one HTTPS call. ureq with the json feature keeps the dependency small (blocking is fine; handler processes are short-lived):
pub struct TelegramBot { token: String, chat_id: String }
impl Sink for TelegramBot {
fn name(&self) -> &'static str { "telegram" }
fn send(&self, msg: &Message) -> Result<()> {
let resp = ureq::post(&format!(
"https://api.telegram.org/bot{}/sendMessage", self.token,
))
.send_json(ureq::json!({
"chat_id": self.chat_id,
"text": msg.render(),
"parse_mode": "HTML",
"disable_web_page_preview": true,
}))?;
if resp.status() != 200 {
anyhow::bail!("telegram returned {}", resp.status());
}
Ok(())
}
}
Webhook adapter
pub struct Webhook { url: String, secret_header: Option<(String, String)> }
impl Sink for Webhook {
fn name(&self) -> &'static str { "webhook" }
fn send(&self, msg: &Message) -> Result<()> {
let mut req = ureq::post(&self.url)
.set("content-type", "application/json");
if let Some((k, v)) = &self.secret_header {
req = req.set(k, v);
}
req.send_json(serde_json::to_value(msg)?)?;
Ok(())
}
}
Sending the structured Message as JSON (not just rendered text) lets the receiver format and route it.
Secrets in the config dir
Herdr gives every plugin a private config directory:
herdr plugin config-dir peteretelej.notify
Put secrets in a .env or the TOML config there. Never in the manifest (it is public in your repo), never in HERDR_PLUGIN_ROOT (that is the GitHub-managed checkout; reinstalling wipes it), and never hardcoded:
# $HERDR_PLUGIN_CONFIG_DIR/notify.toml
[telegram]
bot_token = "123456:ABC..."
chat_id = "42..."
#[derive(serde::Deserialize)]
struct FileConfig {
telegram: Option<TelegramCfg>,
webhook: Option<WebhookCfg>,
}
fn load_config() -> Result<FileConfig> {
let dir = std::env::var("HERDR_PLUGIN_CONFIG_DIR")?;
let raw = std::fs::read_to_string(std::path::Path::new(&dir).join("notify.toml"))
.unwrap_or_default();
Ok(toml::from_str(&raw)?)
}
Gotcha: the official agent-telegram-notify example reads
TELEGRAM_BOT_TOKENfrom the process environment. Event hooks inherit herdr’s environment, not your shell’s, so env-based secrets work only if you export them in the session that started herdr. The config dir is the reliable location.
Message quality bar
The existing webhook/ntfy plugins mostly send “agent done”. That is not actionable when you have four workspaces. Include enough to triage from your phone:
| Field | Why |
|---|---|
| agent | Which of the agents |
| workspace (+ tab) | Which project |
topic (terminal_title_stripped) | What it was working on |
| cwd (or repo name) | Where, with a path you can open |
| status + timestamp | What happened, when |
Deep links beat raw paths where the channel supports them.
Fail silently
Delivery failures must never propagate. Herdr captures your stderr into the plugin log (see debug-plugins.md); that is the right place for them:
for sink in sinks {
if let Err(err) = sink.send(&msg) {
eprintln!("[{}] delivery failed: {err:#}", sink.name());
}
}
The handler still exits 0, herdr is never blocked on your network timeout, and you debug from herdr plugin log list --plugin peteretelej.notify.