7  Walkthrough: Contributing to Stan’s R packages

In this tutorial, we will walk you through one example code contribution to posterior, one of Stan’s R packages. Along the way, we will show how you can use the Stan skills to support your contribution workflow.

Note that these skills are still a prototype. Please use them with care and report any problems you run into, so that we can learn how to improve them.

You can get the skills from https://github.com/florence-bockting/stancon-contributing. Fork and clone the repository. Open the repository of interest (in this case posterior) and save the stancon-contributing/skills folder under

depending on your LLM. Alternatively, use your LLM and prompt it:

read the skills from <path>/stancon-contributing/skills into your global skill

Even if you don’t use an LLM, the skills are useful on their own: they provide recipes for the tasks that come up again and again when contributing to an R package.

7.1 Writing an issue

Suppose you hit a problem in the posterior package: all columns of the summarise_draws() output are converted to character, even the mean column, which should be numeric.

example_draws() |>
   summarise_draws(char_fun = \(x) "word", mean)

# A tibble: 10 × 3
#   variable char_fun mean            
#   <chr>    <chr>    <chr>           
# 1 mu       word     4.1799990610081 
# 2 tau      word     4.16356885610418
# ...

First, we want to report this problem to the developers of the posterior package, so we write an issue. In Writing good Issues, we discussed what the structure of a good issue looks like.

The stan-issue-and-pr skill helps you write an issue for a given problem that follows this structure. The issue template is provided in skills/stan-issue-and-pr/templates/bug-report.md.

Next, we show how the skill can be used to create an issue.

7.1.1 Duplicate issue

Point the AI assistant at the skill and make sure it actually reads it.

You

I want to create an issue in stan-dev/posterior for the following problematic case:

example_draws() |>
   summarise_draws(char_fun = \(x) "word", mean)

Problem: all columns are converted to string even those that should be a double (e.g., mean).
Help me write the Issue according to Stan conventions.

The first thing the LLM does is check for duplicates. You can look up all steps the skill asks for in skills/stan-issue-and-pr/SKILL.md.

In our case, an issue for this problem already exists, so the skill flags it as a duplicate.

Claude Opus Before drafting: I searched for duplicates first, and this exact case is already reported.

stan-dev/posterior#355 — “In summarise draws, a function that returns a character changes all columns to character”, open since 2024-03-14, opened by n-kall, labelled bug. Its reproducible example is character-for-character your code:

example_draws() |>
   summarise_draws(char_fun = \(x) "word", mean)

The discussion there already diagnosed the cause: summarise_draws_helper binds results into a matrix and uses unlist, so a single character return coerces every column. Maintainers (mjskay, paul-buerkner) agreed the fix is to bind into a data frame directly rather than round-trip through a string.

There is also an open PR: #356 by n-kall, “modify summarise_draws internals not to coerce values to same type”. It is stalled — last update 2025-03-03, using purrr::flatten, with a known failing test for functions returning an unnamed list or vector.

So a new issue would be a duplicate, and Stan maintainers close those. Two more useful paths:

  • Comment on #355 if you have something new — a case the existing report misses, or evidence it still reproduces on 1.7.0.
  • Pick up #356. It is unfinished, the maintainers want it, and the remaining problem is well-defined.

Which do you want to do? If it’s the PR, I can help you draft the description with the stan-r-package-contribution workflow. If you think your case actually differs from #355, tell me how and we’ll write the issue.

7.1.2 Drafting an issue

To see the rest of the workflow, we tell the LLM to skip the first step (i.e., the search for pre-existing issues).

You

Skip this step and help me draft an issue anyway as this is a demo.

The skills are written so that the AI assistant acts as a mentor: it guides you through the process step by step instead of working autonomously. This way, you learn the contribution process yourself and keep authority over every decision that is made.

Claude Draft is at issue-draft.md.

Three things are still yours to write. I left them marked rather than guessed:

  • “What I expected” — I put a suggestion there. Rewrite it. Reviewers ask about text a contributor did not write.
  • Additional context — did you hit this in real work or while reading code? Do you have a workaround?
  • AI disclosure — the Stan AI Contribution Policy requires it in stan-dev repos, and you are literally on a branch adding that policy to CONTRIBUTING.md. Suggested wording is in the draft.

