library(ellmer)
read_file_tool <- tool(
function(path) paste(readLines(path, warn = FALSE), collapse = "\n"),
"Read the full contents of a text file and return it as a string.",
path = type_string("Relative path to the file to read")
)
write_file_tool <- tool(
function(path, content) {
writeLines(content, path)
paste("Wrote", nchar(content), "characters to", path)
},
"Write content to a file, creating or overwriting it.",
path = type_string("Relative path to the file to write"),
content = type_string("The full content to write to the file")
)
run_command_tool <- tool(
function(command) {
paste(system(command, intern = TRUE), collapse = "\n")
},
"Run a shell command in the current working directory and return its output.",
command = type_string("The shell command to run, e.g. 'Rscript myscript.R'")
)39 Make an agent: simple read-and-write agent
By now the word “agent” has come up a lot — Copilot’s agent mode, Cline, Claude Code. It’s tempting to imagine something mysterious going on inside them. It isn’t. As Hadley Wickham put it in a recent post, “A coding agent is six functions in a trenchcoat”: strip away the marketing and a coding agent is a handful of plain functions — read a file, write a file, run a shell command — wired up to an LLM that gets to call them in a loop. Everything else (the fancy chat UI, the permission prompts, the diff viewer) is scaffolding around that simple core.
We already saw the general shape of this loop back in Section 2: the model responds, your software inspects the response, runs whatever tool was requested, and feeds the result back — round and round until the model decides it’s done. Let’s build the smallest possible version of that ourselves, in R, using ellmer’s tool-calling support.
39.1 Three tools
That’s the whole toolbox. Nothing here is agent-specific — they’re just ordinary R functions wrapped with ellmer::tool(), which describes each one and its arguments so the LLM knows when and how to call them.
39.2 Wiring them to a chat
strong_model <- "anthropic/claude-sonnet-4"
chat <- chat_openrouter(
model = strong_model,
system_prompt = "You are a careful coding agent. You have tools to read files,
write files, and run shell commands in the current project directory. Use
Rscript to run R scripts, e.g. `Rscript 'my-script.R'`. Explain what you did
after each tool use."
)
chat$register_tool(read_file_tool)
chat$register_tool(write_file_tool)
chat$register_tool(run_command_tool)
chat$chat("Read fish-coral-cover-sites.csv, then write a one-paragraph summary
of its columns to a new file called summary.md")This is the trick worth noticing: chat$chat() already runs the loop for you. Internally, ellmer sends your prompt, checks whether the model’s response is a tool call, executes it if so, sends the result back as part of the conversation, and repeats — automatically — until the model responds with plain text instead of a tool call. You don’t write the while loop yourself; ellmer’s tool support is the loop. That’s really all Cline, Copilot’s agent mode, or Claude Code are doing under the hood, just with many more tools, a permission-prompt UI wrapped around each call, and a much more elaborate system prompt (see resources/DIY-stats-bot-system.md in this repo for a full example of a system prompt).
39.3 Making it safer
Our toy agent above will happily run any shell command it’s asked to, including ones that touch the network or delete files. Real agents guard this with permission prompts before every tool call (which is why Copilot and Claude Code ask you to confirm each step) or with an allow/deny list of specific commands. If you wanted to harden this toy version, the cheapest fix is to check command inside run_command_tool before executing it — for instance, refusing to run anything containing rm, curl, or git push.
Add a fourth tool, list_files, that returns the files in the current directory (list.files()). Register it with your chat, then ask the agent to “List the files here, then read whichever one looks like it contains the coral and fish survey data.” See whether it picks fish-coral-cover-sites.csv correctly.