R Tidyverse skill for Codex

R analysis that reruns from a clean session. Codex writes R the way a careful analyst does: renv pins packages, readr declares column types and reports parse problems, dplyr joins state their expected relationship so duplicates raise errors, and ggplot2 figures are saved at explicit sizes. Reports are parameterised Quarto documents rendered with one command from a clean session.

  • Data & Analytics
  • $r-tidyverse
  • 212 lines
  • 2 files
  • ~1,900 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- r-tidyverse

Installs to ~/.agents/skills/r-tidyverse/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $r-tidyverse, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Writes reproducible R analysis with the tidyverse - readr imports with explicit col_types, dplyr 1.1 verbs with .by and join_by, tidyr pivots, purrr iteration, ggplot2 charts saved at fixed sizes, renv lockfiles, and parameterised Quarto reports rendered from the command line. Use when working in an R project or R Markdown/Quarto report, translating base R or spreadsheet logic to tidyverse, fixing join or grouping bugs in dplyr, or producing ggplot2 figures and HTML/PDF reports that must rerun identically.

Use it when

  • Working in an R project, R Markdown or Quarto report
  • Translating base R or spreadsheet logic into tidyverse code
  • dplyr joins, grouping or imports produce surprising results
  • ggplot2 figures or reports must rerun identically for others

Use something else when

How to check the result

From a clean session, renv::restore and quarto render succeed, readr problems() is empty or explained, and headline numbers match a known total.

The mistake it prevents

Letting read_csv guess types so ID columns become numbers, then joining on them and silently losing or duplicating rows.

~/.agents/skills/r-tidyverse/SKILL.md

--- name: r-tidyverse description: Writes reproducible R analysis with the tidyverse - readr imports with explicit col_types, dplyr 1.1 verbs with .by and join_by, tidyr pivots, purrr… license: MIT ---

Good R analysis reruns from a clean session with renv::restore() and one render command, declares column types on import, makes joins state their expected relationship so silent row multiplication fails loudly, and saves figures at explicit sizes. Results never depend on objects left in someone’s global environment.

Inspect first

  1. Read AGENTS.md, DESCRIPTION (if it is a package), renv.lock, _quarto.yml and .Rprofile. Note the R version (R --version) and whether renv is active.
  2. Check for a project file (*.Rproj) or here usage; paths should resolve from the project root with here::here("data", "orders.csv"), never setwd().
  3. Run existing scripts with Rscript from a clean session, not inside an IDE session that already holds objects:
Rscript -e 'renv::status()'
Rscript analysis/01_import.R
quarto render reports/monthly.qmd

Installing packages (renv::restore(), install.packages) needs network; in a Codex sandbox with network off, ask for approval or work with the installed library.

Environment with renv

renv::init()          # once: creates renv.lock, project library, .Rprofile hook
renv::install("dplyr")
renv::snapshot()      # record versions after adding packages
renv::restore()       # collaborators and CI reproduce the library

Commit renv.lock, renv/activate.R and .Rprofile; never commit renv/library/.

Import with explicit types

library(tidyverse)

orders <- read_csv(
  here::here("data", "orders.csv"),
  col_types = cols(
    order_id    = col_character(),
    customer_id = col_character(),
    zip         = col_character(),            # keep leading zeros
    amount      = col_double(),
    order_date  = col_date(format = "%Y-%m-%d"),
    channel     = col_factor(levels = c("web", "app", "store")),
    .default    = col_character()
  ),
  na = c("", "NA", "NULL")
)
problems(orders)        # parsing failures with row and column; must be empty or explained

European files: read_csv2() or locale = locale(decimal_mark = ",", grouping_mark = "."). Excel: readxl::read_excel(path, sheet = "Data", col_types = "text"), then convert.

dplyr 1.1 idioms

summary <- orders |>
  filter(order_date >= as.Date("2025-01-01")) |>
  mutate(month = floor_date(order_date, "month")) |>       # lubridate, part of tidyverse 2.0
  summarise(
    orders  = n(),
    revenue = sum(amount, na.rm = TRUE),
    aov     = revenue / orders,
    .by = c(month, channel)                                 # per-operation grouping, no ungroup()
  ) |>
  arrange(month, desc(revenue))
  • Prefer .by over group_by() + ungroup(): grouping cannot leak into later steps.
  • Use across() for the same operation on many columns: summarise(across(c(amount, qty), \(x) sum(x, na.rm = TRUE)), .by = channel).
  • case_when() for multi-branch logic; the final .default = catches the rest.
  • Native pipe |> and lambda \(x) need R 4.1+; follow the project’s existing choice if it uses %>%.

