Walkthrough: Build a Notify Plugin

Build peteretelej.notify: watch pane.agent_status_changed, send a useful message to Telegram (and any webhook) when an agent turns blocked or done. It wires together the patterns from notify-external-services.md into a runnable skeleton.

The pipeline

  • Everything hangs off one event; the filter and debounce do the selection work.
  • Sinks are pluggable; config decides which workspaces route where.

Project layout

herdr-plugins/
├── Cargo.toml                      # workspace root (members = ["plugins/*"])
└── plugins/notify/
    ├── herdr-plugin.toml
    ├── Cargo.toml
    └── src/main.rs

The manifest

# plugins/notify/herdr-plugin.toml
id = "peteretelej.notify"
name = "Notify"
version = "0.1.0"
min_herdr_version = "0.7.0"
description = "Telegram and webhook notifications when agents turn blocked or done."
platforms = ["macos", "linux"]

[[build]]
command = ["cargo", "build", "--release"]

[[events]]
on = "pane.agent_status_changed"
command = ["sh", "-c", "exec \"$HERDR_PLUGIN_ROOT/target/release/notify\" event"]

One event, one entrypoint. There is nothing else for herdr to invoke.

Cargo

# plugins/notify/Cargo.toml
[package]
name = "notify"
version = "0.1.0"
edition = "2021"

[dependencies]
anyhow = "1"
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
ureq = { version = "2", features = ["json"] }

Event and context payloads

HERDR_PLUGIN_EVENT_JSON carries the full event; HERDR_PLUGIN_CONTEXT_JSON carries the invocation context. Both are read defensively with #[serde(default)]:

use serde::Deserialize;
use std::collections::HashMap;

#[derive(Deserialize)]
struct EventJson {
    #[serde(default, rename = "type")]
    kind: String,
    #[serde(default)]
    data: EventData,
}

#[derive(Deserialize, Default)]
struct EventData {
    pane_id: String,
    workspace_id: String,
    agent_status: String,
    #[serde(default)]
    agent: Option<String>,
    #[serde(default)]
    title: Option<String>,
    #[serde(default)]
    display_agent: Option<String>,
    #[serde(default)]
    state_labels: HashMap<String, String>,
}

#[derive(Deserialize, Default)]
struct ContextJson {
    #[serde(default)]
    workspace_label: Option<String>,
    #[serde(default)]
    workspace_cwd: Option<String>,
    #[serde(default)]
    tab_label: Option<String>,
    #[serde(default)]
    focused_pane_status: Option<String>,
}

Status filtering uses the event payload first, falling back to context (the official example does the same dance):

fn status_of(event: &EventJson, ctx: &ContextJson) -> Option<String> {
    let direct = (!event.data.agent_status.is_empty())
        .then(|| event.data.agent_status.clone());
    direct.or_else(|| ctx.focused_pane_status.clone())
}

Config

# $HERDR_PLUGIN_CONFIG_DIR/notify.toml
debounce_secs = 30
notify = ["blocked", "done"]

[telegram]
bot_token = "123456:ABC-DEF..."
chat_id = "42"

[webhook]
url = "https://hooks.example.invalid/herdr"
secret_header = "X-Webhook-Secret"
secret = "s3cret"
#[derive(Deserialize)]
struct Config {
    #[serde(default = "default_statuses")]
    notify: Vec<String>,
    #[serde(default = "default_debounce")]
    debounce_secs: u64,
    telegram: Option<TelegramCfg>,
    webhook: Option<WebhookCfg>,
}

fn default_statuses() -> Vec<String> { vec!["blocked".into(), "done".into()] }
fn default_debounce() -> u64 { 30 }

#[derive(Deserialize)]
struct TelegramCfg { bot_token: String, chat_id: String }

#[derive(Deserialize)]
struct WebhookCfg {
    url: String,
    #[serde(default)]
    secret_header: Option<String>,
    #[serde(default)]
    secret: Option<String>,
}

