Files
picoclaw/docs/hooks/hook-json-protocol.md
T
Harmoon ee29aaa871 Enhance hooks with respond action and comprehensive documentation (#2215)
* feat(hooks): add respond action for tool execution bypass

Add a new HookActionRespond that allows hooks to return tool results directly, skipping actual tool execution. This enables plugin tool injection, caching, and mocking capabilities.

- Add HookActionRespond constant and support in HookManager
- Extend ToolCallHookRequest with HookResult field
- Implement respond action handling in process hooks and agent loop
- Add comprehensive tests for respond and deny_tool actions
- Update documentation with hook actions table and examples

* docs(hooks): add JSON-RPC protocol and plugin tool injection documentation

Add comprehensive documentation for hook JSON-RPC protocol and plugin tool injection capabilities:

- Add "Hook Actions" section to README.zh.md explaining respond action for tool execution bypass
- Create hook-json-protocol.md/.zh.md detailing JSON-RPC 2.0 protocol for all hook methods
- Create plugin-tool-injection.md/.zh.md with complete examples for external tool implementation
- Document how hooks can inject tool definitions and return results via respond action
- Include Python and Go examples for weather query plugin implementation

* feat(agent): emit tool events and feedback for hook results

Add ToolExecStart event emission and tool feedback for hook results to ensure consistent behavior between normal tool execution and hook bypass scenarios. This maintains parity in event tracking and user feedback when tools are executed via hooks.

* style(agent): format whitespace in hook structs and constants

Remove trailing whitespace and standardize spacing in JSON struct tags, constants, and test data for improved code consistency.

* feat(hooks): add media support for plugin tool injection

Extend the hook respond action to support media file handling:
- Add `media` field for returning images and files from hooks
- Add `response_handled` field to control turn completion behavior
- When response_handled=true, media is automatically delivered to user
- When response_handled=false, media is passed to LLM for vision requests

This enables plugins to directly return generated images, downloaded
files, and other media content either to users or for LLM analysis.

* docs(hooks): document security implications of respond action

Add security boundary documentation explaining that the respond action
bypasses ApproveTool checks, allowing hooks to return results for any
tool without approval. Include recommendations for secure hook
implementation and code comments marking the security considerations.

Changes:
- Add "Security Boundaries" section to plugin-tool-injection docs
- Document bypass of approval checks and associated risks
- Provide security recommendations and example code
- Add inline security comments in hooks.go and loop.go

* refactor(agent): improve completeness of tool result cloning and hook processing

Extend cloneToolResult to properly copy ArtifactTags and Messages fields,
ensuring deep copies of all ToolResult data. Consolidate event emission
and user message handling to match the normal tool execution flow.

* fix(agent): align hook respond path with normal tool execution flow

The hook respond code path was missing several critical behaviors that
existed in normal tool execution:

- Add logging for tool calls with arguments preview
- Add is_tool_call metadata to user-facing messages
- Handle attachment delivery failures by setting error state and
  notifying LLM
- Set ResponseHandled=false when using bus for media delivery
- Check for steering messages and graceful interrupts after tool
  execution, skipping remaining tools when appropriate
- Poll for SubTurn results that arrived during tool execution

This ensures consistent behavior between hook-responded tool calls and
normally executed tool calls.

* test(agent): add tests for hook respond media error handling

Add comprehensive tests for the hook respond code path when media
delivery fails. Tests cover error media channel scenarios and verify
proper error state handling.

Also document that AfterTool is not called when using respond action,
as it provides the final answer directly (design decision).
2026-04-08 11:47:02 +08:00

12 KiB

Hook JSON-RPC Protocol Details

All hooks use JSON-RPC 2.0 format, with one JSON message per line, transmitted via stdio.


Basic Protocol Structure

Request (PicoClaw → Hook)

{"jsonrpc":"2.0","id":1,"method":"hook.xxx","params":{...}}

Response (Hook → PicoClaw)

Success:

{"jsonrpc":"2.0","id":1,"result":{...}}

Error:

{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"error message"}}

1. hook.hello (Handshake)

Handshake must be completed at startup, otherwise the hook process will be terminated.

Request

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "hook.hello",
  "params": {
    "name": "py_review_gate",
    "version": 1,
    "modes": ["observe", "tool", "approve"]
  }
}
Field Description
name hook name (from configuration)
version protocol version, currently 1
modes capability modes supported by the hook

Response

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "ok": true,
    "name": "python-review-gate"
  }
}

2. hook.before_llm

Triggered before sending request to LLM. Can be used to inject tools.

Request

{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "hook.before_llm",
  "params": {
    "meta": {
      "AgentID": "agent-1",
      "TurnID": "turn-1",
      "ParentTurnID": "",
      "SessionKey": "session-1",
      "Iteration": 0,
      "TracePath": "runTurn",
      "Source": "turn.llm.request"
    },
    "model": "claude-sonnet",
    "messages": [
      {"role": "user", "content": "hello"}
    ],
    "tools": [
      {
        "type": "function",
        "function": {
          "name": "echo",
          "description": "echo text",
          "parameters": {"type": "object"}
        }
      }
    ],
    "options": {
      "temperature": 0.7
    },
    "channel": "cli",
    "chat_id": "chat-1",
    "graceful_terminal": false
  }
}
Field Description
meta event metadata for tracing
model requested model name
messages conversation history
tools list of available tool definitions
options LLM parameters (temperature, max_tokens, etc.)
channel request source channel
chat_id session ID

Response (Tool Injection Example)

{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "action": "modify",
    "request": {
      "model": "claude-sonnet",
      "messages": [{"role": "user", "content": "hello"}],
      "tools": [
        {
          "type": "function",
          "function": {
            "name": "echo",
            "description": "echo",
            "parameters": {}
          }
        },
        {
          "type": "function",
          "function": {
            "name": "my_plugin_tool",
            "description": "Plugin injected tool",
            "parameters": {
              "type": "object",
              "properties": {
                "query": {"type": "string"}
              }
            }
          }
        }
      ]
    }
  }
}
Field Description
action decision action (see table below)
request modified request object

3. hook.after_llm

Triggered after receiving LLM response. Can modify response content.

Request

{
  "jsonrpc": "2.0",
  "id": 3,
  "method": "hook.after_llm",
  "params": {
    "meta": {
      "AgentID": "agent-1",
      "TurnID": "turn-1",
      "SessionKey": "session-1"
    },
    "model": "claude-sonnet",
    "response": {
      "role": "assistant",
      "content": "Hi!",
      "tool_calls": [
        {
          "id": "tc-1",
          "type": "function",
          "function": {
            "name": "echo",
            "arguments": "{\"text\":\"hi\"}"
          }
        }
      ]
    },
    "channel": "cli",
    "chat_id": "chat-1"
  }
}

Response

{
  "jsonrpc": "2.0",
  "id": 3,
  "result": {
    "action": "continue"
  }
}

4. hook.before_tool

Triggered before tool execution. Can modify tool name and arguments, deny execution, or return result directly.

Request

{
  "jsonrpc": "2.0",
  "id": 4,
  "method": "hook.before_tool",
  "params": {
    "meta": {
      "AgentID": "agent-1",
      "TurnID": "turn-1",
      "SessionKey": "session-1"
    },
    "tool": "echo_text",
    "arguments": {
      "text": "hello"
    },
    "channel": "cli",
    "chat_id": "chat-1"
  }
}
Field Description
tool tool name
arguments tool arguments

Response (Modify Arguments)

{
  "jsonrpc": "2.0",
  "id": 4,
  "result": {
    "action": "modify",
    "call": {
      "tool": "echo_text",
      "arguments": {
        "text": "modified hello"
      }
    }
  }
}

Response (Deny Execution)

{
  "jsonrpc": "2.0",
  "id": 4,
  "result": {
    "action": "deny_tool",
    "reason": "Invalid arguments"
  }
}

Response (Return Result Directly - respond)

{
  "jsonrpc": "2.0",
  "id": 4,
  "result": {
    "action": "respond",
    "call": {
      "tool": "my_plugin_tool",
      "arguments": {
        "query": "hello"
      }
    },
    "result": {
      "for_llm": "Plugin tool executed successfully",
      "for_user": "",
      "silent": false,
      "is_error": false
    }
  }
}

The respond action allows hooks to return tool results directly, skipping actual tool execution. Use cases:

  1. Plugin tool injection: External hooks can implement tools without registering in ToolRegistry
  2. Tool result caching: Return cached results for repeated calls
  3. Tool mocking: Return mock results during testing
Field Description
action must be respond
call modified call information (optional)
result tool result to return directly

5. hook.after_tool

Triggered after tool execution completes. Can modify the result returned to LLM.

Request

{
  "jsonrpc": "2.0",
  "id": 5,
  "method": "hook.after_tool",
  "params": {
    "meta": {
      "AgentID": "agent-1",
      "TurnID": "turn-1",
      "SessionKey": "session-1"
    },
    "tool": "echo_text",
    "arguments": {
      "text": "hello"
    },
    "result": {
      "for_llm": "echoed: hello",
      "for_user": "",
      "silent": false,
      "is_error": false,
      "async": false,
      "media": [],
      "artifact_tags": [],
      "response_handled": false
    },
    "duration": 15000000,
    "channel": "cli",
    "chat_id": "chat-1"
  }
}
Field Description
result.for_llm content returned to LLM
result.for_user content sent to user
result.silent whether silent (not sent to user)
result.is_error whether it's an error
result.async whether executed asynchronously
result.media list of media references
result.artifact_tags local artifact path tags
result.response_handled whether response has been handled
duration execution time (nanoseconds)

Response

{
  "jsonrpc": "2.0",
  "id": 5,
  "result": {
    "action": "continue"
  }
}

