7Hook System
Shell commands fire at lifecycle boundaries (run-start / step / run-end) โ for logging, eval, side effects.
Shell commands that fire before and after node execution, at graph lifecycle boundaries, enabling validation, logging, and policy enforcement without modifying the execution engine.
1. Overview
The hook system lets researchers attach shell commands to graph execution events. Hooks can observe, modify inputs/outputs, or block node execution โ all without changing the graph nodes themselves.
Design principles:
- Fail-open โ hook errors (timeout, crash, malformed JSON) default to continue. Never crashes graph execution.
- Zero-cost when unused โ a single has_hooks() boolean check guards all hook logic. No overhead when graph.hooks is empty.
- Serializable โ hooks are pure JSON data in GraphDefinition. They travel with saved graphs.
2. Hook Events
| Event | When it fires | Common use |
|---|---|---|
PreNodeExecute |
Before instance.forward() is called |
Validate inputs, block dangerous nodes, modify inputs |
PostNodeExecute |
After instance.forward() completes (success or error) |
Log results, modify outputs, track errors |
GraphStart |
Before the dataflow loop begins | Set up logging, initialize external systems |
GraphComplete |
After the loop ends (both success and error) | Export metrics, finalize logs |
GraphError |
When an unhandled exception occurs | Alert, dump diagnostics |
2.1 PreNodeExecute Actions
The hook's response determines what happens:
| Action | Effect |
|---|---|
"continue" |
Proceed normally (default) |
"block" |
Skip this node โ _fire_node() returns {} without executing |
"modify" |
Replace pending_inputs with modified_data before executing |
2.2 PostNodeExecute Actions
| Action | Effect |
|---|---|
"continue" |
Proceed normally (default) |
"modify" |
Replace the node's output with modified_data (only on success, not on error) |
PostNodeExecute receives an error field in the payload: null on success, the error message string on failure. This ensures observability hooks can track failures.
3. Hook Configuration
Hooks are defined in the GraphDefinition JSON:
{
"name": "My Agent",
"nodes": [...],
"edges": [...],
"hooks": [
{
"event": "PostNodeExecute",
"command": "python log_node.py",
"match_node_type": "llmCall",
"timeout_ms": 1000,
"enabled": true
},
{
"event": "PreNodeExecute",
"command": "python validate_env.py",
"match_node_type": "env_habitat__*"
},
{
"event": "GraphComplete",
"command": "python export_metrics.py",
"match_node_type": "*"
}
]
}
3.1 HookDef Fields
| Field | Type | Default | Description |
|---|---|---|---|
event |
string | (required) | One of the 5 events above |
command |
string | (required) | Shell command to run |
match_node_type |
string | "*" |
Node type filter (see matching rules) |
match_node_id |
string | null | null |
Exact node instance match (auto-set for node-level hooks) |
timeout_ms |
int | 1000 |
Max execution time per invocation |
enabled |
bool | true |
Toggle without deleting |
3.2 Backend Data Model
@dataclass
class HookDef:
event: str
command: str
match_node_type: str = "*"
match_node_id: Optional[str] = None # exact node instance match
timeout_ms: int = 1000
enabled: bool = True
Added to GraphDefinition as hooks: List[HookDef] โ serialized only when non-empty for backward compatibility. Node-level hooks are stored in NodeDef.config["hooks"] and auto-get match_node_id set during merge.
4. Matching Rules
4.1 Type-based matching
The match_node_type field supports three patterns:
| Pattern | Matches | Example |
|---|---|---|
"*" |
All node types | Every node fires the hook |
"llmCall" |
Exact match | Only llmCall nodes |
"env_habitat__*" |
Prefix match via startswith |
env_habitat__observe, env_habitat__step, etc. |
4.2 Instance-based matching
When match_node_id is set (non-null), the hook matches only the specific node instance with that ID. This takes precedence over match_node_type โ type matching is skipped entirely.
Node-level hooks (from NodeDef.config["hooks"]) automatically get match_node_id set to the node's ID during extraction, ensuring they fire only for their owning node.
4.3 Implementation
def _matches(self, hook, node_type, node_id=None):
# Node-level hooks: exact instance match
if hook.match_node_id is not None:
return node_id is not None and hook.match_node_id == node_id
# Type-based matching (graph-level and global hooks)
pattern = hook.match_node_type
if pattern == "*":
return True
if node_type is None:
return False
if pattern.endswith("__*"):
return node_type.startswith(pattern[:-1])
return pattern == node_type
5. Execution Protocol
Hooks communicate with the executor via stdin/stdout JSON.
5.1 stdin (executor โ hook)
{
"event": "PreNodeExecute",
"node_type": "llmCall",
"node_id": "node_abc123",
"inputs": {"prompt": "Navigate to the kitchen..."}
}
For PostNodeExecute, includes outputs and error (null or string).
For lifecycle events (GraphStart, GraphComplete, GraphError), includes relevant metadata.
5.2 stdout (hook โ executor)
{
"action": "continue",
"modified_data": null
}
- Empty stdout or no JSON โ treated as
"continue"(fail-open) - Unknown
actionvalue โ treated as"continue" - Non-zero exit code โ treated as
"continue" - Timeout โ subprocess killed, treated as
"continue"
5.3 Execution Order
Multiple hooks for the same event run sequentially in list order:
- First "block" result wins โ short-circuits, remaining hooks don't run
- "modify" results accumulate โ each hook can re-modify the previous hook's output
- Errors in one hook don't prevent subsequent hooks from running
6. Hook Sources and Merge Order
Hooks come from three sources, merged in order from least to most specific:
global hooks (workspace/hooks.json)
+ graph-level hooks (GraphDefinition.hooks)
+ node-level hooks (NodeDef.config.hooks, match_node_id auto-set)
= merged list โ HookRunner
6.1 Global Hooks
Project-wide hooks defined in workspace/hooks.json:
[
{"event": "GraphComplete", "command": "python log_metrics.py", "match_node_type": "*"}
]
Loaded by WorkspaceComponentRegistry._load_global_hooks() at scan time. Passed to GraphExecutor via the API โ LoopRunner โ executor chain.
6.2 Graph-Level Hooks
Defined in GraphDefinition.hooks โ travel with saved graph JSON files.
6.3 Node-Level Hooks
Stored in NodeDef.config["hooks"]. Configured per-node via the Properties panel. During merge, extract_node_hooks() auto-sets match_node_type to the node's type and match_node_id to the node's ID.
6.4 Merge Utilities
# hooks.py
def extract_node_hooks(nodes: List[NodeDef]) -> List[HookDef]:
"""Extract hooks from NodeDef.config, auto-set match_node_id."""
def merge_hooks(global_hooks, graph_hooks, node_hooks) -> List[HookDef]:
"""Concatenate: global โ graph โ node."""
def load_hooks_file(path: str) -> List[HookDef]:
"""Load from JSON file. Returns [] on missing/malformed."""
6.5 Precedence Semantics
- block: first block wins (global can enforce project-wide policy)
- modify: last modify wins (node-level has final say)
7. Integration Points
7.1 GraphExecutor.run()
async def run(self, graph, orchestrator, ..., global_hooks=None):
# Merge 3 hook sources
node_hooks = extract_node_hooks(graph.nodes)
merged = merge_hooks(global_hooks or [], graph.hooks, node_hooks)
self._hook_runner = HookRunner(merged)
# GraphStart
await self._hook_runner.run_hooks("GraphStart", payload={...})
try:
# ... dataflow loop ...
except Exception as e:
await self._hook_runner.run_hooks("GraphError", payload={"error": str(e)})
raise
finally:
await self._hook_runner.run_hooks("GraphComplete", payload={...})
7.2 GraphExecutor._fire_node()
async def _fire_node(self, node, orchestrator):
# PreNodeExecute โ can block or modify inputs
if self._hook_runner.has_hooks():
pre_result = await self._hook_runner.run_hooks(
"PreNodeExecute", node_type=node.type, node_id=node.id,
payload={"node_id": node.id, "inputs": safe_serialize(node.pending_inputs)},
)
if pre_result.action == "block":
return {}
if pre_result.action == "modify" and pre_result.modified_data:
node.pending_inputs.update(pre_result.modified_data)
# Execute with error capture
exec_error = None
try:
result = await instance.forward(node.pending_inputs, ctx)
except Exception as e:
exec_error = e
result = {"error": str(e)}
# PostNodeExecute โ fires on BOTH success and error
if self._hook_runner.has_hooks():
post_result = await self._hook_runner.run_hooks(
"PostNodeExecute", node_type=node.type, node_id=node.id,
payload={"node_id": node.id, "outputs": safe_serialize(result),
"error": str(exec_error) if exec_error else None},
)
if post_result.action == "modify" and post_result.modified_data and not exec_error:
result = post_result.modified_data
if exec_error:
raise exec_error
return result
8. Properties Panel UI
When a node is selected on the canvas, the Properties panel shows a Hooks section at the bottom:
- Add button creates a new hook with defaults (
PostNodeExecute, empty command, 1000ms timeout, enabled) - Each hook row shows: event dropdown (
PreNodeExecute/PostNodeExecute), command text input, timeout input (ms), enabled toggle, remove button - Changes are stored in
node.data.hookswhich maps toNodeDef.config.hooksviafromFlowNodes - The
match_node_typeandmatch_node_idfields are implicit โ auto-set during server-side extraction
The UI is implemented as HooksSection + HookRow components in PropertiesPanel.tsx.
9. safe_serialize()
Hook payloads must be JSON-safe. The safe_serialize() utility converts complex Python objects to metadata-only representations โ no full data (images, arrays) passes through the hook stdin pipe.
| Input Type | Output |
|---|---|
str, int, float, bool, None |
Pass through |
dict |
Recurse (up to max_depth=3) |
list, tuple |
Recurse (up to max_depth=3) |
numpy.ndarray |
{"__type": "ndarray", "shape": [H, W, C], "dtype": "float32"} |
PIL.Image |
{"__type": "Image", "size": [W, H], "mode": "RGB"} |
bytes |
{"__type": "bytes", "length": N} |
| Any other type | str(obj)[:200] |
| At max depth | str(obj)[:200] |
Prior art: _broadcast_step() in graph_executor.py handles similar conversions for WebSocket broadcast.
10. Performance Considerations
Shell hooks have subprocess startup overhead (~5-20ms per invocation). This is significant for large graphs:
| Graph | Nodes/iter | Iterations | Wildcard hook overhead |
|---|---|---|---|
| Simple DAG | 5 | 1 | ~50ms (negligible) |
| NavGPT-CE | 14 | 500 | ~70s (significant) |
| NavGPT-CE + pre+post | 14 | 500 | ~140s (major) |
Mitigations:
1. Use exact or prefix matching instead of wildcard ("llmCall" vs "*")
2. Reduce step_budget during development
3. Set enabled: false to disable hooks without removing config
4. Future V1.1: persistent-process hooks that avoid subprocess respawn
Zero-cost guarantee: When graph.hooks is empty (the common case), HookRunner.has_hooks() returns False and all hook code is skipped โ no async overhead, no function calls, just a single boolean check.
11. Key Files
| File | Role |
|---|---|
agentcanvas/backend/app/agent_loop/hooks.py |
HookRunner, HookResult, safe_serialize(), extract_node_hooks(), merge_hooks(), load_hooks_file() |
agentcanvas/backend/app/graph_def.py |
HookDef dataclass (with match_node_id), GraphDefinition.hooks field |
agentcanvas/backend/app/components/registry.py |
_load_global_hooks(), get_global_hooks() โ loads workspace/hooks.json |
agentcanvas/backend/app/agent_loop/graph_executor.py |
3-source merge in run(), node_id in _fire_node() |
agentcanvas/backend/app/agent_loop/loop_runner.py |
Threads global_hooks parameter |
agentcanvas/backend/app/api/execution/run.py |
Fetches global hooks from WorkspaceComponentRegistry.get_global_hooks() and threads them into LoopRunner.run(..., global_hooks=...) on the POST /run path (the single /run endpoint per ADR-platform-002; the old api/navigate_api.py module is gone) |
agentcanvas/frontend/src/canvas/types.ts |
HookDef TypeScript interface (with match_node_id) |
agentcanvas/frontend/src/canvas/panels/PropertiesPanel.tsx |
HooksSection + HookRow โ per-node hook editor |
agentcanvas/frontend/src/canvas/runPipeline.ts |
Passes hooks to backend in loopDef |
workspace/hooks.json |
Global hooks config file (empty [] by default) |
agent_loop/test_hooks.py (49 tests) is no longer present in the tree; find agentcanvas -name "test*hook*" returns nothing. Behaviour-level coverage of hook merge / matching / subprocess wiring exists implicitly through the higher-level executor tests, but a dedicated hook test suite is currently a gap โ flag as a follow-up before relying on hooks for load-bearing eval workflows.
Status
| Item | Status | Notes |
|---|---|---|
| HookDef data model | Done | Serializable, backward compatible, includes match_node_id |
| HookRunner (subprocess execution) | Done | Fail-open, timeout, kill |
| safe_serialize() | Done | Metadata-only for numpy/images |
| Matching (, exact, prefix__, node_id) | Done | 4 patterns supported |
| PreNodeExecute (block/modify) | Done | Guards _fire_node() |
| PostNodeExecute (success + error) | Done | try/except, error field in payload |
| GraphStart / GraphComplete / GraphError | Done | Lifecycle boundaries |
| 3-source merge (global/graph/node) | Done | merge_hooks() in hooks.py |
Global hooks (workspace/hooks.json) |
Done | Loaded by WorkspaceComponentRegistry |
| Node-level hooks (Properties panel UI) | Done | HooksSection in PropertiesPanel.tsx |
| Frontend types + runPipeline passthrough | Done | Also fixed containers/state_edges gap |
| Dedicated hook test suite | Gap | Earlier agent_loop/test_hooks.py (49 tests) is no longer in the tree; hook code paths are now covered only implicitly via higher-level executor tests. Re-introduce a focused suite before relying on hooks for load-bearing eval gating. |
| Persistent-process hooks (V1.1) | Not done | Future: avoid subprocess respawn for hot loops |