Also swap my environment block for your own sessionInfo().

▤ issue-draft.md

# Title (suggestion — rewrite in your words)

summarise_draws() coerces all summary columns to character when one summary function returns a character

## What happened

`summarise_draws()` returns every summary column as `<chr>` if any one summary
function returns a character value. Numeric summaries such as `mean` become
strings.

## What I expected

<!-- SUGGESTION — this is your sentence to write. Mine below as a starting point. -->

Each summary column keeps its own type. `char_fun` is a character column and
`mean` stays a double. Only the column produced by the character-returning
function should be character.

## Minimal reproducible example

```r
library(posterior)

example_draws() |>
  summarise_draws(char_fun = \(x) "word", mean)
```

## Actual output

```
# A tibble: 10 × 3
   variable char_fun mean
   <chr>    <chr>    <chr>
 1 mu       word     4.1799990610081
 2 tau      word     4.16356885610418
 3 theta[1] word     6.74893947963962
 4 theta[2] word     5.25331634990726
 5 theta[3] word     3.04393475572924
 6 theta[4] word     4.85842854299997
 7 theta[5] word     3.22258991784476
 8 theta[6] word     3.98696993634695
 9 theta[7] word     6.5030995214442
10 theta[8] word     4.56520199862817
```

`str()` confirms the storage type, so this is not only a print-method effect:

```
drws_smm [10 × 3] (S3: draws_summary/tbl_df/tbl/data.frame)
 $ variable: chr [1:10] "mu" "tau" "theta[1]" "theta[2]" ...
 $ char_fun: chr [1:10] "word" "word" "word" "word" ...
 $ mean    : chr [1:10] "4.1799990610081" "4.16356885610418" ...
```

## Steps to reproduce

1. `library(posterior)`
2. Run the example above.
3. Inspect the column types with `str()` or the tibble header.

## Environment

- OS: Linux, x86_64-pc-linux-gnu
- Package version: posterior 1.7.0 (installed release)
- R version: 4.6.1 (2026-06-24)

<!-- Replace with your own sessionInfo() output before posting. -->

## Additional context

<!-- OPEN — only you can fill these in:
     - Did you hit this in real work, or while reading the code?
     - Do you have a workaround you are using?
-->

Possible cause, from reading `R/summarise_draws.R`: `summarise_draws_helper()`
pre-allocates a matrix at line 360 and fills it with `unlist()` at lines 362 and
367. A matrix holds one type, and `unlist()` on a mixed-type list coerces to
character. Binding the per-variable results into a data frame instead of a
matrix would avoid the round trip through character.

<!-- AI DISCLOSURE — required by the Stan AI Contribution Policy if it applies.
     Delete if you used no assistant. Suggested wording:
     "I used Claude Code to reproduce the example and locate the relevant code
     in summarise_draws.R. The report is in my own words." -->

Now suppose we want to contribute a fix for the issue above. The instructions for the contribution workflow are described in skills/stan-r-package-contribution/SKILL.md.

You can either work through the contribution yourself, using the skill as a guideline (or checklist), or you can work through it as a dialogue with an LLM.

7.2 Is the issue unclaimed?

You I want to contribute a solution to the issue: https://github.com/stan-dev/posterior/issues/355

How do I get started?

Claude Here is the whole path. It is your checklist, not mine.

Orient    1. Issue read, unclaimed, and understood   ← you are here
Set up    2. Fork and clone
          3. Check the environment: roxygen2 pin, dev packages, clean tree
          4. Create a branch
Change    5. Write the code next to its siblings
          6. Document, then read your own diff
Verify    7. Test the failure modes, then the full suite
          8. Update NEWS.md
          9. Run devtools::check()
Submit   10. Commit, push, open the pull request
Review       Respond to reviewers

Stage: orient — step 1, confirm the issue is unclaimed.

Before any code, ask in the issue. Suggested comment — rewrite it in your words:

Is #356 still active? If not, I would like to pick this up. I plan to follow the data-frame binding approach mjskay suggested rather than purrr::flatten, to avoid the new dependency and the unnamed-vector case that PR hit.

Post that yourself, or shall I with gh?

