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
testaction that sends a canned message so users can verify credentials. - ntfy or Slack adapters.
- Deep links: resolve the repo name from
workspace_cwdand link to it.