Skip to contents

Tools let a model call R functions: read a file, fetch a page, or run a calculation. Choose built-in tools or register functions with ellmer::tool(). Permissions determine which calls can run.

Reusable Skill Configuration

A Skill bundles instructions, tools, and declared requirements in a read-only S7 value. Construct it with Skill(...) or skill_create(...), and inspect requirements with skill_check_requirements(skill). Skill() defaults to version "0.0.0"; skill_create() retains its "1.0.0" default.

library(deputy)
concise <- skill_create(
  "concise",
  prompt = "Answer in one short paragraph.",
  requires = list(packages = "base")
)
skill_check_requirements(concise)$ok
#> [1] TRUE
fields <- S7::props(concise)
fields$prompt <- "Answer in one sentence."
shorter <- do.call(Skill, fields)
shorter$prompt
#> [1] "Answer in one sentence."
concise$prompt
#> [1] "Answer in one short paragraph."

Read properties with $, @, or S7::prop(). Configuration is frozen, including the declared requirements and initially NULL fields. Tools keep their original closures, clients, and caller-owned state when a Skill is constructed, revised, or loaded into an Agent.

agent$load_skill(skill) checks package and provider requirements, registers tools under the Agent’s existing permissions, and appends the prompt. Its agent$skills() getter returns a list of read-only Skill values. Checking requirements does not install packages or execute tools. skill_load(path) remains the explicit file-loading entry point and can source tool files declared by the skill. AgentDefinition YAML attaches approved skills through a host registry; it does not source those files.

Built-in Tools

deputy includes the following tools out of the box:

Tool Description Annotations
tool_read_file Read a file’s contents read-only
tool_read_markdown Convert a supported document to markdown read-only
tool_write_file Write or append to a file destructive
tool_edit_file Apply one targeted file edit destructive
tool_multi_edit Apply multiple file edits destructive
tool_list_files List files in a directory read-only
tool_glob_files Find files by glob pattern read-only
tool_grep_files Search file contents read-only
tool_run_r_code Execute trusted R code as the current user destructive, open-world
tool_run_bash Execute trusted shell code as the current user destructive, open-world
tool_read_csv Read and summarise a CSV read-only
tool_web_fetch Fetch a web page read-only, open-world
tool_web_search Search the web read-only, open-world
tool_ask_user Ask structured questions read-only

Each tool carries annotations that the permission system uses to decide whether the agent is allowed to use it. See vignette("permissions") for details.

Tool Bundles

Bundles group related tools together:

library(deputy)

tools_file()  # read_file, read_markdown, write_file, list_files
tools_code()  # run_r_code, run_bash
tools_data()  # read_csv, read_file, read_markdown
tools_web()   # universal web_fetch and web_search
tools_all()   # everything

Registration is not authorization: tools_code() makes the two trusted-code tools available to the model, while the Agent’s Permissions still decides whether either call may run. permissions_standard() denies both.

Combine bundles by concatenating:

tools <- c(tools_file(), tools_code())
agent <- Agent$new(chat = ellmer::chat_openai(model = "gpt-5.6-luna"), tools = tools)

Tool Presets

Presets are named collections with opinionated defaults:

tools_preset("minimal")
tools_preset("standard")
tools_preset("dev")
tools_preset("data")
tools_preset("full")
Preset Included capabilities
minimal file and document reads
standard reads plus native file writes; no code execution
data data reads plus trusted R execution
dev native files plus trusted R and shell execution
full every built-in tool

Create a tool from an R function

A tool wraps an R function with a name, a description, and argument types that the model can use. Start with a function you can call directly. This example wraps toupper(); a package function or your own function works the same way.

library(deputy)
uppercase <- ellmer::tool(
  base::toupper,
  name = "uppercase",
  description = "Uppercase local text.",
  arguments = list(x = ellmer::type_string()),
  annotations = ellmer::tool_annotations(
    read_only_hint = TRUE,
    destructive_hint = FALSE,
    open_world_hint = FALSE,
    idempotent_hint = TRUE
  )
)
uppercase("hello")
#> [1] "HELLO"

Register the tool when constructing the agent:

agent <- Agent$new(
  chat = ellmer::chat("openai/gpt-5.6-luna"),
  tools = list(uppercase),
  permissions = Permissions(file_write = FALSE)
)
result <- agent$run_sync("Use uppercase to convert 'hello' to capitals.")
result$response

The annotations describe the function’s effects. Here the tool changes no files, uses no network, and returns the same answer for the same input. Deputy uses these declarations when checking permissions; it does not inspect the function body to verify them.

Annotation Meaning when TRUE
read_only_hint Does not change state
destructive_hint May delete or overwrite data
open_world_hint Accesses external systems
idempotent_hint Repeated calls have the same effect

Replace or inspect registered tools

Use agent$register_tool(tool) to add a tool to an existing agent, or agent$register_tool(tool, replace = TRUE) to replace a tool with the same name.

Existing names are errors unless replace = TRUE is explicit. Repeated names inside one batch always fail, including with replacement enabled. List names do not rename a tool. set_tools() replaces the whole user registry. Both methods and construction validate and adapt the complete batch before changing the backend registry; a malformed later tool leaves earlier tools untouched. Skills use load_skill(..., allow_conflicts = TRUE) for explicit replacement.

Missing annotations stay absent on the source object. For custom tools, permissions assume possible modification, destruction, and external access, and no idempotence. An explicit read-only annotation makes an omitted destructive annotation irrelevant. A local read tool therefore needs at least read_only_hint = TRUE and open_world_hint = FALSE under standard permissions. Readonly mode additionally requires an explicit allowlist entry for custom tools. Annotations do not grant authority or verify a service’s trustworthiness. See PermissionMode for callback and mode behavior.

Inspect the supplied fields and defaults without executing the tool:

tool_metadata(uppercase)
#> $name
#> [1] "uppercase"
#> 
#> $source
#> $source$type
#> [1] "package"
#> 
#> $source$package
#> [1] "base"
#> 
#> 
#> $annotations
#> $annotations$read_only_hint
#> [1] TRUE
#> 
#> $annotations$open_world_hint
#> [1] FALSE
#> 
#> $annotations$idempotent_hint
#> [1] TRUE
#> 
#> $annotations$destructive_hint
#> [1] FALSE
#> 
#> 
#> $missing_annotations
#> character(0)
#> 
#> $effective_annotations
#> $effective_annotations$read_only_hint
#> [1] TRUE
#> 
#> $effective_annotations$destructive_hint
#> [1] FALSE
#> 
#> $effective_annotations$idempotent_hint
#> [1] TRUE
#> 
#> $effective_annotations$open_world_hint
#> [1] FALSE

Web Tools

deputy provides web tools that are provider-aware. Pass the chat to tools_web() to select provider-native web tools when available. Calling it without a chat returns Deputy’s universal web tools:

chat <- ellmer::chat_anthropic()
agent <- Agent$new(
  chat = chat,
  tools = tools_web(chat),
  permissions = Permissions(
    web = TRUE,
    tool_allowlist = c("web_search", "web_fetch")
  )
)

Provider-native tools execute outside R, so Deputy authorizes known native web search and fetch tools before registering them. The policy must grant web access and explicitly allow the tool; static denylists are also enforced at registration. A custom can_use_tool callback rejects provider-native tools because Deputy cannot supply request arguments or run context after provider execution begins. Other provider-native tool types are rejected. Use the universal function tools when authorization must inspect each request. If set_permission_mode() later narrows away web access, Deputy removes registered provider-native web tools before the new policy becomes active.

For providers without native support, deputy falls back to tool_web_fetch (fetches a URL and extracts text) and tool_web_search (searches via DuckDuckGo).

MCP Integration

deputy can load tools from Model Context Protocol servers via the mcptools package:

# Load tools from selected configured servers
agent <- Agent$new(
  chat = ellmer::chat_anthropic(),
  tools = c(tools_file(), tools_mcp(servers = "github"))
)

tools_mcp() returns an empty list with a warning when mcptools is not installed or no tools are available. MCP servers are configured in ~/.config/mcptools/config.json; see the mcptools documentation for setup.

Server metadata and reconnection

For service tools, tools_mcp(config, servers = "evidence") selects an exact configured server name before connecting. The qualified mcptools 1.0.2 bridge retains annotations from the server and records source$type = "mcp", its server name, and tool name. It reports missing metadata rather than inventing safe effects. Other mcptools versions fail explicitly pending qualification. The caller must trust the configured service; metadata does not establish trust.

Pass these objects to Agent$new(tools = ...), register_tools(), or an explicit tool registry used by agent_definition_read(). The same metadata survives YAML selection, Agent wrapping, cloning, and delegation. Permission callbacks receive it as context$tool_metadata, alongside the supplied context$tool_annotations. A service called read_file still uses its service annotations and capabilities; its name does not confer native file access. Local file checkpointing excludes MCP calls, so rewinding files never treats a remote service write as a change to the local workspace.

Reconnecting a server invalidates tools from its old connection. Reload and replace explicitly with agent$load_mcp(config, servers = "evidence", replace = TRUE). This refresh removes obsolete tools from the selected servers, including when discovery succeeds with an empty tool set. Unrelated tools remain registered. A load failure records a failed attempt in mcp_status(). Tools that still have working connections remain registered. If mcptools already closed an old connection, its invalidated handles are removed even when discovery or registration fails. Malformed metadata never silently becomes an unannotated executable.

Connections owned by a conversation

Use McpConnection when a host needs independent connections, asynchronous calls, or resource and prompt access. Create the Agent first, then allow exact items from one configured server:

agent <- Agent$new(
  chat = ellmer::chat("openai/gpt-5.6-luna"),
  permissions = Permissions(web = TRUE)
)
connection <- McpConnection$new(
  config = "~/.config/mcptools/config.json",
  server = "evidence",
  agent = agent,
  tools = "inspect_evidence",
  resources = "evidence://report/current",
  prompts = "summarize"
)
agent$register_tools(c(connection$tools(), connection$capability_tools()))

When one Agent uses multiple connections, choose distinct capability prefixes, such as first$capability_tools(prefix = "papers") and second$capability_tools(prefix = "notes"). These produce names such as papers_read_resource and notes_read_resource without changing the allowlists. Connections with no allowed tools can use resource-only or prompt-only servers; they do not request a tool catalogue during initialization.

The allowlists are fixed. $discover("resources") returns a promise for one catalogue page; its result may include a cursor for the next page. Discovery does not fetch resource contents, retrieve prompts, register tools or expand the Agent’s permissions. Resource and prompt tools still require the Agent’s web capability. The host can use $get_prompt(name, arguments) for prompts that require arguments; retrieving a prompt never inserts it into a Chat.

Each connection has its own mcptools registry. Two conversations can therefore use the same configured server name without replacing each other’s connection. Tools carry the connection ID into permission callbacks and hooks. They reject registration with a different Agent, session or run context. The host assigns those identities and remains responsible for authentication.

Calls return promises and permit one active request per connection. Overlapping requests fail with a busy error; other connections and the host event loop can continue. Construction waits for startup. Call $close() when the conversation ends, for example from a Shiny session’s end callback. $cancel() terminates the connection and discards its server session state. A timeout does the same; old tool handles never start a replacement connection implicitly.

This is a temporary, version-checked mcptools 1.0.2 adapter. It uses upstream transport, authentication, tool conversion and shutdown with a small internal request bridge. Public replacements are requested in mcptools #129, #130, and #109.

Sandboxed R with mcp-repl

Use tools_mcp_repl() when model-generated R needs an OS sandbox rather than the current user’s authority:

repl_tools <- tools_mcp_repl(
  config = "~/.config/mcptools/config.json",
  server = "r",
  sandbox = "workspace-write"
)

agent <- Agent$new(
  chat = ellmer::chat("openai/gpt-5.6-luna"),
  tools = repl_tools,
  permissions = Permissions(web = FALSE)
)

The helper isolates the named server from the rest of the MCP configuration and checks its exact final --sandbox argument. Missing policies and modes that do not establish a Deputy-verifiable boundary are errors. mcp-repl then owns OS-specific confinement, including fail-closed startup on unsupported hosts. See vignette("permissions") for the complete trust model.

For a persistent session owned by a particular Agent, use mcp_repl_connection():

agent <- Agent$new(
  chat = ellmer::chat("openai/gpt-5.6-luna"),
  permissions = Permissions(web = FALSE)
)
connection <- mcp_repl_connection(
  config = "~/.config/mcptools/config.json",
  agent = agent,
  server = "r",
  sandbox = "workspace-write"
)
agent$register_tools(connection$tools())

Each connection starts a separate upstream REPL, even when its configured server name matches another conversation’s. Registering its tools with an unrelated Agent fails. The connection ID and selected sandbox appear in tool metadata for permission callbacks and hooks. The host must call connection$close() when the conversation ends.

After mcp-repl returns a busy-interpreter response, the host can request an interrupt with mcp_repl_control(connection, "interrupt"). To discard interpreter state and request a fresh session, use mcp_repl_control(connection, "reset"). Both return promises for the upstream result; inspect that result before assuming the control succeeded. An interpreter can restart inside the same MCP connection, so the connection ID alone does not prove its state survived.

An active client request cannot accept an overlapping control request. connection$cancel() or interruption of the owning Agent terminates that connection and discards its state. mcp-repl’s timeout_ms can instead return control while interpreter work continues; the client’s timeout closes the connection when the MCP request itself takes too long.

Plots retain ellmer image content. Oversized transcripts retain mcp-repl’s bounded previews and artifact references, and ordinary Deputy result offloading still applies when configured. These paths are upstream-owned session artifacts; the host must preserve anything it needs durably before closing the session. Deputy does not create another spill store or automatically read every linked artifact.

The tested producer combination is mcptools 1.0.2 with mcp-repl 0.3.0 on macOS. The executable name does not establish its version. Install the qualified runtime explicitly; unsupported host sandboxing remains an upstream startup error. Put the sandbox mode in --sandbox; a --config sandbox_mode=... override is rejected so it cannot change the policy Deputy checked.

Human-in-the-Loop

The tool_ask_user tool lets an agent ask the user a question during execution. This is useful for confirmations, clarifications, or collecting input:

agent_id <- "agent-analysis"
session_id <- "session-analysis"

agent <- Agent$new(
  chat = ellmer::chat_openai(model = "gpt-5.6-luna"),
  tools = c(
    tools_file(),
    tools_interactive(
      callback = function(questions, context) {
        # Route a modal to this Agent's host session and collect its answers.
        collect_answers(questions, route = context$session_id)
      },
      context = list(agent_id = agent_id, session_id = session_id)
    )
  ),
  agent_id = agent_id,
  session_id = session_id
)

tools_interactive() creates a fresh tool_ask_user instance. Its handler and routing context belong to that tool, so concurrent Agents cannot overwrite one another. The context may also be a zero-argument function when routing values must be resolved for each request. Omit the callback in an interactive console to use readline().

set_ask_user_callback() remains a process-wide compatibility fallback for single-Agent scripts. Do not use it to route Shiny or other concurrent hosts.