Skip to contents

A hook is an R function that Deputy calls at a fixed point in a run: before a tool runs, after it returns, when the run stops, and so on. Use hooks to log what an agent does, keep an audit trail, block particular tool calls, or stop a run early.

Add a hook

Create a hook with HookMatcher(), naming the event and the callback, and add it to an agent:

library(deputy)

log_tool <- HookMatcher(
  event = "PostToolUse",
  callback = function(tool_name, tool_result, tool_error, context) {
    cli::cli_inform("{tool_name} finished")
    NULL
  }
)

agent <- Agent$new(
  chat = ellmer::chat("openai/gpt-6-luna"),
  tools = tools_file(),
  permissions = permissions_readonly()
)
agent$add_hook(log_tool)

For tool events, pattern is a regular expression that limits the hook to matching tool names. hook_matches() tells you whether a hook applies to a tool:

library(deputy)

watch_writes <- HookMatcher(
  event = "PreToolUse",
  pattern = "^(write_file|edit_file|multi_edit)$",
  callback = function(tool_name, tool_input, context) {
    cli::cli_inform("{tool_name}: {tool_input$path}")
    NULL
  }
)
hook_matches(watch_writes, "edit_file")
#> [1] TRUE
hook_matches(watch_writes, "read_file")
#> [1] FALSE

Events

Event Fires Callback arguments
PreToolUse before a tool runs tool_name, tool_input, context
PostToolUse after a tool returns tool_name, tool_result, tool_error, context
PostToolUseFailure after a tool fails tool_name, tool_result, tool_error, context
PostModelRequest after each model request of a run context
PermissionRequest when the permission policy denies a call tool_name, tool_input, permission_result, context
SessionStart at the start of each run context
UserPromptSubmit at the start of each run, with the task prompt, context
Stop at each stop of a run reason, context
SessionEnd once, when the run has ended reason, context
SubagentStart when a subagent starts a delegated task agent_name, task, context
SubagentStop when a subagent finishes agent_name, task, result, context
PreCompact before older turns are summarised turns_to_compact, turns_to_keep, context
PostCompact after a summary is installed result, context
Notification when Deputy reports something, such as a denied call message, context
ConfigChange when set_permission_mode() changes the mode key, old_value, new_value, context

context is a named list with the working directory, the agent and run identifiers, and fields specific to the event, such as the tool’s annotations for PreToolUse or the run’s usage for Stop. ?HookEvent lists them all.

Stop and SessionEnd also get the run’s ending in context: a RunEnding value that says whether the run was answered, stopped, hit a limit, was cut off by the provider or failed. PostModelRequest gets the run’s usage so far, so you can show what a run is spending while it runs:

agent$add_hook(HookMatcher(
  event = "PostModelRequest",
  callback = function(context) {
    cli::cli_alert_info(
      "Request {context$request_number}: {context$usage$total_tokens} tokens so far"
    )
    NULL
  }
))

Block a tool call

A PreToolUse hook runs after the permission policy has allowed a call, and can still deny it. Return HookResultPreToolUse():

no_secrets <- HookMatcher(
  event = "PreToolUse",
  pattern = "^write_file$",
  callback = function(tool_name, tool_input, context) {
    if (grepl("\\.env$|secrets", tool_input$path)) {
      return(HookResultPreToolUse("deny", reason = "Don't write to secret files."))
    }
    NULL
  }
)
agent$add_hook(no_secrets)

The model sees the reason and can try something else. A PreToolUse hook can only take permissions away: calls the policy denies never reach it.

Allow a denied call

A PermissionRequest hook runs only when the policy denies a call, and can allow the call anyway. Use it to ask a person before running something the policy would refuse:

ask_before_code <- HookMatcher(
  event = "PermissionRequest",
  pattern = "^run_r_code$",
  callback = function(tool_name, tool_input, permission_result, context) {
    cat(tool_input$code, sep = "\n")
    if (isTRUE(utils::askYesNo("Run this R code?", default = FALSE))) {
      return(PermissionResultAllow())
    }
    NULL
  }
)
agent$add_hook(ask_before_code)

Returning NULL keeps the denial, and PermissionResultDeny() replaces its reason. An allowed call runs with your R session’s access, so treat this hook as part of the permission policy.

Stop a run from a hook

PostToolUse hooks see each tool’s result or error. Return HookResultPostToolUse(continue = FALSE) to end the run after the current tool:

stop_on_error <- HookMatcher(
  event = "PostToolUse",
  callback = function(tool_name, tool_result, tool_error, context) {
    if (!is.null(tool_error)) {
      cli::cli_alert_danger("{tool_name} failed: {tool_error}")
      return(HookResultPostToolUse(continue = FALSE, stop_reason = "tool_failed"))
    }
    NULL
  }
)

HookResultPreToolUse() also takes continue and stop_reason. Both take additional_context, text that Deputy appends to the system prompt.

