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() and run_sync() methods return
AgentEvent objects instead. Calling
agent$chat$stream_async() directly works but bypasses
deputy’s turn-level controls like max_turns and
max_cost_usd.
run_shiny() bridges this gap: it returns a content
stream that shinychat understands while still enforcing deputy’s
permissions, hooks, and limits.
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 |
| Tool call limit | Yes | Counter in on_tool_request, rejects with graceful
message |
Cost limit (max_cost_usd) |
Yes | Checked in on_tool_request
|
| 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 |
Basic Setup
library(shiny)
library(deputy)
library(shinychat)
ui <- bslib::page_fluid(
chat_ui("chat", fill = TRUE)
)
server <- function(input, output, session) {
chat <- ellmer::chat_openai(
model = "gpt-4o-mini",
system_prompt = "You are a helpful assistant. Be concise."
)
agent <- Agent$new(
chat = chat,
tools = tools_file()
)
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:
server <- function(input, output, session) {
chat <- ellmer::chat_anthropic(
model = "claude-sonnet-4-5-20250929",
system_prompt = "You are a data analyst. Be concise."
)
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,
max_cost_usd = 0.50
)
)
# 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
When a tool call limit or cost limit is reached, deputy calls
ellmer::tool_reject() with a message asking the LLM to wrap
up. The LLM receives this as a tool error and generates a final
response. The user sees a complete, coherent message rather than a
truncated stream.
The max_tool_calls parameter counts individual tool call
requests, not LLM turns. One turn can include multiple parallel tool
calls (e.g., the LLM reads three files at once), each counting
separately. This is more precise than turn counting for controlling
resource usage.
Running the Example
A complete example app is included in the package:
shiny::runApp(system.file("examples/shiny-chat", package = "deputy"))