While you wait, two questions decide the design. The thread contains a disagreement nobody resolved:

  • purrr::flatten (n-kall’s PR) or direct data-frame binding (mjskay, and paul-buerkner agreed)?
  • PR #356 has a failing test for functions returning an unnamed list or vector. What should summarise_draws() do in that case?

Can you describe, in your own words, why the current code coerces the columns? If not, we read R/summarise_draws.R:340-375 together before step 2.

Skip this step and start with fresh local PR

7.3 Check setup

First, we ensure that we have a fork and clone of the repo of interest. There are different approaches for doing this, below are two different approaches using either usethis or gh.

# from R
usethis::create_from_github("stan-dev/posterior", fork = TRUE)

# from the shell, clones into ./posterior
gh repo fork stan-dev/posterior --clone

(In my case the LLM will notice that I have a clone of the package and push rights.)

Then, we check whether the environment is set up as expected. For this, the skills ship an R script, stan-r-package-contribution/scripts/check_environment_setup.R, that runs a few automatic checks.

Claude Two facts change the plan, both read-only:

You have push access to stan-dev/posterior (“push”: true), so no fork is needed. Step 2 genuinely does not apply to you. Working tree is clean.

Stage: set up — step 3, check the environment.

This script reads your DESCRIPTION, your R library, and your git state. The check that matters is roxygen2: if your version differs from the one that documented this package, devtools::document() at step 6 turns a 4-file change into a 47-file diff of reformatted \link{} calls, and a reviewer cannot find your actual change in that. It changes nothing — it reads and prints one line per check.

Rscript /u/21/wa.bocktif1/unix/.claude/skills/stan-r-package-contribution/scripts/check_environment_setup.R

It will flag your current branch, add-ai-contribution-policy. That flag is correct — we cut the new branch from master in the next step, not from your policy work.

Run it, or shall I? Paste what it prints.

You

Copy & paste terminal output to LLM

user@posterior$ Rscript /u/21/wa.bocktif1/unix/.claude/skills/stan-r-package-contribution/scripts/check_environment_setup.R

Environment check for an R package contribution.
Reads DESCRIPTION, your R library, and your git state. Changes nothing.
FAIL blocks a reviewable pull request. WARN is worth a look.

[OK  ] Package: posterior
[OK  ] Contributing guide: .github/CONTRIBUTING.md -- read it before you start.
[OK  ] roxygen2 7.3.3 matches the pin in DESCRIPTION.
[OK  ] devtools installed.
[OK  ] usethis installed.
[OK  ] testthat installed.
[OK  ] Working tree clean.
       Branch 'add-ai-contribution-policy' is not a base branch.
       Did you create it for this contribution? [y/N] N

[FAIL] Branch 'add-ai-contribution-policy' is not for this contribution.
       Your change would start from somebody else's work.
       git checkout main   # or master, or develop
       usethis::pr_init("123-short-description")
[OK  ] gh CLI authenticated.

Fix every FAIL above before you start the change.

Claude

Stage: set up — step 3, the FAIL on the branch.

Orient    1. Issue read, unclaimed, and understood
Set up    2. Fork and clone
          3. Check the environment  ← you are here
          4. Create a branch
Change    5. Write the code
          6. Document, then read your own diff
Verify    7. Test
          8. Update NEWS.md
          9. devtools::check()
Submit   10. Commit, push, open the pull request

The FAIL is correct. git log master..HEAD prints nothing, so add-ai-contribution-policy is already merged into master. New work here starts from a finished change.

This returns you to master and pulls the latest upstream commits, so your branch starts from current code:

usethis::pr_pause()

Run it, or shall I?

You Done.

Claude

Stage: set up — step 4, create a branch.

pr_init() pulls the latest upstream commits, creates the branch, and switches to it in one call. You are on master, so it starts from current code.

usethis::pr_init("355-summarise-draws-types")

The name appears in the pull request, so change the words if you prefer others.

Run it, or shall I?

Run the command and then we are on the respective development branch and can make our changes.

NoteIf Rscript is not found

On Windows the R installer does not add R\bin to your PATH, so this command fails with “not recognized” before it checks anything. Either add R\bin to the PATH, or skip the script and run the same checks by hand from the R console — packageVersion("roxygen2") against the pin in DESCRIPTION is the one that matters. The script is a convenience; the checks are the point.

