Skip to main content

AE 20: Tool calling + agents

Suggested answers

Application exercise
Answers
Modified

November 12, 2026

19_agent-1

library(ellmer)
library(here)

# Your agent works in `blockbuster/`, the records for the Last Blockbuster in
# Bend, Oregon. Open the folder and look around before you run this script.
project_dir <- here("19_agent-1", "blockbuster")
proj_path <- function(path) file.path(project_dir, path)

# STEP 1: Wrap up file reading and writing so the LLM can use them ------------

# The {brio} package gives us two great functions for reading and writing files:
#
#   * brio::read_file()
#   * brio::write_file()
#
# But we're not going to just give those directly to the LLM! We wrap the
# operations we trust it with into functions and hand those to it as tools.
# For each one, think through:
#
#   1. What inputs do we want from the LLM?
#
#   2. Fill in the function to take action for the LLM.
#
#   3. What text do we send back to the LLM?
#

read_file <- function(path) {
  path <- proj_path(path)
  # ____
}

write_file <- function(path, content) {
  path <- proj_path(path)
  # ____
}

# STEP 2: Document each function so the LLM knows when and how to use it ----
# Remember: the LLM sees only your descriptions, not the R code.
tool_read_file <- tool(
  read_file,
  description = "____",
  arguments = list(
    path = type_string("Path to a file relative to the Blockbuster workspace.")
  )
)

tool_write_file <- tool(
  write_file,
  description = "____",
  arguments = list(
    path = type_string("Path to a file relative to the Blockbuster workspace."),
    content = type_string("The full contents of the file to write.")
  )
)

# Agent ------------------------------------------------------------------------

chat <- chat_openai(
  model = "gpt-6-luna",
  system_prompt = r"(
You are a coding agent for the Last Blockbuster in Bend, Oregon.
Work in the current directory.
When a task needs code, write an R script for the user to run.)"
)

# STEP 3: Register both tools with the chat client ----
chat$register_tool(tool_read_file)
chat$register_tool(tool_write_file)

# STEP 4: Put your agent to work ----
# Ask the agent to build the win-back list as `win-back.csv`.
# It cannot run code, so it must write `find_lapsed.R` for you to run from
# inside `blockbuster/`.
chat$chat(
  r"(
It is time for the renewal drive. Which members have gone quiet?
Build the win-back list as `win-back.csv` by writing `find_lapsed.R`
for me to run from inside the blockbuster folder.)"
)

# Inspect the whole conversation, including every tool call.
chat

20_agent-2

library(ellmer)
library(here)

# Your agent from `19_agent-1` gets two more tools today: one to list the
# files in the workspace and one to make targeted edits.
project_dir <- here("20_agent-2", "blockbuster")
proj_path <- function(path) file.path(project_dir, path)

# Read/Write Tools -------------------------------------------------------------
# These are the read and write tools from the last exercise.
# Skip ahead to the list files tool.

read_file <- function(path) {
  brio::read_file(proj_path(path))
}

write_file <- function(path, content) {
  brio::write_file(content, proj_path(path))
  paste0("Wrote ", path, ".")
}

tool_read_file <- tool(
  read_file,
  description = paste(
    "Read the full contents of a file in the Blockbuster workspace.",
    "Use this to inspect the store records before you write a script."
  ),
  arguments = list(
    path = type_string("Path to a file relative to the Blockbuster workspace.")
  )
)

tool_write_file <- tool(
  write_file,
  description = paste(
    "Write a file in the Blockbuster workspace, overwriting it if it exists.",
    "Use this to create a script or its output."
  ),
  arguments = list(
    path = type_string("Path to a file relative to the Blockbuster workspace."),
    content = type_string("The full contents of the file to write.")
  )
)

# STEP 1: The list files tool --------------------------------------------------
# Write a description that tells the LLM what this tool does and when to use it.

list_files <- function() {
  paste(list.files(project_dir), collapse = "\n")
}

tool_list_files <- tool(
  list_files,
  description = "____"
)

# STEP 2: The edit file tool ---------------------------------------------------
# Describe each argument so the LLM knows exactly what to pass — especially
# what counts as a valid `old`.

