First Rust plugin

Build, link, and drive a minimal plugin with one event hook and one action. It logs agent status changes (visible in the plugin log) and reports workspaces on demand. Everything here runs as written on Linux or macOS with Rust and herdr installed.

Repo layout

One self-contained crate per plugin. The target/release/<bin> path inside the plugin directory is what the manifest points at, so keep the crate standalone (see the workspace gotcha below).

hello-agent/
  Cargo.toml
  herdr-plugin.toml
  src/
    main.rs

Cargo.toml

[package]
name = "hello-agent"
version = "0.1.0"
edition = "2021"

[dependencies]
anyhow = "1"
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

[profile.release]
strip = true

herdr-plugin.toml

id = "peteretelej.hello-agent"
name = "Hello Agent"
version = "0.1.0"
min_herdr_version = "0.9.0"
description = "Minimal event + action plugin: logs agent status changes, reports workspaces"
platforms = ["linux", "macos"]

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

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

[[actions]]
id = "sync"
title = "Report workspaces"
command = ["sh", "-c", "exec \"$HERDR_PLUGIN_ROOT/target/release/hello-agent\" sync"]

The sh -c exec "..." wrapper expands $HERDR_PLUGIN_ROOT and exec replaces the shell with your binary. Commands are argv arrays; herdr does no shell expansion itself.

src/main.rs

Two clap subcommands: run --event NAME is the hook entry, sync is the action entry. The hook parses HERDR_PLUGIN_EVENT_JSON; the action calls back through HERDR_BIN_PATH.

use anyhow::{bail, Context, Result};
use clap::{Parser, Subcommand};
use serde::Deserialize;

#[derive(Parser)]
#[command(name = "hello-agent", version, about = "Minimal herdr plugin")]
struct Cli {
    #[command(subcommand)]
    command: Command,
}

#[derive(Subcommand)]
enum Command {
    /// Entry point for [[events]] hooks
    Run {
        /// Event name, passed by the manifest as $HERDR_PLUGIN_EVENT
        #[arg(long)]
        event: String,
    },
    /// Entry point for [[actions]]
    Sync,
}

/// Shape of HERDR_PLUGIN_EVENT_JSON (EventEnvelope in herdr's schema).
#[derive(Deserialize)]
struct EventEnvelope {
    event: String,
    #[serde(default)]
    data: serde_json::Value,
}

fn main() -> Result<()> {
    match Cli::parse().command {
        Command::Run { event } => run(&event),
        Command::Sync => sync(),
    }
}

fn run(event_name: &str) -> Result<()> {
    let json = std::env::var("HERDR_PLUGIN_EVENT_JSON")
        .context("HERDR_PLUGIN_EVENT_JSON not set; is this an event hook invocation?")?;
    let envelope: EventEnvelope =
        serde_json::from_str(&json).context("parsing HERDR_PLUGIN_EVENT_JSON")?;
    let status = envelope
        .data
        .get("agent_status")
        .and_then(|v| v.as_str())
        .unwrap_or("?");
    let pane = envelope
        .data
        .get("pane_id")
        .and_then(|v| v.as_str())
        .unwrap_or("?");
    eprintln!("[{event_name}] pane {pane} status {status} ({} bytes)", json.len());
    Ok(())
}

fn sync() -> Result<()> {
    let herdr = std::env::var("HERDR_BIN_PATH").unwrap_or_else(|_| "herdr".to_string());
    let out = std::process::Command::new(&herdr)
        .args(["workspace", "list"])
        .output()
        .with_context(|| format!("spawning {herdr}"))?;
    if !out.status.success() {
        bail!(
            "herdr workspace list failed: {}",
            String::from_utf8_lossy(&out.stderr)
        );
    }
    print!("{}", String::from_utf8_lossy(&out.stdout));
    Ok(())
}

Output to stderr is fine for hooks: stdout and stderr both land in the plugin log, which is where you will watch it.

The dev loop

cd hello-agent
cargo build --release
herdr plugin link "$(pwd)"
herdr server reload-config

Then exercise both entries:

# Action entry:
herdr plugin action invoke peteretelej.hello-agent.sync

# Event entry: trigger a real event (start or prompt any agent in a pane),
# then read the log:
herdr plugin log list --plugin peteretelej.hello-agent

You can also drive the hook offline with a hand-built envelope, no herdr needed:

HERDR_PLUGIN_EVENT_JSON='{"event":"pane_agent_status_changed","data":{"type":"pane_agent_status_changed","pane_id":"w1:p2","agent_status":"done"}}' \
  ./target/release/hello-agent run --event pane.agent_status_changed

Iterate by editing and rebuilding; the link points at your working tree, so cargo build --release plus re-triggering is the whole loop. No reinstall, no re-link.

Gotcha: plugin link never runs [[build]]; that step exists only for GitHub installs. In development you are the build system: a stale target/release/hello-agent is the most common cause of “my change did nothing”.

Gotcha: If you move the crate into a cargo workspace, the binary lands in the workspace-level target/ directory, not $HERDR_PLUGIN_ROOT/target/release/, and every manifest command breaks. Keep quickstart-shaped plugins standalone, or set a per-plugin target-dir, or point the manifest at the shared path explicitly.

Install shape

When the plugin lives in a public GitHub repo with its manifest at the root or in a subdirectory:

herdr plugin install peteretelej/herdr-plugins/hello-agent

Install clones the repo, previews every command, runs [[build]] (cargo build --release, so cargo must be present; herdr does not install toolchains, so document that in your README), then registers the plugin. Use --yes for trusted sources in scripts and --ref to pin a revision.

Where to go next