Skip to contents

An agent works anywhere shinychat expects an ellmer chat. Pass it to chat_server() and the app streams the model’s replies and tool calls, while Deputy applies the agent’s permissions, hooks and limits to every message.

A minimal app

library(shiny)
library(shinychat)
library(deputy)

workspace <- normalizePath(getwd(), winslash = "/", mustWork = TRUE)

ui <- bslib::page_fluid(
  chat_ui("chat", fill = TRUE, allow_attachments = TRUE)
)

server <- function(input, output, session) {
  agent <- Agent$new(
    chat = ellmer::chat(
      "openai/gpt-6-luna",
      system_prompt = "You are a concise data assistant."
    ),
    tools = tools_data(),
    permissions = permissions_readonly(),
    usage_limits = UsageLimits(max_requests = 10, max_tool_calls = 12),
    working_dir = workspace
  )

  chat_server("chat", agent)
}

shinyApp(ui, server)

Create the agent inside server(), so that each browser session gets its own conversation, usage and hooks. shinychat calls the agent’s stream_async() method, passing text and attachments through unchanged, and handles the input box, cancellation and conversation history. After each reply, agent$last_run() returns the run’s AgentResult.

The limits apply to each message the user sends. The agent also keeps its context policy: long conversations are compacted automatically, and large tool results are stored outside the model’s context.

Tell users when older messages are summarised

When compaction runs, the model sees a summary instead of the oldest messages, but the chat still shows the whole conversation. The example app in inst/examples/shiny-chat/ shows a small notice above the chat while that happens (“Summarizing earlier messages…”) and afterwards (“Earlier messages summarized for the assistant. Your full conversation is still available.”), with a “View summary” link that shows the summary text.

The notice is driven by hooks: PreCompact sets it to busy, PostCompact records the summary, and Stop clears the busy state if a run fails or is cancelled. If you call agent$compact() yourself, clear the notice when that call returns, because a manual compaction doesn’t fire Stop. The notice belongs to the page, not the conversation: Deputy never adds messages to the chat to report compaction.

shinychat’s conversation history, where available, stores the whole conversation (agent$get_turns()), so restoring a conversation brings back every message while the model continues from the summary.

Hooks in an app

Hooks are added to the agent as usual, inside server():

agent$add_hook(hook_log_tools())

agent$add_hook(HookMatcher(
  event = "PostCompact",
  callback = function(result, context) {
    showNotification("Earlier messages were summarised.")
    NULL
  }
))

When the user cancels a reply, the run stops at the next point where ellmer can stop it, and Deputy still fires the Stop and SessionEnd hooks and saves any file checkpoints.

To ask the user questions from a tool, give each session its own handler with tools_interactive(); see Human input and approvals.

Long-running tools

Delegating to a subagent doesn’t block the app: LeadAgent runs subagents with run_async(), so other sessions keep working while the lead waits. Your own tools can do the same by returning a promise:

ask_scout <- ellmer::tool(
  coro::async(function(prompt) {
    result <- coro::await(scout$run_async(prompt))
    result$response
  }),
  name = "ask_scout",
  description = "Ask the literature scout to search for papers.",
  arguments = list(prompt = ellmer::type_string("A self-contained task"))
)
agent$register_tool(ask_scout)

Here scout is another Agent. A tool that calls another agent like this should also have annotations, like any other tool; see Tools.

Run the example

The package includes the app with the compaction notice:

shiny::runApp(system.file("examples", "shiny-chat", package = "deputy"))

For apps that show subagent conversations beside the main chat, see Briefing and inspecting subagents.