library(ellmer)
# > packageVersion("ellmer")
# [1] ‘0.3.0’
Introduction
ellmer is an R package designed to easily call LLM APIs from R. It supports a wide variety of model providers, including Google Gemini/Vertex AI, Anthropic Claude, OpenAI, and Ollama. Although there are resources on how to use ellmer, as of this writing, there is no explanation on how ellmer works.
This blog series will dive deep into the source code of ellmer, aiming to build an understanding of how ellmer interfaces with LLM APIs. We will use the Google Gemini API, which provides a generous free tier.
Note that the ellmer R package is currently at version 0.3.0
, indicating it is still in the early stages of development and subject to change.
Part 2 takes a deep dive into tool calling. Tool calling provides LLM with tools it can call. A tool is like a function and is defined by the function name, description, and a list of arguments. As Joe Cheng shares in his “Demystifying LLMs with Ellmer” talk, think of an LLM as a brain in a jar. It does not have any ability other than the questions you ask it. Tool calling equips this brain with the ability to interact with other systems.
According to the tool calling vignette in ellmer, this is how tool calling works:
The user asks the assistant a question, “What is the weather right now at Fenway Park?” along with a tool, which consists of a name of a function, a description of what it does, a list of arguments, and argument types. The assistant responds with a request to call the tool. Then, the user calls the tool, returns the result to the assistant, which uses it to generate the final answer.
Walkthrough of Tool-Calling
In the rest of this blog post, we will examine the tool calling functionality within the $chat()
method of the Chat
object. We will draw an example from the tool calling vignette in ellmer.
Setup
First, we initialize the chat
object: chat <- chat_openai()
. We will use OpenAI’s GPT 4.1 model. I will skip the details, as they were covered with great detail in Deep Dive into ellmer: Part 1.
# Arguments to chat_openai()
= NULL
system_prompt = Sys.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")
base_url = openai_key()
api_key = NULL
model = NULL
params = list()
api_args = character()
api_headers = "output"
echo
# Code inside chat_openai()
<- set_default(model, "gpt-4.1")
model <- check_echo(echo)
echo <- params %||% params()
params
<- ProviderOpenAI(
provider name = "OpenAI",
base_url = base_url,
model = model,
params = params,
extra_args = api_args,
extra_headers = api_headers,
api_key = api_key
)
# Initialize chat object
<- Chat$new(provider = provider, system_prompt = system_prompt, echo = echo)
chat
# Needed for later calls
<- list()
private $provider <- provider private
Then, we define a function to get the current time, get_current_time()
:
#' Gets the current time in the given time zone.
#'
#' @param tz The time zone to get the current time in.
#' @return The current time in the given time zone.
<- function(tz = "UTC") {
get_current_time format(Sys.time(), tz = tz, usetz = TRUE)
}
We turn the above function into a tool with tool()
:
<- tool(
get_current_time
get_current_time,name = "get_current_time",
description = "Returns the current time.",
arguments = list(
tz = type_string(
"Time zone to display the current time in. Defaults to `\"UTC\"`.",
required = FALSE
)
) )
Note that the tool calling vignette introduces a helper function that uses an LLM to generate this tool()
call, create_tool_def()
. This function may make mistakes, so the user must review the generated code. However, for more complicated functions, create_tool_def()
is definitely a time-saver.
The final setup is to give our chat
object access to our tool:
$tools[[get_current_time@name]] <- get_current_time private
$chat()
method
In this section, we walk through the source code that powers the $chat()
method (as it pertains to tool calling) with the prompt "How long ago did Neil Armstrong touch down on the moon?"
:
<- "How long ago did Neil Armstrong touch down on the moon?"
user_prompt
<- user_turn(user_prompt)
turn <- check_echo(echo %||% private$echo) echo
As shown in the diagram of ellmer’s chat implementation in Deep Dive into ellmer: Part 1, the chat implementation method $chat_impl()
leads to $submit_turns()
, which then leads to chat_perform()
.
To better understand the “whole game” of tool calling, I’ve created a diagram of tool calling functions in ellmer:
# Setup for chat_perform()
<- turn
user_turn <- FALSE
yield_as_content = NULL
type = TRUE
stream
<- chat_perform(
response provider = private$provider,
mode = if (stream) "stream" else "value",
turns = c(private$.turns, list(user_turn)),
tools = if (is.null(type)) private$tools,
type = type
)
Calling chat_perform()
results in a generator instance called response
. A generator instance is created by calling a generator function:
> response
<generator/instance>
function(provider, req) {
<- req_perform_connection(req)
resp on.exit(close(resp))
repeat {
<- chat_resp_stream(provider, resp)
event <- stream_parse(provider, event)
data if (is.null(data)) {
break
else {
} yield(data)
}
}
}<environment: namespace:ellmer>
In order to unpack the iterators within a generator, we can iterate manually with
::loop(for (x in response) {
coroprint(x)
})
Alternatively, we can collect all values of an iterator with coro::collect()
:
<- coro::collect(response) all_chunks
Then, we simplify the response with stream_merge_chunks()
:
<- NULL
result for (chunk in all_chunks) {
<- stream_merge_chunks(private$provider, result, chunk)
result }
We observe the streaming response data where the function name is get_current_time()
and the function argument is tz: "UTC"
:
1]]$delta$tool_calls[[1]]$`function`
[[1]]$delta$tool_calls[[1]]$`function`$name
[[1] "get_current_time"
[
1]]$delta$tool_calls[[1]]$`function`$arguments
[[1] "{\"tz\":\"UTC\"}" [
Then, we create a Turn
object for the assistant turn:
<- value_turn(private$provider, result, has_type = !is.null(type)) turn
And we insert our tool function (get_current_time()
) in the Turn
object:
<- match_tools(turn, private$tools) turn
So far, we have the following user_turn
(user turn) and turn
(assistant turn):
> user_turn
<Turn: user>
How long ago did Neil Armstrong touch down on the moon?> turn
<Turn: assistant>
request (call_0rAWkevHlh4ZXHdfQbob3Aw8)]: get_current_time(tz = "UTC") [tool
We add these turns to the Chat object:
# self$add_turn(user_turn, turn)
$.turns[[length(private$.turns) + 1]] <- user_turn
private$.turns[[length(private$.turns) + 1]] <- turn private
This concludes the private$submit_turns()
method. To recap, the user asked the assistant a question, How long ago did Neil Armstrong touch down on the moon?
and the assistant responded with a tool request, asking the user to invoke the tool function get_current_time(tz = "UTC")
.
Invoke tool
Next, we invoke the tool since the assistant turn has a tool request:
# assistant_turn <- self$last_turn()
<- turn
assistant_turn <- NULL
user_turn
$callback_on_tool_request <- CallbackManager$new(args = "request")
private$callback_on_tool_result <- CallbackManager$new(args = "result")
private
<- invoke_tools(
tool_calls
assistant_turn,echo = echo,
on_tool_request = private$callback_on_tool_request$invoke,
on_tool_result = private$callback_on_tool_result$invoke,
yield_request = yield_as_content
)
The invoke_tools()
function is a generator function that invokes all the tool requests in the assistant turn with the similar sounding invoke_tool()
function.
Let’s take a look at the invoke_tool()
function, which invokes an individual tool:
<- function(request) {
invoke_tool if (is.null(request@tool)) {
return(new_tool_result(request, error = "Unknown tool"))
}
<- tool_request_args(request)
args if (is_tool_result(args)) {
# Failed to convert the arguments
return(args)
}
tryCatch(
{<- do.call(request@tool, args)
result new_tool_result(request, result)
},error = function(e) {
new_tool_result(request, error = e)
}
) }
It takes a list of arguments for the tool function and uses do.call()
to execute a function call. Then, it stores the result and the original request in a ContentToolResult
object.
Tool calling in ellmer involves two S7 classes, ContentToolRequest
for tool requests and ContentToolResult
for tool results.
Properties of ContentToolRequest
:
id
: Tool call idname
: Name of tool functionarguments
: Arguments to call functiontool
: Tool metadata
Properties of ContentToolResult
:
value
: The result of calling tool functionerror
: Error message when calling tool function and it throws an errorextra
: Optional additional datarequest
: TheContentToolRequest
associated with tool result.
The request
, a ContentToolRequest
object, looks like:
> request
<ellmer::ContentToolRequest>
@ id : chr "[call_0rAWkevHlh4ZXHdfQbob3Aw8]"
@ name : chr "get_current_time"
@ arguments:List of 1
$ tz: chr "UTC"
.. @ tool : <ellmer::ToolDef> function (tz = "UTC")
@ name : chr "get_current_time"
.. @ description: chr "Returns the current time."
.. @ arguments : <ellmer::TypeObject>
.. @ description : NULL
.. .. @ required : logi TRUE
.. .. @ properties :List of 1
.. .. $ tz: <ellmer::TypeBasic>
.. .. .. @ description: chr "Time zone to display the current time in. Defaults to `\"UTC\"`."
.. .. .. ..@ required : logi FALSE
.. .. .. ..@ type : chr "string"
.. .. .. ..@ additional_properties: logi FALSE
.. .. @ convert : logi TRUE
.. @ annotations: list() ..
The result, a ContentToolResult
object, looks like:
<ellmer::ContentToolResult>
@ value : chr "2025-09-12 02:03:03 UTC"
@ error : NULL
@ extra : list()
@ request: <ellmer::ContentToolRequest>
@ id : chr "call_r2el9wHpfQEmvBFw9kTKQGMP"
.. @ name : chr "get_current_time"
.. @ arguments:List of 1
.. $ tz: chr "UTC"
.. .. @ tool : <ellmer::ToolDef> function (tz = "UTC")
.. @ name : chr "get_current_time"
.. .. @ description: chr "Returns the current time."
.. .. @ arguments : <ellmer::TypeObject>
.. .. @ description : NULL
.. .. .. @ required : logi TRUE
.. .. .. @ properties :List of 1
.. .. .. $ tz: <ellmer::TypeBasic>
.. .. .. .. @ description: chr "Time zone to display the current time in. Defaults to `\"UTC\"`."
.. .. .. .. ..@ required : logi FALSE
.. .. .. .. ..@ type : chr "string"
.. .. .. .. ..@ additional_properties: logi FALSE
.. .. .. @ convert : logi TRUE
.. .. @ annotations: list() .. ..
Finally, the output of invoke_tools()
is a generator instance and is created by calling a generator function.
We loop through tool_calls
and store our tool results in tool_results
:
<- list()
tool_results
::loop(for (tool_step in tool_calls) {
coroif (is_tool_result(tool_step)) {
<- c(tool_results, list(tool_step))
tool_results
} })
The output of tool_results
:
> tool_results
1]]
[[<ellmer::ContentToolResult>
@ value : chr "2025-09-05 03:37:59 UTC"
@ error : NULL
@ extra : list()
@ request: <ellmer::ContentToolRequest>
@ id : chr "call_0rAWkevHlh4ZXHdfQbob3Aw8"
.. @ name : chr "get_current_time"
.. @ arguments:List of 1
.. $ tz: chr "UTC"
.. .. @ tool : <ellmer::ToolDef> function (tz = "UTC")
.. @ name : chr "get_current_time"
.. .. @ description: chr "Returns the current time."
.. .. @ arguments : <ellmer::TypeObject>
.. .. @ description : NULL
.. .. .. @ required : logi TRUE
.. .. .. @ properties :List of 1
.. .. .. $ tz: <ellmer::TypeBasic>
.. .. .. .. @ description: chr "Time zone to display the current time in. Defaults to `\"UTC\"`."
.. .. .. .. ..@ required : logi FALSE
.. .. .. .. ..@ type : chr "string"
.. .. .. .. ..@ additional_properties: logi FALSE
.. .. .. @ convert : logi TRUE
.. .. @ annotations: list() .. ..
Next, we store the tool result in user_turn
(user turn):
<- tool_results_as_turn(tool_results) user_turn
Output of user_turn
shows the tool result:
<Turn: user>
result (call_0rAWkevHlh4ZXHdfQbob3Aw8)]: 2025-09-05 03:37:59 UTC [tool
After completion of the while
loop, we go back to the top of the loop. Since user_turn
is not NULL
, we run private$submit_turns()
again.
The content of the result
, which is the assistant’s response, is the following:
$choices[[1]]$delta$content
1] "Neil Armstrong touched down on the moon on July 20, 1969. As of today (September 6, 2025), it has been 56 years, 1 month, and 17 days since Neil Armstrong first set foot on the lunar surface." [
And we insert the result and provider into turn
, for assistant turn:
<- value_turn(private$provider, result, has_type = !is.null(type))
turn <- match_tools(turn, private$tools) turn
The assistant turn contains the response of the assistant when the user sent the tool result:
> turn
<Turn: assistant>
20, 1969. As of today (September 6, 2025), it has been 56 years, 1 month, and 17 days since Neil Armstrong first set foot on the lunar surface. Neil Armstrong touched down on the moon on July
We add these turns to the Chat object:
# self$add_turn(user_turn, turn)
$.turns[[length(private$.turns) + 1]] <- user_turn
private$.turns[[length(private$.turns) + 1]] <- turn private
This concludes private$submit_turns()
.
<- self$last_turn()
assistant_turn <- NULL # Important user_turn
Since turn_has_tool_request(assistant_turn)
is FALSE
, that completes the while
loop. We go back to the top of the while
loop, and exit out since user_turn
is NULL
. This concludes private$chat_impl()
, and hence completes the $chat()
method.
Conclusion
In this blog post, we took a closer look at tool calling in ellmer. We traced through the source code of the $chat()
method to understand the mechanics of tool calling. The tool calling workflow is as follows: user sends a question along with a tool to the assistant, the assistant responds with a tool request, the user calls the tool and then submits the tool result to the assistant, and finally the assistant responds with its final answer.
More on other capabilities of ellmer, such as structured data, in the next blog post.
Thanks for reading.