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
#> NULLcontinue 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)
#> NULLAttach 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.