edit_file <- function(path, old, new) {
  full <- proj_path(path)
  content <- brio::read_file(full)
  matches <- gregexpr(old, content, fixed = TRUE)[[1]]

  if (identical(matches, -1L)) {
    cli::cli_abort("Could not find the text to replace in {path}.")
  }

  if (length(matches) != 1L) {
    cli::cli_abort(
      "Expected one exact match in {path}, but found {length(matches)}."
    )
  }

  brio::write_file(sub(old, new, content, fixed = TRUE), full)
  paste0("Edited ", path, ".")
}

tool_edit_file <- tool(
  edit_file,
  description = paste(
    "Replace one exact span of text in a workspace file.",
    "The existing text must appear exactly once or the tool returns an error.",
    "Use this to patch a script instead of rewriting it."
  ),
  arguments = list(
    path = type_string("____"),
    old = type_string("____"),
    new = type_string("____")
  )
)

# STEP 3: Prepare the agent with its tools -------------------------------------
chat <- chat_openai(
  model = "gpt-6-luna",
  system_prompt = r"(
You are a coding agent for the Last Blockbuster in Bend, Oregon.
Work in the current directory.
When a task needs code, write an R script for the user to run.)"
)

chat$register_tool(tool_read_file)
chat$register_tool(tool_write_file)
chat$register_tool(tool_list_files)
chat$register_tool(tool_edit_file)

# STEP 4: Put your agent to work -----------------------------------------------
# Ask the agent to do the same task as the last exercise: build the win-back
# list by writing `find_lapsed.R` for you to run from inside blockbuster/.
chat$chat(
  r"(
It is time for the renewal drive. Which members have gone quiet?
Build the win-back list as `win-back.csv` by writing `find_lapsed.R`
for me to run from inside the blockbuster folder.)"
)

# STEP 5: Your manager found an old register export -----------------------------
# Drop the old register export into the workspace, as if your manager just
# delivered it. (If this copy fails, copy data/blockbuster/rentals-old.csv into
# the workspace folder manually.)
file.copy(
  here("data/blockbuster", "rentals-old.csv"),
  project_dir,
  overwrite = TRUE
)

# Then tell the agent that your manager found an old register export and
# dropped it in the folder. Without naming the file, ask it to bring the list
# up to date.
chat$chat(
  r"(
The manager found an old register export and dropped it in the folder.
Bring the win-back list up to date.)"
)

# Inspect the whole conversation, including every tool call.
chat

21_skills-1

library(ellmer)
library(here)

# Your agent from `20_agent-2` gets a new ability today: skills.
# A skill is a folder with a SKILL.md file: frontmatter with a name and
# description, then instructions for a job.
# `skills/renewal-letters/SKILL.md` holds the letter-writing instructions.
skills_dir <- here("21_skills-1", "skills")

source(here("21_skills-1", "_tools.R"))

# STEP 1: Document the read_skill tool -----------------------------------------

read_skill <- function(skill) {
  path <- file.path(skills_dir, skill, "SKILL.md")
  brio::read_file(path)
}

# Read `read_skill()` above, then describe it for the LLM.
# Remember: the LLM sees only your description, not the R code.
tool_read_skill <- tool(
  read_skill,
  description = "____",
  arguments = list(
    skill = type_string("Name of the skill to read.")
  )
)

# STEP 2: List your skills -----------------------------------------------------

# We've written `list_skills()` for you:
# it reads each skill folder's SKILL.md frontmatter and returns a
# `name: description` line per skill. Run it to see what it produces,
# then use it in the system prompt below.
list_skills <- function(skills_dir) {
  files <- fs::dir_ls(skills_dir, recurse = TRUE, glob = "**/SKILL.md")
  skills <- purrr::map_dfr(
    files,
    \(path) frontmatter::read_front_matter(path)$data
  )

  paste(
    interpolate("- {{ skills$name }}: {{ skills$description }}"),
    collapse = "\n"
  )
}

list_skills(skills_dir)

# Agent ------------------------------------------------------------------------

# Add each skill's name and description to the system prompt.
chat <- chat_openai(
  model = "gpt-6-luna",
  system_prompt = interpolate(
    "
    You are a coding agent for the Last Blockbuster in Bend, Oregon.

    Read a skill with the read_skill tool before you do the job it describes.

    ## Available skills

    ________
    "
  )
)

chat$register_tool(tool_read_file)
chat$register_tool(tool_write_file)
chat$register_tool(tool_list_files)
chat$register_tool(tool_edit_file)

# Register the read_skill tool.
chat$register_tool(tool_read_skill)

# STEP 3: Put your agent to work -----------------------------------------------
# Draft renewal letters for the top three members on `lapsed.csv`.
# Save one file for each member in `letters/drafts/`.
# Watch for the agent to read the skill before it starts drafting.
chat$chat(paste(
  "Draft renewal letters for the top three members on lapsed.csv.",
  "Save one file for each member in letters/drafts/."
))

# Inspect the whole conversation, including every tool call.
chat

_tools.R

project_dir <- here("21_skills-1", "blockbuster")
proj_path <- function(path) file.path(project_dir, path)

read_file <- function(path) {
  brio::read_file(proj_path(path))
}

write_file <- function(path, content) {
  brio::write_file(content, proj_path(path))
  paste0("Wrote ", path, ".")
}

list_files <- function() {
  paste(list.files(project_dir), collapse = "\n")
}

edit_file <- function(path, old, new) {
  full <- proj_path(path)
  content <- brio::read_file(full)
  matches <- gregexpr(old, content, fixed = TRUE)[[1]]

  if (identical(matches, -1L)) {
    stop("Could not find the text to replace in ", path, ".")
  }

  if (length(matches) != 1L) {
    stop(
      "Expected one exact match in ",
      path,
      ", but found ",
      length(matches),
      "."
    )
  }

  brio::write_file(sub(old, new, content, fixed = TRUE), full)
  paste0("Edited ", path, ".")
}

tool_read_file <- tool(
  read_file,
  description = paste(
    "Read the full contents of a file in the Blockbuster workspace.",
    "Use this to inspect the store records before you write a script."
  ),
  arguments = list(
    path = type_string("Path to a file relative to the Blockbuster workspace.")
  )
)

tool_write_file <- tool(
  write_file,
  description = paste(
    "Write a file in the Blockbuster workspace, overwriting it if it exists.",
    "Use this to create a script, a CSV, or a letter draft."
  ),
  arguments = list(
    path = type_string("Path to a file relative to the Blockbuster workspace."),
    content = type_string("The full contents of the file to write.")
  )
)

tool_list_files <- tool(
  list_files,
  description = paste(
    "List the names of files in the Blockbuster workspace.",
    "Use this to discover new files before you read or edit them."
  )
)

tool_edit_file <- tool(
  edit_file,
  description = paste(
    "Replace one exact span of text in a workspace file.",
    "The existing text must appear exactly once or the tool returns an error.",
    "Use this to patch a script instead of rewriting it."
  ),
  arguments = list(
    path = type_string("Path to a file relative to the Blockbuster workspace."),
    old = type_string("The exact existing text to replace."),
    new = type_string("The text that replaces it.")
  )
)
---
name: renewal-letters
description: Draft renewal letters for lapsed Last Blockbuster members. Use when drafting those letters, not when identifying lapsed members or updating store records.
---

# Renewal letters

Write with the voice of a neighbor who knows the store and wants the member back.
Be warm, specific, and plainspoken.
Do not sound like a mass promotion.

Save each letter as a Markdown file in `letters/drafts/`.
Use the member's name in the filename with lowercase words separated by hyphens.

Each letter includes:

1. A personal greeting.
2. A reference to the member's last rented title.
3. One renewal incentive that matches the member's tier.
4. A clear invitation to visit or call the store.
5. A brief sign-off from the Last Blockbuster team.

Offer Basic members a free-rental code.
Offer Family members a family-night bundle.
Offer Founders members a commemorative card.

Do not promise an unlisted discount, a title's availability, or any no-late-fees terms.
Do not mention overdue fees unless the task asks for them.

