Skip to contents

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.

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, {
    stream <- agent$run_shiny(input$chat_user_input)
    chat_append("chat", stream)
  })
}

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, {
    # max_tool_calls limits how many tool calls the agent can make
    stream <- agent$run_shiny(
      input$chat_user_input,
      max_tool_calls = 10
    )
    chat_append("chat", stream)
  })
}

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"))