install.packages('ellmer')
# or the dev version
::pak('tidyverse/ellmer') pak
29 gander
gander
goes beyond ellmer
and chores
by extending the use of ellmer
models to,
βtalk[ing] to the objects in your R environment.β1
The previous LLM package weβve covered required background information and conditions to be provided via prompts. gander
is able to βpeek aroundβ in our current R environment to provide framing and context.
29.1 Configuration
First weβll install the ellmer
and gander
packages:
install.packages('gander')
# or the dev version
::pak("simonpcouch/gander") pak
Configure the model in your .Renviron
and .Rprofile
files (an API key is required):
# configure ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.
::edit_r_environ() usethis
# configure .gander_chat
::edit_r_profile()
usethisoptions(
.gander_chat = ellmer::chat_anthropic()
)
29.1.1 Keyboard shortcut
Configure the keyboard shortcut following the instructions on the package website (RStudio or Positron
).
I use the recommended Ctrl+Cmd+G (or Ctrl+Alt+G on Windows).
29.3 Developing with gander
To develop with gander
interactively, highlight the code youβd like to send to the model and use the keyboard shortcut:
The gander
addin includes a text box and a select input with Prefix, Replace, or Suffix.
29.3.1 Package functions
Lets assume weβve made the decision to change the reactable
tables to use the gt
package. Weβll start by converting a single reactable
table output (in R/mod_counts_tbl.R
) to use a gt
table.
After loading the new gt
table function, we can test the code by launching our app:
We can see the initial response throws an error, but weβll use this as a opportunity to explore how the gander
addin works.
29.4 Context
We can view the background/context passed to the model using gander::peek()
. After giving us the model, turns, tokens and cost, the results are separated into system
, user
, and assistant
.
<Chat Anthropic/claude-3-7-sonnet-latest turns=3 tokens=1178/336 $0.01>
29.4.1 System
The system
portion tells us how each response is framed and provides, βadditional instructions to the model, shaping its responses to your needs.β4
ββ system [0] ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
You are a helpful but terse R data scientist. Respond only with valid R code:
no exposition, no backticks. Always provide a minimal solution and refrain
from unnecessary additions. Use tidyverse style and, when relevant, tidyverse
packages. For example, when asked to plot something, use ggplot2, or when
asked to transform data, using dplyr and/or tidyr unless explicitly instructed otherwise.
I like to think of system prompts as an assistantβs βpersonality,β because it affects every response.
29.4.2 User
The user prompt section gives the model information on the R environment, file contents, etc. In this example we can see it provided the entire module (UI and server functions) and roxygen2
documentation.
Note the βUp to this point, the contents of my r file reads: β and βNow, convert the reactable output to a gt table: β delineation.
ββ user [1178] βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Up to this point, the contents of my r file reads:
\`\`\`r
#' UI for counts table module
#'
#' Creates a reactive table displaying count data. This function is designed
#' to work together with [mod_counts_tbl_server()].
#'
#' @param id A character string used to identify the namespace for the module.
#'
#' @return A `tagList` containing UI elements:
#' * A reactive table output that displays the count data
#'
#' @seealso [mod_counts_tbl_server()] for the server-side logic
#'
#' @examples
#' # UI implementation
#' ui <- fluidPage(
#' mod_counts_tbl_ui("counts1")
#' )
#'
#' # Server implementation
#' server <- function(input, output, session) {
#' mod_counts_tbl_server("counts1", counts_data = reactive(data))
#' }
#'
mod_counts_tbl_ui <- function(id) {
ns <- NS(id)
tagList(
reactable::reactableOutput(
outputId = ns("counts_table")
)
)
}
#' Server function for the count table module
#'
#' Creates a reactive table showing movies based on selected filters. This
#' function is designed to work together with a corresponding UI function.
#'
#' @param id A character string used to identify the namespace for the module.
#' @param vals A reactive expression that returns a list containing at least:
#' * `start_year`: numeric value for the earliest year to include
#' * `end_year`: numeric value for the latest year to include
#' * `chr_var`: symbol representing the variable to display alongside title
#'
#' @return Creates the following reactive elements within the module's namespace:
#' * `counts_table`: A reactive Reactable table with three columns:
#' - Title: The movie title
#' - The selected character variable from `vals()$chr_var`
#' - Thtr Rel Year: The theatrical release year
#'
#' The table includes styling with a dark background, white text, and features
#' such as highlighting, striping, and compact display.
#'
#' @details
#' The function filters the global `movies` dataset based on the year range
#' provided in `vals()`. Column names are normalized using the `name_case()`
#' function before displaying.
#'
#' @seealso [mod_counts_tbl_()] The corresponding UI function for this module
#'
#' @examples
#' # Server implementation
#' server <- function(input, output, session) {
#' # Create a reactive values list
#' selected_vals <- reactive({
#' list(
#' start_year = 2000,
#' end_year = 2010,
#' chr_var = sym("Critics Rating")
#' )
#' })
#'
#' # Call the module server
#' mod_counts_tbl_server("movie_table", selected_vals)
#' }
#'
\`\`\`
Now, convert the reactable output to a gt table:
\`\`\`r
mod_counts_tbl_server <- function(id, vals) {
moduleServer(id, function(input, output, session) {
output$counts_table <- reactable::renderReactable({
req(vals())
# subset
count_data <- subset(
movies,
thtr_rel_year >= vals()$start_year &
thtr_rel_year <= vals()$end_year
)
# Convert selected variable to snake_case
group_var <- name_case(as.character(vals()$chr_var), case = "lower")
# Count per group using tidy evaluation
tbl_data <- count_data |>
dplyr::group_by(.data[[group_var]]) |>
dplyr::summarise(n = dplyr::n(), .groups = "drop") |>
dplyr::arrange(dplyr::desc(n))
# Normalize column names in the data
names(tbl_data) <- name_case(names(tbl_data))
# reactable with dynamic row styling
reactable::reactable(tbl_data,
defaultPageSize = 25,
borderless = TRUE,
highlight = TRUE,
striped = TRUE,
compact = TRUE,
style = list(
backgroundColor = "#121212",
color = "#ffffff"
)
)
})
})
} \`\`\`
29.4.3 Assistant
Finally, the assistant
is the response from the model (given the system
and user
prompts).
ββ assistant [336] βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
mod_counts_tbl_server <- function(id, vals) {
moduleServer(id, function(input, output, session) {
output$counts_table <- gt::render_gt({
req(vals())
# subset
count_data <- subset(
movies,
thtr_rel_year >= vals()$start_year &
thtr_rel_year <= vals()$end_year
)
# Convert selected variable to snake_case
group_var <- name_case(as.character(vals()$chr_var), case = "lower")
# Count per group using tidy evaluation
tbl_data <- count_data |>
dplyr::group_by(.data[[group_var]]) |>
dplyr::summarise(n = dplyr::n(), .groups = "drop") |>
dplyr::arrange(dplyr::desc(n))
# Normalize column names in the data
names(tbl_data) <- name_case(names(tbl_data))
# gt table with dark theme styling
gt::gt(tbl_data) %>%
gt::tab_options(
table.background.color = "#121212",
column.labels.background.color = "#1e1e1e",
table.font.color = "#ffffff",
column.labels.font.color = "#ffffff",
table.border.top.style = "hidden",
table.border.bottom.style = "hidden"
) %>%
gt::opt_row_striping()
})
}) }
29.4.4 In practice
The context gander
provides is enough information to 1) ensure the response is aware of the corresponding UI function, and 2) notice weβre developing an R package (i.e., use explicit namespacing with pkg::fun()
).
The errors weβre encountering are due to incorrect arguments passed to gt::tab_options()
:
::tab_options(
gttable.background.color = "#121212",
column.labels.background.color = "#1e1e1e",
table.font.color = "#ffffff",
column.labels.font.color = "#ffffff",
table.border.top.style = "hidden",
table.border.bottom.style = "hidden"
)
- 1
-
Should be
column_labels.background.color
- 2
-
Doesnβt exist (uses
table.font.color
)
After making the changes to the server-side gt
table code, we need to remember to update the corresponding UI code with the gt
rendering function (recall that we didnβt specify any changes to this function in the prompt).
After updating the UI module function and loading the changes, we can see the Counts table below:
29.5 Graph adjustments
Iβve found the gander
addin to be incredibly helpful for visualization development in Shiny modules. In the Compare tab, we display a plotly
scatter plot. Iβve asked gander
to convert the plotly
code in the module server function into a utility function (R/compare_plot.R
):
Prompt: βConvert the plotly code inside renderPlotly() into a separate utility function (compare_plot()).β
This resulted in the utility function below:
show/hide R/compare_plot.R
#' Create an Interactive Scatter Plot for Comparing Variables
#'
#' This function creates an interactive scatter plot using plotly to compare two
#' variables with points colored by a third variable.
#'
#' @param data A data frame containing the variables to plot
#' @param x Character string with the name of the variable to plot on the x-axis
#' @param y Character string with the name of the variable to plot on the y-axis
#' @param color Character string with the name of the variable to use for
#' coloring points
#' @param size Numeric value for the size of the points (default: 5)
#' @param alpha Numeric value for the transparency of the points (default: 0.7)
#' @param title Character string for the plot title. If NULL, a default title is
#' generated
#'
#' @return a plotly interactive scatter plot object
#'
#' @examples
#' require(webshot)
#' compare_plot(movies,
#' "imdb_rating", "critics_score", "genre",
#' size = 7, alpha = 0.75, "Plot Title")
#'
#' @export
#'
<- function(data, x, y, color, size = 5, alpha = 0.7, title = NULL) {
compare_plot # title if not provided
if (is.null(title)) {
<- paste(name_case(x), "vs.", name_case(y))
title
}
# plot
<- plotly::plot_ly(
plot data = data,
x = ~get(x),
y = ~get(y),
color = ~get(color),
text = ~title,
type = "scatter",
mode = "markers",
colors = clr_pal3,
marker = list(
size = size,
opacity = alpha
)|>
) # title
::layout(
plotlytitle = list(
text = title,
font = list(color = "#e0e0e0")
),# x-label
xaxis = list(
title = name_case(x),
titlefont = list(color = "#e0e0e0"),
tickfont = list(color = "#e0e0e0")
),# y-label
yaxis = list(
title = name_case(y),
titlefont = list(color = "#e0e0e0"),
tickfont = list(color = "#e0e0e0")
),# legend
legend = list(
font = list(color = "#e0e0e0")
),# background color
plot_bgcolor = "#121212",
paper_bgcolor = "#121212"
)return(plot)
}
Placing the graph code in a separate utility function can make it easier to debug/write tests. Writing graph examples in the roxygen2
documentation also makes it possible to view minor changes to the graph without dealing with the reactivity inside the application:
I also noticed the text size was too small in the graphs on the Distribution tabs, so I also asked gander
to make the font size in the graph adjust with the display:
Prompt: βMake the font in the ggplot2 graphs adjust to the screen sizeβ
gander
only returned a few changes to the ggplot2
code, but these small changes represent a lot saved time because there are so many ggplot2::theme()
arguments (and they usually take multiple iterations to get correct):
::theme(
ggplot2strip.text = ggplot2::element_text(
color = "#ffffff",
size = ggplot2::rel(1.05)
),axis.text = ggplot2::element_text(
color = "#ffffff",
size = ggplot2::rel(1.075)
),axis.title = ggplot2::element_text(
color = "#ffffff",
size = ggplot2::rel(1.15)
) )
Graph changes like these can take up a lot of time if weβre reading through package documentation (or Stack Overflow/Posit Community). But gander
can handle tasks like this quickly, although a foundational understanding of plotly
and ggplot2
makes it easier to debug any errors the generated code produces.
29.6 Downloadable reports
I wanted to stretch the functionality of gander
, so I asked the chatbot to create a downloadable report module. This required a series of prompts to get working, but I think it serves as a great example of using the gander
addin to iterate through a solution/feature.
First, I created the report template file (inst/rmd/report_template.Rmd
) with the following YAML:
---
title: "Movie Analysis Report"
date: "May 29, 2025"
output:
html_document:
toc: true
toc_depth: 2
theme: united
params:
compare_values: NA
count_values: NA
dist_values: NA
award_values: NA
---
The params
in the report will be passed from the new R/mod_report.R
module, which we can ask gander
to generate by highlighting the contents of appβs server function (nav_server()
) and entering the following prompt:
Prompt: Create a module for downloading an R Markdown HTML report using the
_values
as parameters.
The resulting module is below:
show/hide mod_report module
#' Report download UI
#'
#' @param id Module ID
#'
#' @return UI elements for downloading report
#'
#' @export
<- function(id) {
mod_report_ui <- NS(id)
ns tagList(
downloadButton(ns("download_report"), "Download Report")
)
}#' Generate and download R Markdown report
#'
#' @param id Module ID
#' @param scatter_values Scatter plot reactive values
#' @param count_values Count data reactive values
#' @param dist_values Distribution reactive values
#' @param award_values Awards reactive values
#'
#' @return None
#'
#' @export
<- function(id, scatter_values, count_values, dist_values, award_values) {
mod_report_server moduleServer(id, function(input, output, session) {
$download_report <- downloadHandler(
outputfilename = function() {
paste("report-", Sys.Date(), ".html", sep = "")
},content = function(file) {
# create a temporary directory for report generation
<- tempdir()
tempDir <- file.path(tempDir, "report.Rmd")
tempReport
# copy the report template to the temp directory
file.copy(system.file("rmd", "report_template.Rmd", package = "sap"),
overwrite = TRUE)
tempReport,
# set up parameters to pass to Rmd
<- list(
params compare_values = scatter_values(),
count_values = count_values(),
dist_values = dist_values(),
award_values = award_values()
)
# render the report
::render(
rmarkdowninput = tempReport,
output_file = file,
params = params,
envir = new.env(parent = globalenv()))
}
)
}) }
As we can see, the new module conforms to our naming convention and follows the guidance from the official generating reports documentation (i.e., it has a downloadHandler()
with filename
and content
, and passes the params
to rmarkdown::render()
).
After implementing this module in the UI and the server, I can see the params
have been passed to the .Rmd
file by printing itβs structure:
To convert the symbols to characters in params
, I created an empty .R
script (R/desym.R
), pasted and highlighted the params
structure output from the report, and asked gander
to write a utility function:
Prompt: Write a
desym
function that will convert the symbols to characters inparams
.
show/hide desym() utility function
#' Convert symbols to character
#'
#' @param params_list
#'
#' @returns list of params with character values
#'
#' @export
#'
<- function(params_list) {
desym <- function(x) {
convert_symbol if (is.symbol(x)) {
return(as.character(x))
else if (is.list(x)) {
} return(lapply(x, convert_symbol))
else {
} return(x)
}
}lapply(params_list, convert_symbol)
}
Weβll add the desym()
function to the mod_report_server()
function to ensure only character values are passed to the report.
show/hide mod_report_server() with dsym()
# Set up parameters to pass to Rmd
<- list(
params compare_values = scatter_values(),
count_values = count_values(),
dist_values = dist_values(),
award_values = award_values()
)# remove the symbols
<- desym(params)
clean_params
# Render the report
::render(
rmarkdowninput = tempReport,
output_file = file,
params = clean_params,
envir = new.env(parent = globalenv()))
Now we have parameter values we can use with our new custom plotting function (compare_plot()
). We can include the following in a R code block in inst/report_template.Rmd
:
show/hide compare_plot() in inst/report_template.Rmd
tryCatch({
# extract variable names
<- name_case(params$compare_values$x, case = "lower")
x_var <- name_case(params$compare_values$y, case = "lower")
y_var <- name_case(params$compare_values$color, case = "lower")
color_var # create data
<- sap::movies
compare_data # create plot
compare_plot(data = compare_data,
x = x_var,
y = y_var,
color = color_var,
size = params$compare_values$size,
alpha = params$compare_values$alpha,
title = params$compare_values$title)
},error = function(e) {
cat("\n\n**Error creating plot:**\n\n", e$message)
} )
- 1
-
Convert these to
lower_snake_case
to match the names inmovies
- 2
-
Can pass the original
params
values unchanged.
Our downloaded HTML report contains the following:
I stopped developing the downloadable report here, but as you can see, the gander
addin is incredibly helpful for editing modules, developing utility functions, and creating content for our downloadable R markdown report.
Recap
gander
is the third LLM R package weβve covered by developer Simon Couch (we also coveredensure
in Section 16.1 andchores
in Chapter 28). If youβre interested in staying up on LLMs and their use in R package development, I highly recommend regularly checking his blog.β©οΈThis application uses the
page_navbar
layout frombslib
. Review itβs structure in Section 28.1.β©οΈSee the Section 28.4.1 and previous Section 13.3 sections.β©οΈ
Read more about this in the What is a prompt section? of the
ellmer
documentation.β©οΈ