22_skills-2

library(ellmer)
library(here)

# Your skills-wired agent from `21_skills-1` is back, but its skill is a bad
# first draft. Open `skills/renewal-letters/SKILL.md` before you continue.
# The skills folder also holds three other skills the agent can choose from.
#
# STEP 1: Fix the skill ----
# Start at the top of `skills/renewal-letters/SKILL.md` and work through these
# questions one at a time:
#
# 1. Read only the description. If that were all the agent saw before choosing
#    a skill, what would it know to do?
#
# 2. When will the agent read the first two sentences in the body?
#
# 3. Read the rest of the body beside the store files. Which parts tell the
#    agent something it cannot already find elsewhere?
#
# 4. Where and how does the skill describe the process the agent should
#    follow? Is the process clear? Is there room for interpretation or
#    confusion?
#
skills_dir <- here("22_skills-2", "skills")

source(here("22_skills-2", "_tools.R"))

read_skill <- function(skill) {
  path <- file.path(skills_dir, skill, "SKILL.md")
  brio::read_file(path)
}

tool_read_skill <- tool(
  read_skill,
  description = paste(
    "Read the full instructions for a listed skill.",
    "Call this before you do the job the skill describes."
  ),
  arguments = list(
    skill = type_string("Name of the skill to read, such as 'renewal-letters'.")
  )
)

list_skills <- function(skills_dir) {
  files <- fs::dir_ls(skills_dir, recurse = TRUE, glob = "**/SKILL.md")
  skills <- purrr::map_dfr(
    files,
    \(path) frontmatter::read_front_matter(path)$data
  )

  paste(
    interpolate("- {{ skills$name }}: {{ skills$description }}"),
    collapse = "\n"
  )
}

list_skills(skills_dir)

# Agent ------------------------------------------------------------------------

chat <- chat_openai(
  model = "gpt-6-luna",
  system_prompt = interpolate(
    "
    You are a coding agent for the Last Blockbuster in Bend, Oregon.

    Read a skill with the read_skill tool before you do the job it describes.

    ## Available skills

    {{ list_skills(skills_dir) }}
    "
  )
)

chat$register_tool(tool_read_file)
chat$register_tool(tool_write_file)
chat$register_tool(tool_list_files)
chat$register_tool(tool_edit_file)

# Register the read_skill tool.
chat$register_tool(tool_read_skill)

# STEP 2: Put your agent to work ----
# After you fix the skill, re-run the letter task from `21_skills-1`.
chat$chat(paste(
  "Draft renewal letters for the top three members on lapsed.csv.",
  "Save one file for each member in letters/drafts/."
))

# Inspect the whole conversation, including every tool call.
chat

_tools.R

project_dir <- here("22_skills-2", "blockbuster")
proj_path <- function(path) file.path(project_dir, path)

read_file <- function(path) {
  brio::read_file(proj_path(path))
}

write_file <- function(path, content) {
  brio::write_file(content, proj_path(path))
  paste0("Wrote ", path, ".")
}

list_files <- function() {
  paste(list.files(project_dir), collapse = "\n")
}

edit_file <- function(path, old, new) {
  full <- proj_path(path)
  content <- brio::read_file(full)
  matches <- gregexpr(old, content, fixed = TRUE)[[1]]

  if (identical(matches, -1L)) {
    stop("Could not find the text to replace in ", path, ".")
  }

  if (length(matches) != 1L) {
    stop(
      "Expected one exact match in ",
      path,
      ", but found ",
      length(matches),
      "."
    )
  }

  brio::write_file(sub(old, new, content, fixed = TRUE), full)
  paste0("Edited ", path, ".")
}

tool_read_file <- tool(
  read_file,
  description = paste(
    "Read the full contents of a file in the Blockbuster workspace.",
    "Use this to inspect the store records before you write a script."
  ),
  arguments = list(
    path = type_string("Path to a file relative to the Blockbuster workspace.")
  )
)

