Skip to content

Hooks and Plugins

Hooks and plugins let other programs observe or change Anode runs. Use shell hooks for simple lifecycle commands. Use process plugins when you need a richer protocol or want to contribute tools.

Both fire on agent lifecycle events. Shell hooks are simpler to set up. Process plugins give you more control.

Shell hooks are defined in JSON config files:

ScopePath
User~/.config/anode/hooks.json
Project.anode/hooks.json
Projecthooks.json

All existing files are loaded. User hooks load first; project hooks are appended.

EventDescriptionCan reject?
PreToolUseBefore a tool executesYes
PostToolUseAfter a tool executesNo
SessionStartWhen a session beginsNo
SessionEndWhen a session endsNo
UserPromptSubmitAfter a run starts and before the submitted prompt reaches the modelYes
PreCompactImmediately before conversation history is compacted for context budgetNo
{
"hooks": {
"PreToolUse": [
{
"matcher": "bash|edit_file",
"hooks": [
{
"type": "command",
"command": "/path/to/hook.sh",
"timeout": 60,
"description": "Custom validation"
}
]
}
],
"PostToolUse": [
{
"matcher": ".*",
"hooks": [
{
"type": "command",
"command": "/path/to/logger.sh",
"timeout": 10,
"description": "Log tool results"
}
]
}
]
}
}

Each event key maps to an array of matcher groups. Each group has a regex matcher for tool-name filtering and an array of hooks to execute.

The matcher field is a regex pattern tested against the tool name. Use ".*" to match all tools. Use "bash|edit_file" to match specific tools. Omit matcher to match everything.

Anode sends a JSON object on stdin to each hook command:

{
"event": "PreToolUse",
"tool_name": "bash",
"tool_args": {"command": "npm test"},
"file_path": "",
"command": "npm test",
"workspace_root": "/home/user/project"
}
FieldDescription
eventHook event name
tool_nameName of the tool being called
tool_argsFull tool arguments object
file_pathFile path argument (if applicable)
commandCommand argument (if applicable)
promptSubmitted user prompt for UserPromptSubmit
workspace_rootAbsolute path to the workspace root

Anode sets these environment variables for every hook command:

VariableDescription
ANODE_HOOK_EVENTHook event name
ANODE_TOOL_NAMETool name being called
ANODE_FILE_PATHFile path argument
ANODE_COMMANDCommand argument
ANODE_WORKSPACEWorkspace root directory
ANODE_PROJECT_DIRWorkspace root directory

Hook command strings may also interpolate ${FACTORY_PROJECT_DIR} and ${CLAUDE_PROJECT_DIR}; Anode expands those placeholders to the workspace root before running the command.

PreToolUse hooks can control whether the tool executes. Write a JSON decision to stdout:

{"decision": "reject", "reason": "This command modifies production data"}
DecisionEffect
allowPermit the tool call
rejectBlock this call with an error message
blockBlock and prevent retries

If the hook exits without a decision, the tool call proceeds. Other currently wired shell hook events do not control execution.

#!/usr/bin/env bash
# .anode/hooks/no-rm-rf.sh
set -euo pipefail
INPUT=$(cat)
CMD=$(echo "$INPUT" | jq -r '.tool_args.command // ""')
if echo "$CMD" | grep -qE 'rm\s+-rf\s+/'; then
echo '{"decision": "reject", "reason": "Refusing rm -rf on root paths"}'
exit 0
fi
echo '{"decision": "allow"}'
  • Hooks within a matcher group run in parallel.
  • Default timeout: 60 seconds per hook.
  • Hooks that exceed the timeout are killed.
  • A non-zero exit code does not reject by itself. To reject, print JSON such as {"decision":"reject","reason":"..."} to stdout.
  • Non-JSON stdout is treated as feedback content by the shell hook runner. In the current app adapter, PreToolUse feedback is surfaced as warnings; SessionStart output is not injected into agent context.

Process plugins are external executables that speak a structured JSON protocol. They can observe events, modify tool calls, and contribute custom tools.

Set plugin paths via environment variable or config:

export ANODE_PLUGINS="/path/to/plugin1:/path/to/plugin-dir"

Or in config.json:

{
"plugins": {
"paths": ["/path/to/plugin1", "/path/to/plugin-dir"],
"timeoutSeconds": 5
}
}

Paths can be executable files or directories of executables. Anode skips invalid entries silently.