Check an answer and send the run on

A Stop hook sees a run that has just stopped. Return HookResultStop() to send an answered run on: Deputy asks the model again with your message, in the same run, so the run keeps its ID, its limits and its usage, and a stream carries on. This is how to check an answer and ask for a fix without starting a second run:

needs_sources <- HookMatcher(
  event = "Stop",
  callback = function(reason, context) {
    # Only an answered run, and only once.
    if (!S7::S7_inherits(context$ending, RunEndingAnswered)) {
      return(NULL)
    }
    if (context$stop_hook_active) {
      return(NULL)
    }
    answer <- agent$last_turn()@text
    if (!grepl("Sources:", answer, fixed = TRUE)) {
      return(HookResultStop("Add a 'Sources:' line listing what you read."))
    }
    NULL
  }
)
agent$add_hook(needs_sources)

Stop fires again when the run next stops, with context$stop_hook_active set to TRUE. Check it, as the hook above does, or the hook can send the run on for ever; the run’s limits are the only other thing that would stop it. SessionEnd fires once, when the run has really ended. Only an answered run is sent on: a run that was stopped, hit a limit or failed ends as it stands. The result’s response is the text of the last pass, and its turns hold them all.

Wait for something

A callback can return a promise of its result. Deputy waits for it before going on, so a hook can ask a person, call another model or read a file without blocking your R session:

ask_reviewer <- HookMatcher(
  event = "PreToolUse",
  pattern = "^run_bash$",
  promise_timeout = 300,
  callback = function(tool_name, tool_input, context) {
    # ask_someone() returns a promise that resolves to TRUE or FALSE.
    promises::then(ask_someone(tool_input$command), function(approved) {
      if (approved) NULL else HookResultPreToolUse("deny", reason = "Not approved.")
    })
  }
)

promise_timeout is how long Deputy waits, in seconds; the default is 60. A promise that is rejected or hasn’t settled by then counts as no answer and is recorded as a hook error, and for PreToolUse that denies the call. Where the wait happens depends on the event:

  • PreToolUse and PermissionRequest: before the tool runs.
  • PostToolUse and PostToolUseFailure: before the next model request. The tool’s result is already recorded when the hook answers.
  • PostModelRequest, SessionStart, UserPromptSubmit, SubagentStart, SubagentStop, and PreCompact and PostCompact during automatic compaction: before the run goes on.
  • Stop and SessionEnd: before the run finishes. While a Stop hook waits, the agent is still running, so it refuses a new run until the hook answers.
  • Notification and ConfigChange, and any hook fired by $compact(): Deputy doesn’t wait.

If you call agent$interrupt() while a hook is waiting, the run stops when the hook’s promise settles: a tool that was waiting doesn’t run, and a Stop hook’s answer no longer sends the run on. The same goes for a callback that calls agent$interrupt() itself: the tool it was asked about doesn’t run. Otherwise a hook that returns a plain value behaves as it always has.

Built-in hooks

hook_log_tools() prints a line after each tool call saying whether it succeeded. verbose = TRUE adds a short preview of the result:

agent$add_hook(hook_log_tools(verbose = TRUE))

hook_block_dangerous_bash() denies shell commands that match patterns such as rm -rf, sudo and chmod 777. Add your own patterns with additional_patterns:

agent$add_hook(hook_block_dangerous_bash(
  additional_patterns = c("DROP\\s+TABLE", "TRUNCATE")
))

A pattern list catches accidents, not a determined adversary, so don’t rely on it to make run_bash safe.

hook_limit_file_writes(dir) denies writes and edits outside dir, applying the same path rules as Permissions(file_write = dir). Use it to add a second check, or to confine writes more tightly for one agent than its policy does.

Order and errors

Hooks for an event run in the order you added them, and the first hook that returns something other than NULL decides; later hooks for that event don’t run. So return NULL from hooks that only observe, and add hooks that can deny a call before hooks that might allow it. The built-in hooks return NULL unless they deny a call.

If a PreToolUse hook throws an error, Deputy denies the tool call. An error in any other hook is reported and the run carries on. A promise that is rejected or times out is handled the same way. agent$hooks$last_errors() lists recent hook errors.

Run a hook in a separate process

Hooks run in your R session by default, so they can read and update your objects. Give HookMatcher() a positive timeout (in seconds) to run the callback in a fresh R process that is killed if it takes too long. That process can’t see your session’s objects, so refer to package functions with ::, for example deputy::HookResultPreToolUse(). It can’t return a promise either.

Hooks or results?

Hooks are for reacting while a run is in progress. To analyse a run afterwards, use the AgentResult instead: result_tool_calls(), result_tool_results() and result$events hold the same information, without any hook state to manage, and result$ending says how the run ended. With $stream_async(), stream_result(stream) gives a promise of that result.

Human input and approvals shows hooks that ask a person to approve a tool call before it runs.