tool_write_file <- tool(
  write_file,
  description = paste(
    "Write a file in the Blockbuster workspace, overwriting it if it exists.",
    "Use this to create a script, a CSV, or a letter draft."
  ),
  arguments = list(
    path = type_string("Path to a file relative to the Blockbuster workspace."),
    content = type_string("The full contents of the file to write.")
  )
)

tool_list_files <- tool(
  list_files,
  description = paste(
    "List the names of files in the Blockbuster workspace.",
    "Use this to discover new files before you read or edit them."
  )
)

tool_edit_file <- tool(
  edit_file,
  description = paste(
    "Replace one exact span of text in a workspace file.",
    "The existing text must appear exactly once or the tool returns an error.",
    "Use this to patch a script instead of rewriting it."
  ),
  arguments = list(
    path = type_string("Path to a file relative to the Blockbuster workspace."),
    old = type_string("The exact existing text to replace."),
    new = type_string("The text that replaces it.")
  )
)
---
name: renewal-letters
description: A guide assembled from the Last Blockbuster manager's notes, store history, and ideas for welcoming former members back to the store.
---

# Renewal letters

Use this skill when drafting renewal letters for lapsed Last Blockbuster members.
Do not use it to identify lapsed members or update store records.

This guide was written after the manager collected notes from the renewal drive.
The campaign date is September 1, 2026.
A member is lapsed when they have no rental in the last 90 days and no dues payment in the last 12 months.
A member with an unreturned rental is never lapsed.
Members without an email still go on the win-back list.

Write with the voice of a neighbor who knows the store and wants the member back.
Be warm, specific, and plainspoken.
Do not sound like a mass promotion.

Start every letter with the tier incentive so the offer is impossible to miss.
Mention the last rented title only if it was a recent release.

Each letter includes:

1. A personal greeting.
2. A reference to the member's last rented title.
3. One renewal incentive that matches the member's tier.
4. A clear invitation to visit or call the store.
5. A brief sign-off from the Last Blockbuster team.

Save each letter as a Markdown file in `letters/drafts/`.
Use the member's name in the filename with lowercase words separated by hyphens.

Write a good letter.
Use a greeting, some details, and a sign-off.
Be polite and encourage the member to renew.
Check the store files before you write.

Offer Basic members a free-rental code.
Offer Family members a family-night bundle.
Offer Founders members a commemorative card.

Do not promise an unlisted discount, a title's availability, or any no-late-fees terms.
Do not mention overdue fees unless the task asks for them.
---
name: lapsed-audit
description: Audit membership records to find lapsed members and maintain the win-back list. Use when identifying lapsed members or updating the list.
---

# Lapsed audit

A member is lapsed when they have no rental in the last 90 days
and no dues payment in the last 12 months.
A member with an unreturned tape is never lapsed — get the tape back first.

Work from `members.csv`, `rentals.csv`, and `dues.csv`.
Read `notes.md` before you decide anything; it carries the campaign date and exceptions.

Order the win-back list: Founders, then Family, then Basic.
Within a tier, most recent activity first.

Write the list to a new file.
Never delete rows from store records, and never edit the exports.
---
name: social-media-voice
description: Draft posts for the store's social media accounts in the store's voice. Use for public posts and announcements.
---

# Social media voice

Write like a neighbor who loves movies, not like a brand.
Warm, specific, plainspoken. A little playful is fine.

Lead with the movie, not the store.
Name a real title whenever one is relevant.

Posts are short: one or two sentences, and end with an invitation to visit.
Sign-off is the store name only — no team names, no hashtags.

Never promise discounts, availability, or dates that are not confirmed
in a store file first.
---
name: tape-tracking
description: Track unreturned tapes and prepare return reminders. Use when a tape is still out or a member may have one.
---

# Tape tracking

Check `rentals.csv` for rows where `returned` is empty.
Those tapes are still out.

For each tape still out, record:
the member, the title, the rental date, and how long it has been out.

Save the list as a new CSV in the workspace.
Do not edit `rentals.csv` — it is a register export.

A tape still out blocks a member from the win-back list.
Flag those members so the audit skips them.

Reminders go out by email, one short paragraph, friendly not stern.
Do not mention late fees. The manager handles accounts personally.

Acknowledgments