Plugins use protocol version 1. Anode sends a JSON envelope on stdin and sets the ANODE_PLUGIN_EVENT environment variable for each invocation.

EventDescriptionCan modify?
on_run_startRun beginsNo
on_run_endRun completesNo
before_model_requestBefore sending to the modelNo
pre_compactImmediately before conversation history is compacted for context budgetNo
after_model_responseAfter receiving model responseNo
before_tool_callBefore a tool executesYes
after_tool_callAfter a tool executesNo
on_approval_requestTool needs approvalNo
on_check_resultValidation check completedNo
describePlugin self-description (returns tools)N/A
tool_callCustom tool invocationN/A

before_tool_call is the most powerful event. A plugin can:

Rewrite the call - return a replacement call object:

{"call": {"name": "bash", "args": {"command": "echo safe"}}}

Reject the call - block execution with a reason:

{"reject_reason": "Not permitted by policy"}

Synthesize a result - return a complete Anode tool result without executing the original tool:

{
"result": {
"tool": "bash",
"ok": true,
"summary": "Synthetic result",
"stdout": "mocked output for testing"
}
}

Return an empty object {} or nothing to let the call proceed unchanged.

Plugins can contribute custom tools. Anode sends a describe event at startup. The plugin returns a JSON array of tool definitions:

{
"tools": [
{
"name": "lint",
"description": "Run project linter",
"args": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "File to lint"}
},
"required": ["path"]
}
}
]
}

Plugin tools register as plugin__<plugin>__<tool>. For example, a plugin named quality contributing a lint tool registers as plugin__quality__lint.

Plugin tools:

  • Appear in anode tools list.
  • Obey normal permission policy.
  • Receive tool_call events when invoked.
SettingDefault
Per-hook timeout5 seconds
Output limit1 MB

Configure the timeout in config.json under plugins.timeoutSeconds.

#!/usr/bin/env python3
"""A simple process plugin that logs tool calls."""
import json
import os
import sys
event = os.environ.get("ANODE_PLUGIN_EVENT", "")
data = json.load(sys.stdin)
if event == "describe":
json.dump({"tools": []}, sys.stdout)
elif event == "before_tool_call":
tool = data.get("payload", {}).get("call", {}).get("name", "")
with open("/tmp/anode-plugin.log", "a") as f:
f.write(f"tool_call: {tool}\n")
# Proceed without modification
json.dump({}, sys.stdout)
elif event == "tool_call":
# Handle custom tool invocations
json.dump({
"result": {
"tool": data.get("payload", {}).get("name", "plugin_tool"),
"ok": True,
"summary": "plugin tool completed",
"stdout": "ok"
}
}, sys.stdout)

Make it executable and add it to your plugin paths.

Use the hooks command to inspect shell hooks and process plugin hooks:

anode hooks list
anode hooks ls
anode hooks doctor

hooks list shows configured shell hooks and process plugins without failing on diagnostics. hooks doctor validates hook and process plugin configuration; successful checks end with Hook diagnostics passed..

Use the plugins command to inspect and reload process plugins:

anode plugins
anode plugins list
anode plugins ls
anode plugins reload
anode plugins activity
anode plugins doc
anode plugins exec /path/to/plugin

plugins is the same as plugins list; plugins ls is an alias. plugins doc prints the process plugin protocol reference. plugins exec discovers one executable and lists its contributed tools without adding it to config. plugins activity explains that process plugin activity is not persisted and points to anode plugins list for configured plugin paths and contributed tools.

The run engine has Go-native hook interfaces used internally by Anode, process plugins, shell hook adapters, and tests. The public pkg/anode SDK does not currently expose hook registration through anode.Options.

HookSignatureCan modify?
OnRunStartfunc(runID string)No
OnRunEndfunc(runID string, err error)No
OnUserPromptSubmitfunc(prompt string) *UserPromptSubmitDecisionYes
BeforeModelRequestfunc(req *Request)No
OnPreCompactfunc(event *PreCompactEvent)No
AfterModelResponsefunc(resp *Response)No
BeforeToolCallfunc(call *ToolCall) *ToolCallDecisionYes
AfterToolCallfunc(call *ToolCall, result *ToolResult)No
OnApprovalRequestfunc(req *ApprovalRequest)No
OnCheckResultfunc(result *CheckResult)No

BeforeToolCall can return a decision to rewrite the call, reject it, or synthesize a result — the same behavior process plugins expose.