Letting the model write and run R code is the most flexible tool you can give an agent, and the most dangerous. Deputy offers four ways to do it, which differ in two respects: whether R state survives between calls, and whether the code is confined by an OS sandbox.
| Option | State between calls | Confinement |
|---|---|---|
tool_run_r_code |
none; each call starts a new R process | your user account’s access |
RSession |
variables, packages and plots persist | your user account’s access |
mcp-repl, through mcp_repl_connection() or
tools_mcp_repl()
|
persists | OS sandbox |
MCP Console, through mcp_console_connection()
|
persists; R, Python and SQL share data | OS sandbox |
Deputy calls the first two trusted-code tools. A separate process protects your R session from crashes and runaway loops, but the code can still read, write and send anything your account can. Use them when you trust the model, the prompt and every input the agent reads, for example your own analysis on your own data. Otherwise use a sandbox.
The trusted-code tools don’t pass on your environment variables, and
R there doesn’t read .Renviron. The code sees the variables
that locate programs, libraries, locales and temporary files, such as
PATH, HOME, LANG and
TMPDIR, but API keys in your environment aren’t handed to
it. Name any others it needs with env, for example
RSession$new(agent, env = c("HTTPS_PROXY", "NO_PROXY")),
tools_code(env = "HTTPS_PROXY") or
tools_preset("dev", env = ...). Proxy settings match in
either case. env = "inherit" passes everything, keys
included.
This keeps keys out of what the code is given, not out of its reach.
Code running as your account can still read your R session’s starting
environment through the operating system, and any file your account can
read, .Renviron included. The site
Renviron.site still applies, and so does a project
.Rprofile for tool_run_r_code. To keep keys
from the code, run the agent under an account that can’t read them, or
use a sandbox.
One-off R code
tool_run_r_code runs each snippet in a fresh R process
started with callr, with a 30-second timeout, and returns the printed
output and result. Enable it with r_code = TRUE:
library(deputy)
agent <- Agent$new(
chat = ellmer::chat("openai/gpt-6-luna"),
tools = c(tools_data(), list(tool_run_r_code)),
permissions = Permissions(r_code = TRUE, file_write = FALSE)
)
agent$run_sync("How many rows of mtcars have more than 4 cylinders?")tool_run_bash does the same for shell commands and needs
bash = TRUE. Nothing carries over between calls, so the
model has to reload data each time.
A persistent R session
RSession gives one agent its own R worker process.
Variables and loaded packages persist between calls, so the model can
load data once and work with it over many steps. Plots come back to the
model as images.
agent <- Agent$new(
chat = ellmer::chat("openai/gpt-6-luna"),
permissions = Permissions(r_code = TRUE, file_write = FALSE)
)
worker <- RSession$new(agent)
agent$register_tools(worker$tools())
agent$run_sync("Load mtcars, fit mpg ~ wt, and plot the residuals.")
agent$run_sync("Now add hp to the model. Did the fit improve?")
worker$close()The worker starts on the first call. Calls run one at a time in
order, each with a 30-second timeout by default, and each starts in the
agent’s working directory. $cancel(), or a timeout, stops
the worker and discards its variables; the next call starts a fresh
worker and says so. Call $close() when the conversation
ends, for example from session$onSessionEnded() in
Shiny.
A saved conversation keeps the code, output and plots, but not the worker’s variables. After a restart the model has to recreate them.
The worker loads packages from your session’s
.libPaths(). To give it a library of its own, for example
one the model installs packages into, name the directories in order with
libpath:
RSession$new(agent, libpath = c(my_lib, .libPaths())). Your
own session’s search path doesn’t change.
Static plots from base graphics, ggplot2, grid and patchwork are captured. htmlwidgets and other interactive output are reported as unsupported.
Call your tools from R code
An R session can let the model’s code call some of the agent’s other tools, so it can fetch data with a tool and then analyse the result in R:
worker <- RSession$new(agent, tools = "fetch_measurements")
agent$register_tools(worker$tools())Inside the session, the code calls
tools$fetch_measurements(...) and gets the tool’s return
value as an ordinary R object. Each of these calls goes through the
agent’s permissions, hooks and usage limits like any other tool call.
The selected tools must be registered with convert = FALSE
and check their own arguments, and they can’t pause for a durable
approval.
Sandboxed R with mcp-repl
mcp-repl runs an
R interpreter as an MCP server inside an OS sandbox, so model-written
code can only read and write what the sandbox allows. Configure the
server in your mcptools config with an explicit --sandbox
mode:
{
"mcpServers": {
"r": {
"command": "/absolute/path/to/mcp-repl",
"args": ["--sandbox", "workspace-write", "--interpreter", "r"]
}
}
}Then connect an agent to its own REPL:
agent <- Agent$new(
chat = ellmer::chat("openai/gpt-6-luna"),
permissions = Permissions(web = FALSE)
)
repl <- mcp_repl_connection(
config = "~/.config/mcptools/config.json",
agent = agent,
server = "r",
sandbox = "workspace-write"
)
agent$register_tools(repl$tools())
# ...
repl$close()Deputy checks that the server’s final --sandbox argument
matches the mode you ask for, and refuses to start otherwise. It accepts
"read-only" and "workspace-write"; a
configuration that leaves the mode out or uses any other mode is
rejected. mcp-repl enforces the sandbox and refuses to start on a system
where it can’t. Its sandbox
documentation describes what each mode allows on each platform.
Each connection has its own REPL and belongs to one agent. Long
computations return a “busy” result after about three seconds; the model
then polls with an empty call to collect the output.
mcp_repl_control(repl, "interrupt") interrupts the running
code, and mcp_repl_control(repl, "reset") starts a fresh
interpreter. Both return promises; check the result to see whether the
REPL accepted the request.
tools_mcp_repl() loads the same tools without a
connection object, for simple scripts:
agent$register_tools(tools_mcp_repl(server = "r", sandbox = "workspace-write"))Deputy works with mcptools 1.0.2 and 1.0.3 and refuses other versions until they have been tested. It has been tested with mcp-repl 0.3.0.
R, Python and SQL with MCP Console
MCP Console
is a sandboxed workbench that grew out of mcp-repl. Its single
send tool runs R, Python and DuckDB SQL cells in one
worker, so a data frame made in R can be queried in SQL and read from
Python in the same conversation.
agent <- Agent$new(
chat = ellmer::chat("openai/gpt-6-luna"),
permissions = Permissions(bash = TRUE, web = TRUE)
)
console <- mcp_console_connection(
agent,
command = Sys.getenv("DEPUTY_MCP_CONSOLE_BIN"),
dependencies = "allow"
)
agent$register_tools(console$tools())
# ...
console$close()Install MCP Console 0.0.4 yourself (for example with
uv tool install mcp-console==0.0.4) and pass the path to
the executable. Deputy checks the version before starting it.
In its sandbox, code can read files but can’t reach the network, and
it can write only to private temporary storage and to paths you allow.
Deputy keeps it that way: it refuses options that would turn the sandbox
off, widen file access, add a proxy, or run the code somewhere else. It
accepts --writable-root PATH and the
-c extends=:workspace (write in the agent’s working
directory), -c extends=:read-only and
-c sandbox.network=restricted presets. A project config
file, .agents/console/config.yaml, can change the sandbox
policy, so Deputy won’t start when one exists unless you have reviewed
it and pass project_config = TRUE. After starting, Deputy
checks the sandbox the server reports and disconnects unless it is the
native sandbox with restricted networking.
send runs code, so Deputy treats it like a shell
command: the agent needs bash = TRUE, plus
web = TRUE because the server doesn’t annotate its tools.
Read-only and plan modes deny it.
MCP Console can also install the packages that code asks for, and
that happens outside the sandbox. It does this when a call lists
requirements, and also on its own when code loads a missing
package. Since Deputy can’t switch the second case off, the default
dependencies = "deny" refuses to connect to a server that
offers installation. With dependencies = "allow", a call
that lists requirements also needs
install_packages = TRUE in the agent’s permissions.
Installers use their usual caches in your home directory; set variables
such as UV_CACHE_DIR through env to move
them.
Calls wait up to 2.5 seconds. Longer work keeps running and returns
[running; poll with an empty send], and the model collects
the output with an empty send.
mcp_console_control(console, "interrupt") interrupts a cell
and keeps the session’s state;
mcp_console_control(console, "restart") discards all R,
Python and SQL state. If the restarted worker isn’t ready within 2.5
seconds, Deputy closes the connection and you need to start a new
one.
MCP Console records every call, with its output and plots, under
.agents/console/sessions/ in the agent’s working directory.
Recordings aren’t redacted and aren’t deleted automatically;
console$status()$execution gives the path.