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:
| Group | Events |
|---|---|
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_KINDSin_repos/herdrdev-herdr/src/api/schema/events.rsis the authoritative list.
Payloads
Event hooks receive:
HERDR_PLUGIN_EVENT: the dot name, e.g.pane.agent_status_changedHERDR_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:
- 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.
- Slow readers lose events. Since 0.9.2, subscribers that cannot keep up receive
events_lostinstead of a growing backlog. Treat any single event as a hint, not a guarantee; reconcile against current state rather than replaying history. - 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.renamedorworkspace.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*.renamedfor 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:
| Field | Contents |
|---|---|
workspace_id, workspace_label, workspace_cwd | Workspace the context resolves to |
worktree | Worktree info when available |
tab_id, tab_label | Tab the context resolves to |
focused_pane_id, focused_pane_cwd | Focused pane |
focused_pane_agent, focused_pane_status | Agent in the focused pane and its status |
selected_text | Always null from the API path |
invocation_source | What triggered it, e.g. keybinding, api, link_click |
correlation_id | Id correlating this invocation |
clicked_url, link_handler_id | Set 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_textis hardnullfrom the API path. Selection is client presentation state, confirmed at source level incontext.rsand 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).