Joins that fail loudly

orders_cust <- orders |>
  inner_join(
    customers,
    by = join_by(customer_id),
    relationship = "many-to-one",     # error if customers has duplicate ids
    unmatched = c("error", "drop")    # error if an order has no customer; customers without orders are fine
  )

# rolling / inequality joins: price valid at the order date
priced <- orders |>
  left_join(prices, by = join_by(product_id, closest(order_date >= valid_from)))

unmatched only guards rows a join could drop: in a left_join it checks y, not x, so use inner_join with c("error", "drop") to require a match for every left row. Always compare nrow() before and after a join when not using relationship. Use anti_join() to list unmatched keys and report them.

Reshaping and iteration

long <- wide |> pivot_longer(cols = starts_with("q"), names_to = "quarter", values_to = "sales")
wide <- long |> pivot_wider(names_from = quarter, values_from = sales, values_fill = 0)

files <- fs::dir_ls(here::here("data", "monthly"), glob = "*.csv")
all_months <- files |>
  set_names(basename) |>
  map(\(f) read_csv(f, col_types = cols(.default = col_character()))) |>
  list_rbind(names_to = "source_file")

list_rbind() (purrr 1.0) replaces map_dfr().

ggplot2

p <- summary |>
  ggplot(aes(month, revenue, colour = channel)) +
  geom_line(linewidth = 0.9) +
  scale_y_continuous(labels = scales::label_currency(prefix = "EUR ", scale_cut = scales::cut_short_scale())) +
  scale_colour_viridis_d(end = 0.85) +
  labs(title = "App revenue overtook web in June", x = NULL, y = "Monthly revenue",
       colour = NULL, caption = "Source: orders export, 2025-01 to 2025-09") +
  theme_minimal(base_size = 11) +
  theme(legend.position = "top", plot.title.position = "plot")

ggsave(here::here("figures", "revenue_by_channel.png"), p, width = 8, height = 4.5, dpi = 200)
ggsave(here::here("figures", "revenue_by_channel.pdf"), p, width = 7, height = 3.5)
  • Use linewidth for lines (ggplot2 3.4+); size is for points and text.
  • Always pass width, height and dpi to ggsave; defaults depend on the open device.
  • Facets for many groups: facet_wrap(vars(region), ncol = 3).

Quarto reports

---
title: "Monthly commerce report"
format:
  html:
    embed-resources: true
    code-fold: true
execute:
  echo: false
  warning: false
params:
  month: "2025-09-01"
  region: "EU"
---
#| label: setup
month <- as.Date(params$month)
quarto render reports/monthly.qmd -P month:2025-10-01 -P region:US --output monthly-US-2025-10.html
  • params work with the knitr engine; refer to them as params$month.
  • embed-resources: true makes a single shareable HTML file.
  • Use freeze: auto in _quarto.yml for projects so unchanged documents are not re-executed; clear _freeze/ when data changes.
  • Sharing or publishing a report (quarto publish) is outward-facing; confirm the target with the user first.

Gotchas

  • read_csv guesses types from the first 1000 rows; a column of IDs becomes double and loses leading zeros. Declare col_types.
  • NA propagates: sum(x) is NA if any value is missing; decide on na.rm per metric.
  • Factors in joins and bind_rows with different levels coerce with warnings; convert to character for joining.
  • group_by() without ungroup() makes later mutate() calls compute per group.
  • Dates from Excel arrive as numbers; convert with as.Date(x, origin = "1899-12-30").
  • set.seed() once at the top for anything random, and record it.

Verification

Rscript -e 'renv::restore(prompt = FALSE)'
Rscript -e 'testthat::test_dir("tests/testthat")'   # if helper functions are tested
quarto render reports/monthly.qmd
  • problems() empty after every import, or each issue explained.
  • Row counts logged before and after joins and filters.
  • Headline numbers in the rendered report match a known total.
  • lintr::lint_dir() and styler::style_dir() if the project uses them.

Delivery template

**Report:** reports/monthly.qmd -> monthly-EU-2025-09.html (self-contained)
**Run:** renv::restore(); quarto render reports/monthly.qmd -P month:2025-09-01 -P region:EU
**Data:** data/orders.csv (184,201 rows, 0 parse problems)
**Checks:** joins declare relationship and unmatched handling; revenue 1.28M EUR matches finance
**Figures:** figures/revenue_by_channel.png (8 x 4.5 in, 200 dpi)

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "R Tidyverse"
  short_description: "R analysis that reruns from a clean session"
  default_prompt: "$r-tidyverse Turn this R script into a parameterised Quarto report we can render per region"

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc