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() # everythingRegistration 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$responseThe 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] FALSEWeb 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.