6. hook.approve_tool

Approval hook for deciding whether to allow execution of sensitive tools.

Request

{
  "jsonrpc": "2.0",
  "id": 6,
  "method": "hook.approve_tool",
  "params": {
    "meta": {
      "AgentID": "agent-1",
      "TurnID": "turn-1",
      "SessionKey": "session-1"
    },
    "tool": "bash",
    "arguments": {
      "command": "rm -rf /"
    },
    "channel": "cli",
    "chat_id": "chat-1"
  }
}

Response (Approved)

{
  "jsonrpc": "2.0",
  "id": 6,
  "result": {
    "approved": true
  }
}

Response (Denied)

{
  "jsonrpc": "2.0",
  "id": 6,
  "result": {
    "approved": false,
    "reason": "Dangerous command, execution denied"
  }
}

7. hook.event (notification)

Observer event, broadcast only, no response required. id is 0 or absent.

{
  "jsonrpc": "2.0",
  "method": "hook.event",
  "params": {
    "Kind": "tool_exec_start",
    "Meta": {
      "AgentID": "agent-1",
      "TurnID": "turn-1"
    },
    "Payload": {
      "Tool": "echo_text",
      "Arguments": {"text": "hello"}
    }
  }
}

Common Kind values:

  • turn_start / turn_end
  • llm_request / llm_response
  • tool_exec_start / tool_exec_end / tool_exec_skipped
  • steering_injected
  • interrupt_received
  • error

Action Options

action Applicable hooks Effect
continue All interceptor types Pass through without modification
modify before_llm, before_tool, after_llm, after_tool Modify request/response and pass through
respond before_tool Return tool result directly, skip actual execution. Note: AfterTool is NOT called (design decision - respond provides final answer).
deny_tool before_tool Deny tool execution
abort_turn All interceptor types Abort current turn, return error
hard_abort All interceptor types Force stop entire agent loop

Complete Flow Example

{"jsonrpc":"2.0","id":1,"method":"hook.hello","params":{"name":"my_hook","version":1,"modes":["tool","approve"]}}
{"jsonrpc":"2.0","id":1,"result":{"ok":true,"name":"my_hook"}}
{"jsonrpc":"2.0","id":2,"method":"hook.before_llm","params":{"model":"claude-sonnet","messages":[{"role":"user","content":"hello"}],"tools":[]}}
{"jsonrpc":"2.0","id":2,"result":{"action":"continue"}}
{"jsonrpc":"2.0","id":3,"method":"hook.before_tool","params":{"tool":"bash","arguments":{"command":"ls"}}}
{"jsonrpc":"2.0","id":3,"result":{"action":"continue"}}
{"jsonrpc":"2.0","id":4,"method":"hook.approve_tool","params":{"tool":"bash","arguments":{"command":"ls"}}}
{"jsonrpc":"2.0","id":4,"result":{"approved":true}}
{"jsonrpc":"2.0","id":5,"method":"hook.after_tool","params":{"tool":"bash","arguments":{"command":"ls"},"result":{"for_llm":"file1.txt\nfile2.txt"},"duration":5000000}}
{"jsonrpc":"2.0","id":5,"result":{"action":"continue"}}
{"jsonrpc":"2.0","id":6,"method":"hook.after_llm","params":{"model":"claude-sonnet","response":{"role":"assistant","content":"Files listed"}}}
{"jsonrpc":"2.0","id":6,"result":{"action":"continue"}}

Plugin Tool Injection via before_llm and before_tool

Standard flow for plugin tool injection:

  1. In before_llm, inject tool definition to let LLM know the tool is available
  2. In before_tool, use respond action to return tool execution result directly

before_llm Inject Tool Definition

def handle_before_llm(params: dict) -> dict:
    tools = params.get("tools", [])
    
    # Add plugin tool definition
    tools.append({
        "type": "function",
        "function": {
            "name": "my_plugin_tool",
            "description": "Plugin provided tool",
            "parameters": {
                "type": "object",
                "properties": {
                    "input": {"type": "string", "description": "Input content"}
                },
                "required": ["input"]
            }
        }
    })
    
    return {
        "action": "modify",
        "request": {
            "model": params["model"],
            "messages": params["messages"],
            "tools": tools,
            "options": params.get("options", {})
        }
    }

before_tool Return Execution Result

def handle_before_tool(params: dict) -> dict:
    tool = params.get("tool", "")
    
    if tool == "my_plugin_tool":
        # Implement tool logic here
        args = params.get("arguments", {})
        input_text = args.get("input", "")
        
        # Return result directly, no need to register in ToolRegistry
        return {
            "action": "respond",
            "result": {
                "for_llm": f"Plugin tool executed successfully, input: {input_text}",
                "silent": False,
                "is_error": False
            }
        }
    
    return {"action": "continue"}

This way, external hooks can fully implement plugin tools without registering any tool implementation inside PicoClaw.