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_TOKEN from 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:

FieldWhy
agentWhich 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 + timestampWhat 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.