One check further ahead has a platform requirement too: devtools::check() compiles the package, which needs RTools on Windows or the Xcode command line tools (xcode-select --install) on macOS. Sort that out here, at the set-up stage, rather than after the change is written.

7.4 Code contributions

The next step is to make the code changes. This step should always consist of two connected parts: (1) a test that checks the outcome you want to achieve, and (2) the code changes needed to achieve it.

In our example, we want summarise_draws() to return an output in which the type of one column is not carried over to the other columns.

So we could, for example, write the following tests into tests/testthat/test-summarise_draws.R.

test_that("summarise_draws_helper works correctly", {
  res1 <- example_draws() %>%
    summarise_draws(char_fun = function(x) "word", mean)
  
  expect_type(res1$char_fun, "character")
  expect_type(res1$mean, "double")

  res2 <- example_draws() %>%
    summarise_draws(char_fun = function(x) factor("a"), mean)
  
  expect_s3_class(res2$char_fun, "factor")
  expect_type(res2$mean, "double")

  res3 <- example_draws() %>%
    summarise_draws(char_fun = function(x) TRUE, mean)
  
  expect_type(res3$char_fun, "logical")
  expect_type(res3$mean, "double")
})

Then we make the code changes needed to reach the desired output. In this case, we change a helper function that is used by summarise_draws() in R/summarise_draws.R:

# flatten the summary list of a single variable into a list with one element
# per output column, keeping each value in its original type
flatten_summary_list <- function(v_summary) {
  unlist(lapply(v_summary, as.list), recursive = FALSE, use.names = FALSE)
}

summarise_draws_helper <- function(x, funs, .args) {
  variables_x <- variables(x)
  # get length and output names, calculated on the first variable
  out_1 <- create_summary_list(x, variables_x[1], funs, .args)
  the_names <- vector(mode = "list", length = length(funs))
  for (i in seq_along(out_1)){
    if (rlang::is_named(out_1[[i]])) {
      the_names[[i]] <- names(out_1[[i]])
    } else if (length(out_1[[i]]) > 1) {
      the_names[[i]] <- paste0(names(out_1)[i], ".", c(1:length(out_1[[i]])))
    } else {
      the_names[[i]] <- names(out_1)[i]
    }
  }
  the_names <- unlist(the_names)
  # Check for naming issues prior do doing lengthy computation
  if ("variable" %in% the_names) {
    stop_no_call("Name 'variable' is reserved in 'summarise_draws'.")
  }
  # Pre-allocate list to store output, one element per variable holding
  # the summary values of that variable as a flat (but untyped) list
  out <- vector("list", length(variables_x))
  out[[1]] <- flatten_summary_list(out_1)
  # Do the computation for all remaining variables
  if (length(variables_x) > 1L) {
    for (v_ind in 2:length(variables_x)) {
      out_v <- create_summary_list(x, variables_x[v_ind], funs, .args)
      out[[v_ind]] <- flatten_summary_list(out_v)
    }
  }
  # bind the summaries column-wise so that the type of each summary is
  # preserved instead of being coerced to a common type across summaries
  columns <- lapply(seq_along(the_names), function(i) {
    vctrs::vec_c(!!!lapply(out, .subset2, i))
  })
  names(columns) <- the_names
  out <- tibble::as_tibble(columns)
  out$variable <- variables_x
  out <- move_to_start(out, "variable")
  class(out) <- class_draws_summary()
  out
}

Finally, we run the tests to check whether our code changes make them pass.

devtools::load_all()

testthat::test_file("tests/testthat/test-summarise_draws.R")

7.5 Wrapping up

Once the code changes and tests are done, a few final tasks remain before we can open a pull request. For orientation, we can again check skills/stan-r-package-contribution/SKILL.md or simply ask the LLM:

You Changes are made. Is this branch PR ready?

Claude Stage: verify — the branch is not PR ready yet.

What is missing:

  • Nothing is committed. git log master..HEAD is empty. Both files are still working-tree changes only.
  • No NEWS.md entry (step 8). The file starts at # posterior 1.7.1, so you need a new # posterior (development version) header with a ### Bug Fixes bullet citing (#355).
  • devtools::check() has not been run (step 9).
  • No fork remote. origin is stan-dev/posterior itself. If you have push rights that is fine; otherwise usethis::pr_push() will fail.

