Shiny Integration: Interactive Tool Approval
Source:vignettes/articles/shiny-tool-approval.Rmd
shiny-tool-approval.RmdThis article shows how to let users approve or deny Claude’s tool calls interactively from a Shiny app — for example, asking “Do you want Claude to run this Bash command?” before execution.
Architecture: Message-Driven Approval
The recommended approach uses message-driven
approval via PermissionRequestMessage. When
permission_prompt_tool_name = "stdio" is set and no
synchronous can_use_tool callback is configured, every
can_use_tool control request from the CLI is surfaced as a
PermissionRequestMessage in the normal message stream.
The flow:
CLI sends can_use_tool
↓
SDK yields PermissionRequestMessage (request_id, tool_name, tool_input)
↓
Shiny shows modal dialog (tool name + JSON input)
↓
User clicks Allow → client$approve_tool(request_id)
or Deny → client$deny_tool(request_id, reason)
↓
SDK sends control_response → CLI resumes or aborts the tool call
The CLI blocks indefinitely while waiting — no timeout. The streaming loop continues polling during this time, so other messages (text deltas from the response before the tool call) are still delivered.
Why message-driven over on_tool_request callback?
An earlier approach used an on_tool_request callback
with a resolve closure. The message-driven pattern is
preferred because:
-
Interrupt works during approval: the same
interrupt_flagcheck runs at the top of every loop iteration, so pressing ESC while the modal is open sendsdeny_tooland theninterrupt(). -
No extra state machine: approval state
(
pending_id) is a plainreactiveVal, updated from both the streaming coroutine and the button observers.
Complete App
library(shiny)
library(bslib)
library(shinychat)
library(ClaudeAgentSDK)
library(promises)
library(coro)
ui <- page_fillable(
theme = bs_theme(bootswatch = "flatly"),
tags$script(HTML("
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape')
Shiny.setInputValue('esc', Math.random(), {priority: 'event'});
});
")),
card(
card_header(
div(
class = "d-flex justify-content-between align-items-center",
span("Claude — Tool Approval"),
actionButton("interrupt_btn", "Interrupt", class = "btn-warning btn-sm")
)
),
chat_ui("chat", fill = TRUE,
placeholder = "Try: 'Read the file /dev/null'")
)
)
server <- function(input, output, session) {
client <- ClaudeSDKClient$new(ClaudeAgentOptions(
max_turns = 5L,
# Required: routes can_use_tool requests through the message stream
permission_prompt_tool_name = "stdio",
include_partial_messages = TRUE
))
client$connect()
onStop(function() client$disconnect())
interrupt_flag <- reactiveVal(FALSE)
pending_id <- reactiveVal(NULL) # request_id of the open modal, if any
# ---- Approval buttons -------------------------------------------------------
observeEvent(input$tool_allow, {
rid <- pending_id()
if (!is.null(rid)) {
pending_id(NULL)
removeModal()
client$approve_tool(rid)
}
})
observeEvent(input$tool_deny, {
rid <- pending_id()
if (!is.null(rid)) {
pending_id(NULL)
removeModal()
client$deny_tool(rid, "Denied by user")
}
})
# ---- Streaming coroutine ----------------------------------------------------
do_stream <- coro::async(function(client, interrupt_flag, pending_id, session) {
chunk_started <- FALSE
interrupted <- FALSE
repeat {
# Check interrupt at the top of every iteration
if (!interrupted && shiny::isolate(interrupt_flag())) {
interrupted <- TRUE
# If a modal is open, deny the pending tool before interrupting
rid <- shiny::isolate(pending_id())
if (!is.null(rid)) {
pending_id(NULL)
removeModal(session = session)
tryCatch(client$deny_tool(rid, "Interrupted"), error = function(e) NULL)
}
tryCatch(client$interrupt(), error = function(e) NULL)
if (chunk_started) {
chat_append_message("chat",
list(role = "assistant", content = "\n\n_[Interrupted]_"),
chunk = "end", session = session)
chunk_started <- FALSE
} else {
chat_append_message("chat",
list(role = "assistant", content = "_[Interrupted]_"),
chunk = FALSE, session = session)
}
}
msgs <- tryCatch(client$poll_messages(), error = function(e) list())
if (length(msgs) == 0L) {
await(promises::promise(function(resolve, reject) {
later::later(function() resolve(TRUE), 0.05)
}))
next
}
drain_done <- FALSE
for (msg in msgs) {
await(promises::promise_resolve(TRUE)) # yield between messages
# Drain mode after interrupt
if (interrupted) {
if (inherits(msg, "ResultMessage")) { drain_done <- TRUE; break }
next
}
# ---- Tool approval request ----------------------------------------
if (inherits(msg, "PermissionRequestMessage")) {
# Close any open streaming chunk before showing the modal
if (chunk_started) {
chat_append_message("chat",
list(role = "assistant", content = ""),
chunk = "end", session = session)
chunk_started <- FALSE
}
input_json <- jsonlite::toJSON(
msg$tool_input, auto_unbox = TRUE, pretty = TRUE
)
# Show the tool call in the chat history
chat_append_message("chat",
list(role = "assistant", content = paste0(
"\n\n**Tool request: `", msg$tool_name, "`**\n\n```json\n",
input_json, "\n```\n"
)),
chunk = FALSE, session = session)
# Store the request_id and open the approval modal
pending_id(msg$request_id)
showModal(modalDialog(
title = paste("Allow tool:", msg$tool_name),
tags$pre(input_json),
footer = tagList(
actionButton("tool_allow", "Allow", class = "btn-success"),
actionButton("tool_deny", "Deny", class = "btn-danger")
),
easyClose = FALSE
), session = session)
next # keep polling — modal is now open
}
# ---- Streaming text tokens ----------------------------------------
if (inherits(msg, "StreamEvent") && is.list(msg$event)) {
evt <- msg$event
if (identical(evt$type, "content_block_delta") &&
is.list(evt$delta) &&
identical(evt$delta$type, "text_delta") &&
!is.null(evt$delta$text)) {
if (!chunk_started) {
chunk_started <- TRUE
chat_append_message("chat",
list(role = "assistant", content = ""),
chunk = "start", session = session)
}
chat_append_message("chat",
list(role = "assistant", content = evt$delta$text),
chunk = TRUE, session = session)
}
}
# ---- Fallback: full AssistantMessage (non-streaming models) --------
if (inherits(msg, "AssistantMessage") && !chunk_started) {
for (blk in msg$content) {
if (inherits(blk, "TextBlock") && nzchar(blk$text)) {
chat_append_message("chat",
list(role = "assistant", content = blk$text),
chunk = FALSE, session = session)
}
}
}
# ---- Done ---------------------------------------------------------
if (inherits(msg, "ResultMessage")) {
if (chunk_started) {
chat_append_message("chat",
list(role = "assistant", content = ""),
chunk = "end", session = session)
}
return("done")
}
}
if (drain_done) break
}
"done"
})
stream_task <- ExtendedTask$new(function(user_input) {
client$send(user_input)
do_stream(client, interrupt_flag, pending_id, session)
})
observeEvent(input$chat_user_input, {
if (stream_task$status() == "running") return()
interrupt_flag(FALSE)
stream_task$invoke(input$chat_user_input)
})
observeEvent(input$esc, { if (stream_task$status() == "running") interrupt_flag(TRUE) })
observeEvent(input$interrupt_btn, { if (stream_task$status() == "running") interrupt_flag(TRUE) })
}
shinyApp(ui, server)Testing the App
Start the app and send a prompt that triggers tool use:
-
"Read the file /dev/null"— triggersReadtool -
"List files in /tmp"— triggersBashtool
A modal dialog appears showing the tool name and JSON input. Click Allow to let Claude proceed, or Deny to block it. Press ESC or the Interrupt button at any time to cancel the current operation.
Key API Reference
| Call | When to use |
|---|---|
client$approve_tool(request_id) |
User clicked Allow |
client$deny_tool(request_id, reason) |
User clicked Deny |
client$interrupt() |
User interrupted the whole operation |
PermissionRequestMessage$request_id |
ID to pass to approve/deny |
PermissionRequestMessage$tool_name |
Display to the user |
PermissionRequestMessage$tool_input |
Show as JSON in the modal |
Running the Example
shiny::runApp("examples/15_shinychat_tool_approval_msgdriven.R")