fn load_config() -> Result<Config> {
    let dir = std::env::var("HERDR_PLUGIN_CONFIG_DIR")
        .context("HERDR_PLUGIN_CONFIG_DIR not set; run via herdr")?;
    let raw = std::fs::read_to_string(std::path::Path::new(&dir).join("notify.toml"))
        .unwrap_or_default();
    Ok(toml::from_str(&raw)?)
}

Find the dir with herdr plugin config-dir peteretelej.notify.

Message and Sink

struct Message {
    agent: String,
    workspace: String,
    tab: String,
    topic: String,
    cwd: String,
    status: String,
}

impl Message {
    fn render(&self) -> String {
        let icon = match self.status.as_str() {
            "blocked" => "⛔",
            "done" => "✅",
            _ => "•",
        };
        format!(
            "{icon} {agent} is {status}\n{ws} / {tab}: {topic}\n{cwd}",
            agent = self.agent, status = self.status,
            ws = self.workspace, tab = self.tab,
            topic = self.topic, cwd = self.cwd,
        )
    }

    fn to_json(&self) -> serde_json::Value {
        serde_json::json!({
            "agent": self.agent, "workspace": self.workspace, "tab": self.tab,
            "topic": self.topic, "cwd": self.cwd, "status": self.status,
        })
    }
}

trait Sink {
    fn name(&self) -> &'static str;
    fn send(&self, msg: &Message) -> anyhow::Result<()>;
}

Adapters

struct TelegramBot { cfg: TelegramCfg }

impl Sink for TelegramBot {
    fn name(&self) -> &'static str { "telegram" }

    fn send(&self, msg: &Message) -> anyhow::Result<()> {
        let url = format!("https://api.telegram.org/bot{}/sendMessage", self.cfg.bot_token);
        let resp = ureq::post(&url).send_json(serde_json::json!({
            "chat_id": self.cfg.chat_id,
            "text": msg.render(),
            "disable_web_page_preview": true,
        }))?;
        if resp.status() != 200 {
            anyhow::bail!("telegram returned {}", resp.status());
        }
        Ok(())
    }
}

struct Webhook { cfg: WebhookCfg }

impl Sink for Webhook {
    fn name(&self) -> &'static str { "webhook" }

    fn send(&self, msg: &Message) -> anyhow::Result<()> {
        let mut req = ureq::post(&self.cfg.url);
        if let (Some(header), Some(secret)) = (&self.cfg.secret_header, &self.cfg.secret) {
            req = req.set(header, secret);
        }
        req.send_json(msg.to_json())?;
        Ok(())
    }
}

Slack or ntfy is now one more impl Sink.

Debounce

A small state file keyed by pane_id:status keeps flapping agents quiet:

use std::collections::BTreeMap;
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};

#[derive(Default, serde::Serialize, serde::Deserialize)]
struct DebounceState {
    last_sent: BTreeMap<String, u64>, // "pane:status" -> unix ms
}

fn state_path() -> Result<PathBuf> {
    let dir = std::env::var("HERDR_PLUGIN_STATE_DIR")
        .context("HERDR_PLUGIN_STATE_DIR not set; run via herdr")?;
    Ok(PathBuf::from(dir).join("notify-state.json"))
}

fn should_send(state: &mut DebounceState, key: &str, window_ms: u64) -> bool {
    let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() as u64;
    if let Some(last) = state.last_sent.get(key) {
        if now.saturating_sub(*last) < window_ms {
            return false;
        }
    }
    state.last_sent.insert(key.to_string(), now);
    true
}

fn persist(state: &DebounceState) {
    if let Ok(path) = state_path() {
        if let Some(parent) = path.parent() {
            let _ = fs::create_dir_all(parent);
        }
        if let Ok(raw) = serde_json::to_vec(state) {
            let tmp = path.with_extension("json.tmp");
            if fs::write(&tmp, &raw).is_ok() {
                let _ = fs::rename(&tmp, &path);
            }
        }
    }
}

Main: silent failure end to end

use anyhow::{Context as _, Result};
use clap::{Parser, Subcommand};