Next step is NEWS.md. The skill is firm that you write this one — it is the only text users read under the package name.

Shall I show you the recent entries to match the style, or do you want to draft it straight away?

7.5.1 Update NEWS.md

The NEWS.md file keeps track of all changes made to the package over time. When a new release is published, it is the reference used to communicate which changes that release contains.

▤ NEWS.md

# posterior (development version)

* Ensure `summarise_draws()` preserves the type of each summary, so that a
function returning a character or factor no longer coerces the numeric
columns to character (#355).

...

7.5.2 Run CRAN checks

Run the same checks that are run when a package is released to CRAN. This is a larger test suite that builds the vignettes and checks the documentation, the examples, and more. Depending on the package, this can take a while.

Claude

Then step 9. devtools::check() runs what CRAN and the CI run: it rebuilds the docs, runs the full suite in a clean session, and checks the examples and NEWS.md formatting. Finding a NOTE here costs you minutes; finding it in review costs you days. It takes several minutes.

devtools::check()

Run it, or shall I? Paste the summary lines it ends with.

You

Run check() command and paste output.

7.5.2.1 Add, commit, and push your changes (open your PR)

Next, add, commit, and push the changes with git. Commit in reasonable chunks and make sure that no unintended changes end up in the pull request. This is not always easy, especially for larger refactorings. Commit histories can be cleaned up afterwards (e.g., by squashing commits), but for the Stan R packages we would not recommend it: it is extra effort for the contributor without much benefit. Bottom line: try to work methodically and commit cleanly, but don’t panic if your history is a bit messy. Just leave it as is.

git add R/summarise_draws.R
git commit -m "fix: summarise_draws_helper collects summary values column-wise"

git add tests/testthat/test-summarise_draws.R
git commit -m "tests: add tests for updated summarise_draws_helper"

git add NEWS.md
git commit -m "chore: update NEWS.md"

Finally, open a pull request with

usethis::pr_push()

7.6 PR description

The final task is to write the pull request description. The pr-description.md template helps you with this.

Following that template, a description for our example could look as follows:

▤ pr-description.md

Closes #355

## Description

`summarise_draws()` collected all summary values in a matrix, which holds one
type, so a character-valued summary function coerced every column to character.
The values are now collected in a list per variable and bound column-wise with
`vctrs::vec_c()`, so each column keeps the type its function returned. This
follows the approach agreed in the issue thread.

## Breaking changes

A summary function whose return type differs across variables now raises an
error from `vec_c()` instead of coercing to character.

## Tests

Added a test that summary functions returning character, factor, and logical
leave the `mean` column `double`.

    [ FAIL 0 | WARN 0 | SKIP 11 | PASS 2032 ]
    devtools::check(): 0 errors | 0 warnings | 0 notes

## Documentation

`NEWS.md` bullet added. No user-facing docs changed; the new helper is internal.

## Open questions / Uncertainties

- I added the `skills/` folder to `.Rbuildignore`. If this is not desired, I can revert that change.

## AI assistance

Claude helped with drafting this PR. I reviewed every change and can
explain it.

## Copyright and Licensing

Copyright holder: USER or UNIVERSITY

By submitting this pull request, the copyright holder agrees to license the
submitted work under the following licenses:

- Code: BSD 3-clause (https://opensource.org/licenses/BSD-3-Clause)
- Documentation: CC-BY 4.0 (https://creativecommons.org/licenses/by/4.0/)

## Checklist

- [x] Style is consistent with the existing code and docs
- ~[ ] Documentation or vignette renders locally~
- [x] Tests pass, with the output pasted above
- [x] Full check passes (`devtools::check()` / `pytest` + `ruff`)
- [x] Changelog handled — `NEWS.md` entry added, or the title is
      changelog-worthy where the changelog is generated
- [x] Copyright holder named
- [x] Every file in the diff is explainable
- [x] AI assistance disclosed, per the
      [AI Contribution Policy](https://github.com/stan-dev/stan/wiki/AI-Contribution-Policy)