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] FALSEEvents
| 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:
-
PreToolUseandPermissionRequest: before the tool runs. -
PostToolUseandPostToolUseFailure: before the next model request. The tool’s result is already recorded when the hook answers. -
PostModelRequest,SessionStart,UserPromptSubmit,SubagentStart,SubagentStop, andPreCompactandPostCompactduring automatic compaction: before the run goes on. -
StopandSessionEnd: before the run finishes. While aStophook waits, the agent is still running, so it refuses a new run until the hook answers. -
NotificationandConfigChange, 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.