The event model

Event hooks let your plugin react without being asked. You declare [[events]] entries in the manifest; herdr launches the hook command each time a matching event fires, with the event name in HERDR_PLUGIN_EVENT and the full payload in HERDR_PLUGIN_EVENT_JSON.

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

The hookable events

Plugin manifests can hook 22 events (as of 0.9.3), grouped by object:

GroupEvents
workspace.* (7)created, updated, closed, renamed, moved, reordered, focused
worktree.* (3)created, opened, removed
tab.* (5)created, closed, focused, renamed, moved
pane.* (7)created, closed, focused, moved, exited, agent_detected, agent_status_changed

The socket API’s events.subscribe surface is larger (27): it adds workspace.metadata_updated, pane.updated, pane.output_matched, pane.scroll_changed, and layout.updated. The source keeps plugin hooks “intentionally narrower” until high-volume output-change hook semantics exist; these five stay socket-only.

Gotcha: An unknown event name in [[events]] is only an install-preview warning, not a rejection. The manifest installs, the hook never fires, and nothing else tells you why. Check your spelling against the table above; PLUGIN_HOOK_EVENT_KINDS in _repos/herdrdev-herdr/src/api/schema/events.rs is the authoritative list.

Payloads

Event hooks receive:

  • HERDR_PLUGIN_EVENT: the dot name, e.g. pane.agent_status_changed
  • HERDR_PLUGIN_EVENT_JSON: the serialized event envelope

The envelope has an event kind (snake_case string) and a data object (tagged "type" with the same name plus fields):

{
  "event": "pane_agent_status_changed",
  "data": {
    "type": "pane_agent_status_changed",
    "pane_id": "w1:p2",
    "agent_status": "done"
  }
}

Parse it defensively: fields vary per event and new fields appear over time. A serde_json::Value or per-event struct with #[serde(default)] keeps your plugin forward-compatible.

Delivery semantics

Three behaviors shape how you write handlers:

  1. Bursts. One user action fires several subscribed events at once (focus change plus status change plus pane update). Herdr starts one process per matching hook per event, so overlapping runs of your handler are normal, not exceptional.
  2. Slow readers lose events. Since 0.9.2, subscribers that cannot keep up receive events_lost instead of a growing backlog. Treat any single event as a hint, not a guarantee; reconcile against current state rather than replaying history.
  3. Subscriptions start live. Socket subscriptions deliver events from the moment they start. If you consume events programmatically, subscribe first, then snapshot state, then reconcile; snapshot-then-subscribe misses anything that changes in between.

The practical consequence: make every hook entry the same idempotent reconcile function. Read current state, compare with your state file, act only on real changes. The exemplar table in plugin archetypes points at the reference implementations.

Gotcha: Never subscribe to the verb you perform. A plugin that renames tabs or workspaces must not hook tab.renamed or workspace.renamed; your own renames would re-trigger you in an infinite loop. pane-topic-sync subscribes to focus, lifecycle, and status events only, and skips every *.renamed for exactly this reason.

Invocation context

Every hook (and every action, pane, and startup command) receives HERDR_PLUGIN_CONTEXT_JSON, a PluginInvocationContext describing where the invocation happened:

FieldContents
workspace_id, workspace_label, workspace_cwdWorkspace the context resolves to
worktreeWorktree info when available
tab_id, tab_labelTab the context resolves to
focused_pane_id, focused_pane_cwdFocused pane
focused_pane_agent, focused_pane_statusAgent in the focused pane and its status
selected_textAlways null from the API path
invocation_sourceWhat triggered it, e.g. keybinding, api, link_click
correlation_idId correlating this invocation
clicked_url, link_handler_idSet for link handler invocations

Each event type maps to the richest available context with graceful partial fallback (a closed tab still carries ids after its workspace is gone). Every field is optional; never assume presence.

Common ids are also duplicated as plain env vars so shell plugins avoid JSON parsing: HERDR_WORKSPACE_ID, HERDR_TAB_ID, HERDR_PANE_ID, plus HERDR_PLUGIN_CLICKED_URL and HERDR_PLUGIN_LINK_HANDLER_ID for link handlers.

Gotcha: selected_text is hard null from the API path. Selection is client presentation state, confirmed at source level in context.rs and tracked as issue #3380. A keybinding-triggered action cannot read what the user selected; design around it (prompt, config, or the focused pane’s content instead).