#[derive(Parser)]
#[command(name = "notify", about = "Agent notifications to Telegram/webhook")]
struct Cli {
    #[command(subcommand)]
    cmd: Cmd,
}

#[derive(Subcommand)]
enum Cmd {
    /// Manifest event entrypoint for pane.agent_status_changed.
    Event,
}

fn main() {
    let cli = Cli::parse();
    if let Err(err) = match cli.cmd {
        Cmd::Event => on_event(),
    } {
        // The plugin log is the only surface; never fail loudly from an event.
        eprintln!("notify: {err:#}");
    }
}

fn on_event() -> Result<()> {
    let event: EventJson = serde_json::from_str(
        &std::env::var("HERDR_PLUGIN_EVENT_JSON").unwrap_or_default(),
    )
    .unwrap_or_default();
    let ctx: ContextJson = serde_json::from_str(
        &std::env::var("HERDR_PLUGIN_CONTEXT_JSON").unwrap_or_default(),
    )
    .unwrap_or_default();

    let cfg = load_config().context("config")?;
    let Some(status) = status_of(&event, &ctx) else { return Ok(()) };
    if !cfg.notify.contains(&status) {
        return Ok(()); // working/idle: not our business
    }

    let mut state: DebounceState = std::fs::read(state_path()?)
        .ok()
        .and_then(|raw| serde_json::from_slice(&raw).ok())
        .unwrap_or_default();
    let key = format!("{}:{status}", event.data.pane_id);
    if !should_send(&mut state, &key, cfg.debounce_secs * 1000) {
        return Ok(());
    }
    persist(&state);

    // Enrich from the CLI when context is thin; TODO: pane get for
    // terminal_title_stripped when `title` is missing.
    let topic = event.data.title.clone().unwrap_or_default();
    let msg = Message {
        agent: event.data.display_agent.clone()
            .or(event.data.agent.clone())
            .unwrap_or_else(|| "agent".into()),
        workspace: ctx.workspace_label.clone().unwrap_or_else(|| event.data.workspace_id.clone()),
        tab: ctx.tab_label.clone().unwrap_or_default(),
        topic,
        cwd: ctx.workspace_cwd.clone().unwrap_or_default(),
        status,
    };

    let mut sinks: Vec<Box<dyn Sink>> = Vec::new();
    if let Some(tg) = &cfg.telegram {
        sinks.push(Box::new(TelegramBot { cfg: tg.clone() }));
    }
    if let Some(wh) = &cfg.webhook {
        sinks.push(Box::new(Webhook { cfg: wh.clone() }));
    }

    for sink in &sinks {
        if let Err(err) = sink.send(&msg) {
            eprintln!("[{}] delivery failed: {err:#}", sink.name());
        }
    }
    Ok(())
}

Deriving Clone on the config structs (#[derive(Deserialize, Clone)]) is needed for the tg.clone()/wh.clone() calls.

Configure and run

cargo build --release -p notify
herdr plugin link plugins/notify
herdr plugin config-dir peteretelej.notify   # then write notify.toml there

# dry-run the full path with a synthetic event
HERDR_PLUGIN_EVENT_JSON='{"type":"pane.agent_status_changed","data":{"pane_id":"p1","workspace_id":"w1","agent_status":"done","agent":"claude"}}' \
HERDR_PLUGIN_CONTEXT_JSON='{"workspace_label":"demo","tab_label":"main","workspace_cwd":"/tmp/demo"}' \
target/release/notify event

# then verify through herdr itself
herdr plugin log list --plugin peteretelej.notify

Gotcha: running the binary by hand with those env vars uses your shell’s environment. That is fine for testing, but it is also why env-var secrets are unreliable in production: herdr’s own invocation environment is not your shell. Keep secrets in the config dir.

Extensions

  • Per-workspace routing (see notify-external-services.md).
  • A test action that sends a canned message so users can verify credentials.
  • ntfy or Slack adapters.
  • Deep links: resolve the repo name from workspace_cwd and link to it.