Skip to contents

Hooks run R callbacks before or after an agent action. Use them to report tool activity with cli, reject a tool call, or record results for your app.

Hook Events

deputy fires hooks at these events:

Event Callback Signature Purpose
PreToolUse function(tool_name, tool_input, context) Before a tool runs (can allow/deny)
PostToolUse function(tool_name, tool_result, tool_error, context) After a tool completes
Notification function(message, context) Informational runtime notices
Stop function(reason, context) When the agent finishes
SubagentStop function(agent_name, task, result, context) When a sub-agent finishes
UserPromptSubmit function(prompt, context) When user submits input
PreCompact function(turns_to_compact, turns_to_keep, context) Before conversation compaction
SessionStart function(context) When a session starts
SessionEnd function(reason, context) When a session ends

Creating Hooks

Hooks are S7 values created with HookMatcher(). Their configuration is read-only after construction. Use S7::prop(hook, "event") to read a property and hook_matches(hook, "write_file") to test a tool name. Callback closures retain their caller-owned state.

Hooks are created with HookMatcher():

library(deputy)

hook <- HookMatcher(
  event = "PostToolUse",
  callback = function(tool_name, tool_result, tool_error, context) {
    cli::cli_alert_info("Tool {tool_name} completed")
    HookResultPostToolUse()
  }
)

Add hooks to an agent with add_hook():

agent$add_hook(hook)

Hooks run in the caller’s R process by default, so callbacks can use ordinary R state and side effects. Set a positive timeout only when you deliberately want a clean callr subprocess with a hard deadline. An isolated callback cannot rely on caller-process state; qualify package functions such as deputy::HookResultPostToolUse() when using that mode.

Hook result constructors return read-only S7 values. Inspect properties with @, $, or S7::prop() and use S7::props() for a plain record. To change a decision, construct a new result. Nested output objects retain their identity.

library(deputy)
blocked <- HookResultPreToolUse("deny", reason = "Read only")
S7::S7_inherits(blocked, HookResult)
#> [1] TRUE
blocked$permission
#> [1] "deny"
S7::props(HookResultPreCompact(continue = FALSE))
#> $continue
#> [1] FALSE
#> 
#> $summary
#> NULL

continue must be one non-missing logical value; optional text must be NULL or one non-missing string. suppress_output keeps its isTRUE() coercion. Errors raised while constructing results inside callbacks follow the existing hook error path: PreToolUse fails closed; other events log and continue.

Filtering by Tool Name

The optional pattern argument is a regex that filters which tools the hook applies to:

# Only fires for bash commands
HookMatcher(
  event = "PreToolUse",
  pattern = "^run_bash$",
  callback = function(tool_name, tool_input, context) {
    cli::cli_alert_warning("Bash command: {tool_input$command}")
    HookResultPreToolUse(permission = "allow")
  }
)

Pre-Built Hooks

deputy includes several ready-made hooks:

Logging Tool Calls

hook_log_tools() logs every tool call using cli:

library(deputy)

chat <- ellmer::chat_openai(model = "gpt-5.6-luna")
agent <- Agent$new(
  chat = chat,
  tools = tools_file(),
  permissions = permissions_readonly()
)
agent$add_hook(hook_log_tools(verbose = TRUE))

result <- agent$run_sync("What files are in the current directory?")

Blocking Dangerous Bash Commands

hook_block_dangerous_bash() blocks patterns like rm -rf, sudo, chmod 777, and more:

agent$add_hook(hook_block_dangerous_bash())

# Optionally add your own patterns
agent$add_hook(hook_block_dangerous_bash(
  additional_patterns = c("DROP\\s+TABLE", "TRUNCATE")
))

Limiting File Writes

Set Permissions(file_write = output_dir) to restrict native file writes to a directory. hook_limit_file_writes() adds a second check using the same path rules for write_file, edit_file, and multi_edit. The directory must already exist:

output_dir <- file.path(getwd(), "output")
dir.create(output_dir, showWarnings = FALSE)

agent$add_hook(hook_limit_file_writes(output_dir))

Custom PreToolUse Hooks

PreToolUse hooks can allow or deny tool calls. Return HookResultPreToolUse() with permission = "allow" or "deny":

hook_no_secrets <- HookMatcher(
  event = "PreToolUse",
  pattern = "^write_file$",
  callback = function(tool_name, tool_input, context) {
    path <- tool_input$path
    if (is.null(path)) {
      path <- ""
    }
    if (grepl("\\.env$|secrets", path)) {
      HookResultPreToolUse(
        permission = "deny",
        reason = "Cannot write to secret files"
      )
    } else {
      HookResultPreToolUse(permission = "allow")
    }
  }
)

