Skip to contents

deputy is an agentic AI framework for R that builds on ellmer. It enables you to create AI agents that use tools to accomplish multi-step tasks, with built-in support for permissions, hooks, and streaming output.

Installation

# Install from GitHub
pak::pak("JamesHWade/deputy")

Quick Start

An agent wraps an ellmer chat object and gives it tools, permissions, and lifecycle hooks:

library(deputy)

chat <- ellmer::chat_anthropic(model = "claude-sonnet-4-20250514")
agent <- Agent$new(chat = chat, tools = tools_file())

result <- agent$run_sync("List the files in the current directory")
cat(result$response)

The agent sends your task to the LLM, which may call tools, and loops until the task is complete. run_sync() blocks until done and returns an AgentResult.

Tool Bundles

deputy organises built-in tools into bundles you can mix and match:

Bundle Tools Purpose
tools_file() read_file, write_file, list_files File operations
tools_code() run_r_code, run_bash Code execution
tools_data() read_csv, read_file Data reading
tools_web() web_fetch, web_search Web access
tools_all() All of the above Everything
# Combine bundles
agent <- Agent$new(
  chat = ellmer::chat_anthropic(),
  tools = c(tools_file(), tools_code())
)

There are also named presets available via tools_preset():

See vignette("tools") for custom tools, web tools, MCP integration, and human-in-the-loop.

Streaming

For real-time feedback, use run() which returns a generator:

chat <- ellmer::chat_anthropic(model = "claude-sonnet-4-20250514")
agent <- Agent$new(chat = chat, tools = tools_file())

for (event in agent$run("What is the name of this package?")) {
  switch(event$type,
    "text" = cat(event$text),
    "tool_start" = cli::cli_alert_info("Calling {event$tool_name}..."),
    "tool_end" = cli::cli_alert_success("Done"),
    "stop" = cli::cli_alert("Finished!")
  )
}

Events stream as they happen – you see text tokens arrive, tool calls start and finish, and a final stop event.

Permissions

Read-Only Permissions

# Only allows reading files - no writes, no code execution
agent <- Agent$new(
  chat = ellmer::chat("openai"),
  tools = tools_file(),
  permissions = permissions_readonly()
)

Full Permissions

# Allows everything - use with caution!
agent <- Agent$new(
  chat = ellmer::chat("openai"),
  tools = tools_all(),
  permissions = permissions_full()
)

Custom Permissions

For fine-grained control:

perms <- Permissions$new(
  file_read = TRUE,
  file_write = "/path/to/allowed/dir", # Restrict to specific directory

  bash = FALSE,
  r_code = TRUE,
  web = FALSE,
  max_turns = 10,
  max_cost_usd = 0.50
)

agent <- Agent$new(
  chat = ellmer::chat("openai"),
  permissions = perms
)

Custom Permission Callbacks

For complex permission logic:

perms <- Permissions$new(
  can_use_tool = function(tool_name, tool_input, context) {
    # Block any file writes to sensitive directories
    if (tool_name == "write_file") {
      if (grepl("^\\.env|secrets|credentials", tool_input$path)) {
        return(PermissionResultDeny(
          reason = "Cannot write to sensitive files"
        ))
      }
    }
    PermissionResultAllow()
  }
)

Tool Allow/Deny Lists (Claude SDK-style)

Use allow/deny lists when you want an explicit tool policy that is separate from the broader mode flags:

perms <- Permissions$new(
  mode = "default",
  tool_allowlist = c("read_file", "list_files", "run_r_code"),
  tool_denylist = c("run_bash"),
  permission_prompt_tool_name = "AskUserQuestion"
)

agent <- Agent$new(
  chat = ellmer::chat("openai"),
  tools = tools_all(),
  permissions = perms
)

If both lists are present, tool_denylist wins. The permission_prompt_tool_name tool is always allowed and appears in deny reasons so the model can request approval.

Applying Tool Policy from .claude/settings.json

setting_sources now maps Claude-style tool policy keys directly into permissions:

agent <- Agent$new(
  chat = ellmer::chat("openai"),
  tools = tools_all(),
  setting_sources = "project"
)

Example .claude/settings.json:

{
  "allowedTools": ["read_file", "list_files", "run_r_code"],
  "disallowedTools": ["run_bash"],
  "permissionPromptToolName": "AskUserQuestion"
}

Hooks

Hooks let you intercept and customize agent behavior at key points:

Available Hook Events

Event When it Fires Can Modify
PreToolUse Before a tool executes Allow/deny, input
PostToolUse After a tool executes Continue flag
Stop When agent stops -
UserPromptSubmit When user sends a message -
PreCompact Before conversation compaction Summary

Example: Logging All Tool Calls

agent$add_hook(HookMatcher$new(
  event = "PostToolUse",
  callback = function(tool_name, tool_result, context) {
    cli::cli_alert_info("Tool {tool_name} completed")
    HookResultPostToolUse()
  }
))

Example: Block Dangerous Commands

agent$add_hook(HookMatcher$new(
  event = "PreToolUse",
  pattern = "^run_bash$", # Only match bash tool
  callback = function(tool_name, tool_input, context) {
    if (grepl("rm -rf|sudo|chmod 777", tool_input$command)) {
      HookResultPreToolUse(
        permission = "deny",
        reason = "Dangerous command pattern detected"
      )
    } else {
      HookResultPreToolUse(permission = "allow")
    }
  }
))

Session Management

Save and restore agent sessions:

# Save the current session
agent$save_session("my_session.rds")

# Later, restore it
agent2 <- Agent$new(chat = ellmer::chat("openai"))
agent2$load_session("my_session.rds")

# Continue the conversation
result <- agent2$run_sync("Continue where we left off...")

Multi-Agent Systems

For complex tasks, you can create a lead agent that delegates to specialized sub-agents:

# Define specialized sub-agents
code_agent <- agent_definition(
  name = "code_analyst",
  description = "Analyzes R code and suggests improvements",
  prompt = "You are an expert R programmer. Analyze code for best practices.",
  tools = tools_file()
)

data_agent <- agent_definition(
  name = "data_analyst",
  description = "Analyzes data files and provides statistical summaries",
  prompt = "You are a data analyst. Provide clear statistical insights.",
  tools = tools_data()
)

# Create a lead agent that can delegate
lead <- LeadAgent$new(
  chat = ellmer::chat("openai"),
  sub_agents = list(code_agent, data_agent),
  system_prompt = "You coordinate between specialized agents to complete tasks."
)

# The lead agent will automatically delegate to sub-agents as needed
result <- lead$run_sync(
  "Review the R code in src/ and analyze the data in data/"
)

Working with Results

The AgentResult object contains useful information:

result <- agent$run_sync("Analyze this project")

# The final response
cat(result$response)

# Cost information
result$cost
#> $input
#> [1] 1250
#> $output
#> [1] 450
#> $total
#> [1] 0.0045

# Execution duration
result$duration
#> [1] 3.45  # seconds

# Stop reason
result$stop_reason
#> [1] "complete"

# All events (for detailed analysis)
length(result$events)
#> [1] 12

Provider Support

deputy works with any LLM provider that ellmer supports:

# OpenAI
agent <- Agent$new(chat = ellmer::chat("openai"))

# Anthropic
agent <- Agent$new(chat = ellmer::chat("anthropic/claude-sonnet-4-5-20250929"))

# Google
agent <- Agent$new(chat = ellmer::chat("google/gemini-1.5-pro"))

# Local models via Ollama
agent <- Agent$new(chat = ellmer::chat("ollama/llama3.1"))

Best Practices

  1. Start with minimal permissions - Use permissions_readonly() or permissions_standard() and only expand as needed.

  2. Use hooks for logging - Add a PostToolUse hook to track what your agent does.

  3. Set cost limits - Use max_cost_usd in permissions to prevent runaway costs.

  4. Save sessions - For long-running tasks, save sessions periodically.

  5. Use streaming for UX - The run() method provides real-time feedback.

# Example combining best practices
agent <- Agent$new(
  chat = ellmer::chat("openai"),
  tools = tools_file(),
  permissions = Permissions$new(
    file_write = getwd(),
    max_turns = 20,
    max_cost_usd = 1.00
  )
)

Next Steps