AgentCanvas / Pages / Developer Guide / Capabilities / 7Hook System
2026-05-18 (api/execution/run.py path fix + test gap)

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
}

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

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:

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)
Test coverage note (2026-05-18) The previously-listed 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
AgentCanvas docs