This example shows how to use deputy with shinychat to build an interactive chat application with permissions, hooks, and tool call limits.
The Problem
shinychat’s chat_append() expects an ellmer content
stream from chat$stream_async(). Deputy’s
run() yields AgentEvent objects, while
run_sync() returns an AgentResult. Calling
agent$chat$stream_async() directly works but bypasses
Deputy’s permissions, hooks, and run limits from
UsageLimits().
run_shiny() bridges this gap: it returns a content
stream that shinychat understands while still enforcing deputy’s
permissions, hooks, and limits.
run_shiny() is deliberately strict about file tools:
every supplied file path must be absolute and resolve within the Agent’s
immutable working_dir. Relative paths, omitted path
defaults, and paths outside that root are rejected. Bind a normalized
workspace and include its exact path in the model’s system prompt, as
both examples below do.
Following shinychat’s recommended pattern, pass that stream directly
to chat_append(). chat_append() returns the
completion promise, so make it the last expression in your observer if
you want shinychat to surface streaming errors in the chat UI.
What run_shiny() Enforces
| Feature | Enforced? | How |
|---|---|---|
| Permissions (file_read, bash, etc.) | Yes |
on_tool_request callback |
| PreToolUse / PostToolUse hooks | Yes |
on_tool_request / on_tool_result
callbacks |
| File path confinement | Yes | Every recognized file path must be absolute and inside
working_dir
|
| Tool call limit | Yes | Checked at each tool request; an overage cancels the active stream |
| Request, token, and cost limits | Yes | Checked when usage is observed; an overage cancels the active stream |
| SessionStart / SessionEnd hooks | Yes | Fired before/after the stream |
| Stall detection | No | Requires deputy’s own loop |
output_format (structured output) |
No | Requires deputy’s own loop |
Sub-Agents Inside a Streaming Chat: run_async()
run_shiny() is for the agent that owns the chat UI. When
a Deputy Agent is instead a worker – for example a delegated
sub-agent invoked from an ellmer tool of a chat that is already
streaming – use run_async(). It drives the same
callback-enforced loop but returns a promise that resolves to an
AgentResult, so the tool body can await it
without blocking other Shiny sessions:
scout <- Agent$new(
chat = chat$clone()$set_turns(list()),
system_prompt = "You are a literature scout. Report sources with DOIs.",
tools = tools_web(),
usage_limits = UsageLimits(max_requests = 8, max_tool_calls = 12)
)
tool_scout <- ellmer::tool(
fun = coro::async(function(prompt) {
result <- coro::await(scout$run_async(prompt))
paste0(result$response, "\n\n(stopped: ", result$stop_reason, ")")
}),
name = "agent_scout",
description = "Delegate a literature search to the scout sub-agent.",
arguments = list(prompt = ellmer::type_string("Self-contained task"))
)
chat$register_tool(tool_scout)run_async() enforces everything in the table above
except file path confinement, which is a run_shiny() rule:
worker agents resolve relative paths against their
working_dir like run() does. Pass
UsageLimits(on_exceed = "error") to have the promise
rejected on a limit instead of resolving with a typed
stop_reason.
Basic Setup
library(shiny)
library(deputy)
library(shinychat)
workspace <- normalizePath(getwd(), mustWork = TRUE, winslash = "/")
ui <- bslib::page_fluid(
chat_ui("chat", fill = TRUE)
)
server <- function(input, output, session) {
chat <- ellmer::chat_openai(
model = "gpt-4o-mini",
system_prompt = paste(
"You are a helpful assistant. Be concise.",
sprintf(
"For every file tool, use an absolute path inside: %s",
workspace
)
)
)
agent <- Agent$new(
chat = chat,
tools = tools_file(),
permissions = permissions_standard(workspace),
working_dir = workspace
)
observeEvent(input$chat_user_input, {
req(input$chat_user_input)
chat_append(
"chat",
agent$run_shiny(input$chat_user_input)
)
})
}
shinyApp(ui, server)With Permissions and Hooks
Deputy’s permissions and hooks fire on every tool call, even though shinychat drives the streaming loop:
workspace <- normalizePath(getwd(), mustWork = TRUE, winslash = "/")
server <- function(input, output, session) {
chat <- ellmer::chat_anthropic(
model = "claude-sonnet-4-5-20250929",
system_prompt = paste(
"You are a data analyst. Be concise.",
sprintf(
"For every file tool, use an absolute path inside: %s",
workspace
)
)
)
agent <- Agent$new(
chat = chat,
tools = c(tools_file(), tools_data()),
permissions = Permissions$new(
file_read = TRUE,
file_write = FALSE,
r_code = FALSE,
bash = FALSE
),
usage_limits = UsageLimits(max_requests = 10, max_cost_usd = 0.50),
working_dir = workspace
)
# Hooks still fire normally
agent$add_hook(hook_log_tools(verbose = TRUE))
observeEvent(input$chat_user_input, {
req(input$chat_user_input)
# max_tool_calls limits how many tool calls the agent can make
chat_append(
"chat",
agent$run_shiny(
input$chat_user_input,
max_tool_calls = 10
)
)
})
}How Limits Work
run_shiny() starts with the Agent’s run limits and lets
max_tool_calls override that one field. When Deputy
observes a request, tool, token, or cost overage, it marks the run
stopped and asks ellmer’s stream controller to cancel active generation.
An overage found at a tool boundary also rejects that tool call.
Stop and SessionEnd hooks receive the
corresponding limit reason.
Provider token and cost usage is not observable until a response arrives. The initial response can therefore cross one of those limits before cancellation; it may overshoot the configured threshold by one response. Cancellation stops further work but cannot retract content already emitted. Treat token and cost limits as observed stop conditions, not provider-side generation ceilings.
The max_tool_calls parameter counts individual tool call
requests, not model requests. One model request can include multiple
parallel tool calls (for example, reading three files at once), each
counting separately.
Running the Example
A complete example app is included in the package:
shiny::runApp(system.file("examples/shiny-chat", package = "deputy"))