Custom PostToolUse Hooks

PostToolUse hooks run after a tool completes. Use them for logging, metrics, or conditional stopping:

hook_audit <- 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}")
    } else {
      cli::cli_alert_success("{tool_name} completed")
    }
    HookResultPostToolUse()
  }
)

chat <- ellmer::chat_openai(model = "gpt-5.6-luna")
agent <- Agent$new(
  chat = chat,
  tools = tools_file(),
  permissions = permissions_readonly()
)
agent$add_hook(hook_audit)

result <- agent$run_sync("What files are in the current directory?")

# For post-hoc analysis, use AgentResult instead of hook state:
result_tool_calls(result)

Set continue = FALSE to stop the agent after a tool call:

HookResultPostToolUse(continue = FALSE)

Session Lifecycle Hooks

Session hooks fire at the start and end of a session:

HookMatcher(
  event = "SessionStart",
  callback = function(context) {
    cli::cli_inform("Session started at {Sys.time()}")
    NULL
  }
)

Notification Hooks

Notification hooks are useful for informational events that should not alter control flow, such as permission-denied guidance, session-load notices, or compaction fallbacks:

agent$add_hook(HookMatcher(
  event = "Notification",
  callback = function(message, context) {
    cli::cli_alert_info("[{context$code}] {message}")
    NULL
  }
))

Error Handling in Hooks

If a PreToolUse hook throws an error, Deputy fails closed and denies the tool call. Other hook errors are logged and the run continues. You can inspect recent hook errors with:

agent$hooks$last_errors()

Approval before a tool call

The installed recipe defines approval_gate() and approval_after_install():

source(system.file("examples", "approval-gates.R", package = "deputy"))

approval_gate() calls the ask_user tool from tools_interactive() directly inside a PreToolUse callback. The handler belongs to this hook instance; it does not use the process-wide callback. The question includes the exact tool name and arguments. Only the exact answer "Approve" permits execution; missing answers, cancellation, and handler errors cannot approve a call.

Here is an executable dry run with a host that declines the request:

decline <- function(questions, context) {
  setNames(list("Deny"), questions[[1]]$question)
}
gate <- approval_gate(callback = decline)
S7::prop(gate, "callback")(
  "write_file", list(path = "report.txt", content = "Draft"),
  context = list(run_id = "example-run")
)$permission
#> [1] "deny"

Register approval hooks before all other matching hooks, including audit and Stop hooks. The registry returns the first non-NULL result, so an earlier allow or audit result can bypass a later hook. These recipes return NULL after approval, for unarmed gates, and after recording receipts so later hooks still run and can deny the call.

Attach a fresh hook to an Agent whose permissions already allow the relevant operation. Hooks can narrow permissions; approval cannot widen that ceiling. In an interactive R terminal, omit callback to use the normal user prompt. In a hosted application, supply your own synchronous human-input handler. This recipe is not a durable pause/resume mechanism.

agent$add_hook(approval_gate(callback = my_approval_handler))

Approval after an earlier installation

Hook context supplies run_id, not a mutable history of earlier calls. approval_after_install() accumulates successful installation receipts in its own environment, keyed by that run ID. Its PreToolUse callback reads that state before push_changes; Stop removes it. Create a fresh set per Agent. The callbacks use timeout = 0 so they share caller-process state.

This recipe assumes dedicated install_dependency and push_changes tools. The install tool must return list(installed = TRUE) only after success. A failed install does not arm the gate. Matching arbitrary shell text would not reliably identify these operations.

hooks <- approval_after_install(callback = decline)
run <- list(run_id = "installation-run")
S7::prop(hooks[[1]], "callback")(
  "install_dependency", list(installed = TRUE), NULL, run
)
#> NULL
S7::prop(hooks[[2]], "callback")("push_changes", list(remote = "origin"), run)$permission
#> [1] "deny"
# A different run has no installation receipt.
S7::prop(hooks[[2]], "callback")(
  "push_changes", list(remote = "origin"), list(run_id = "another-run")
)
#> NULL
S7::prop(hooks[[3]], "callback")("end_turn", run)
#> NULL

Attach all three hooks together:

for (hook in approval_after_install(callback = my_approval_handler)) {
  agent$add_hook(hook)
}

The example returns NULL before an installation because it demonstrates that particular sequence policy. Use the unconditional gate if every push needs approval. Neither recipe grants permission or performs an installation or a push by itself.