Re-building anndata in R with Claude Code
Introduction
Since 2023, I have been part of the team developing {anndataR}, an R implementation of the Python anndata package. The main goal of the project was to make it easier for researchers analysing single-cell data move between the Python and R ecosystems, allowing them to take advantage of the strengths of both. We were not the first people to do this, but existing solutions were usually incomplete, often not published on CRAN or Bioconductor and, like my previous work on {zellkonverter}, typically relied on a parallel Python installation which makes life more difficult for R users. While we originally focused on reading/writing H5AD files to/from R objects like SingleCellExperiment and Seurat, this evolved into a full implementation of the AnnData object with in-memory, HDF5, Zarr and Python ({reticulate}) backends.
With the rapid rise of agentic coding tools, we have seen more and more automated porting of bioinformatics tools into other languages such as re-writing them in Rust for speed or translating them to Python to make them accessible to a new ecosystem. We have also seen large-scale efforts to use agentic coding to optimise existing tools. Given the rapid development in this space I wanted to see how Claude Code would go at creating an R version of anndata. I think this is an interesting task for a couple of reasons:
- There is a reference Python implementation to use for design and validation
- There is an existing human-written package to compare the final package against
- It presents some challenges in that we are trying to recreate a data structure from one language in another that has a fairly different approach and limitations
- It’s relatively small so it should be achievable within the constraints of a personal Claude Pro subscription
I use these tools a lot in my work but this was the first time using it to fully automate writing some software from scratch so I was keen to see how easy it was in practice. I was also interested to see how Claude tackled the problem, what issues it ran into and if there was anything we can learn from how to solved them that could be applied to {anndataR}.
The final package is available on GitHub. This repository also includes scripts, tasks and note files created during development to record more details of the process itself. The documentation for the package is available on this pkgdown site.
Setup
Here is the initial prompt I ran after setting up an empty git repository for the project and running /setup-matt-pocock-skills:
This is a new project to develop an R version of the Python anndata library using Claude. In this conversation we are going to develop the specifications for this project that will be used by future agents. Read the Python library https://github.com/scverse/anndata then use start a conversation to create the specifications. Use the most recent release as a reference. NOTE: there are existing R anndata implementations but they should be ignored by this project, using only the Python implementation and documentation as a reference. Is there anything you need to know before we start?
/mattpocock-skills:grill-with-docs
If you haven’t seen Matt Pocock’s skills before, they provide structured instructions for how the coding agent should plan and implement a project. The setup skill asks where issues for the project live, what labels they have and where documentation on the project domain should go. For a real project, the issues can live on GitHub or another issue tracker but for this I just used local Markdown files.
The /grill-with-docs skill asks you questions about a plan, helping to make design decisions, then documenting them. Once the design process is complete, other skills help with creating a detailed specification and splitting that up into individual tickets to complete. There are other skills that can help with other steps in the the process but I haven’t tried most of them.
With the design settled, we needed to design how to implement it. The agent suggested a sub-agent development model (probably based on the superpowers skill) but I add recently read about the idea of development using a Ralph loop. If the idea is also new to you, the setup runs a loop that invokes a simple prompt with roughly these instructions:
1. Read the specification
2. Read the plan for what needs to be fixed
3. Pick the most important tasks and fix it
4. Update the documentation
You can see the full Ralph prompt here.
You run that as many times as needed until all tasks and issues have been completed. The power of the approach lies in it’s simplicity. Unlike a sub-agent approach there is no main orchestrator that controls the tasks and has context on the whole project. Instead, each loop focuses on a specific task and only needs the context to complete that job, potentially allowing you to use smaller and cheaper models. When a task is finished, it updates the tickets but can also record anything it has learned for future runs.
In the end, I used a somewhat hybrid approach. Most of the development was done using the Ralph loop1, but within the loop there were instructions to use sub-agents for running checks and reviewing changes (along with a couple of other tasks). I also kept the session I had used for the design around as a kind of supervisor, running the loop as needed, catching and fixing issues as needed and asking further design questions as we reached tasks that were deferred in the initial design.
Because I wanted this to be an AI-driven experiment, I mostly accepted the suggested answers for what to do, occasionally limiting the scope to avoid spending time on things like deprecated features. The one time I nudged it in another direction was when it was selecting the library to use for the Zarr implementation. It found issues with all the current release packages and was going to start implementing things from scratch. I knew a lot of those issues were addressed in the release version of {Rarr} so I told it to check that first, after which it decided that was the best option. I wanted this to be independent so the prompt and documentation told it to ignore {anndataR} and other current packages but there it’s more than possible that some information leaked over.
Process
Before we consider what was actually produced, let’s look at some number from the log files for what happened during the process.
Interactive sessions
Sessions where I was actively involved a prompting Claude (Opus 5.5):
| Metric | Value |
|---|---|
| Sessions | 11 |
| Prompts | About 175 |
| API requests | About 900 |
| Tokens | 207M cached input read, 11.2M written to the cache, 0.57M output |
| Estimated cost | About $1452: ~$41 reading from the cache, ~$90 writing to it and ~$11 output |
Loop runs
Sessions run during the Ralph loop:
| Metric | Value |
|---|---|
| Runs | 189: 169 new runs and 20 resumed after a usage limit or a human decision |
| Turns (main agent) | 9,239 |
| Tool calls (main agent) | 8,709: 2,967 Bash, 2,706 Edit, 1,609 Read, 955 Agent, 402 Write |
| Subagent calls | 955: 736 checker, 163 reviewer, 31 python-oracle, 24 searcher |
| API requests | About 9,600 |
| Run time | About 66 hours (median run 15 minutes, longest run 2.6 hours) |
| Cost | $224.60: $204.07 Sonnet 5.5, $20.02 Haiku 4.5, $0.50 Haiku 5.5 |
| Tokens | About 486M cached input read, 21M written to the cache and 4.4M output for Sonnet. Haiku used about 70M total. |
Overall, managing this was not a lot of work. After the setup step, not a lot of input was required from me other than approving design decisions at the start of a new sub-project. The first few runs ran into some minor issues like missing dependencies or incorrect permissions but after I gave the supervisor session to correct these the loop generally ran until it ran out of tasks.
Output
Here are some statistics about the work the loop did:
| Metric | Value |
|---|---|
| Tickets | 110 across 12 sub-projects: 108 done and 2 won’t fix |
| Commits | 246: 146 by Sonnet, 96 by Opus and 4 by me |
| Tags | 111: development versions plus releases 0.0.1–0.0.6 |
| Code | 14.9k lines of R in 55 files, 518 lines of C |
| Tests | 19.8k lines in 81 files (about 24,000 expectations), plus 6.6k lines of Python oracle scripts |
I haven’t gone over all the code in detail, but as usual with AI code it is pretty clear and well organised. There are some parts that will take be a while to wrap my head around. Not necessarily because the code itself is particularly complex but because I don’t have the mental model to understand the context and how and why it works that way.
This is one of the challenges of letting the agents control the project. I’m pretty confident that it works because I have seen the process and I know that the tests pass but I don’t have an understanding of the internals. It would take me a lot more effort to find and correct a bug here than it would in {anndataR}, once you head done the road of letting AI write the code you are kinda locked into that path, unless you can invest the effort in understanding a complex project.
Comparison
This table summarises some of the key differences between this new package ({anndata.claude}) and {anndataR} v1.3.3:
| Area | {anndataR} | {anndata.claude} |
|---|---|---|
| Goal | Interoperability and conversion | Parity with Python anndata v0.13.4 |
| Object model | R6 AbstractAnnData with InMemoryAnnData, HDF5AnnData, ZarrAnnData and ReticulateAnnData |
One R6 AnnData, with backed, lazy and view as states |
| H5AD read and write | Yes | Yes |
| Zarr read and write | v2 and v3 | v2 and v3 read, v3 write only |
| Compression | H5AD: gzip, lzf (not booleans). zarr: gzip, blosc, zstd, lzma, bz2, zlib, lz4 (default none) | gzip, lzf, zstd, blosc and zarr sharding |
| Python dtypes on disk | Not kept (float32 becomes float64, for example) | Kept |
raw |
No | Yes |
| Views | AnnDataView, a lazy subset applied when converted |
Lazy views with copy-on-write |
| File-backed objects | HDF5AnnData / ZarrAnnData read and write slots in the file, backed = TRUE gives DelayedArray X |
H5AD backed mode (partial reads, in-place writes, chunked_X()) |
| Lazy reading | Only X through DelayedArray |
read_lazy() and read_elem_lazy() for every slot |
concat() |
No | Yes, all Python merge strategies |
| Files from anndata < 0.8 | No | Read with Python’s warnings |
| Other readers | None | csv, mtx, text, excel, hdf, umi_tools and write_csvs() |
SingleCellExperiment |
To and from | No |
Seurat |
To and from | No |
| Python bridge | ReticulateAnnData |
None |
| Python anndata v0.13 | Partly supported | The reference version |
| Rec-arrays | Read as dicts and written back as dicts | Kept, with field dtypes (through a C helper) |
A lot of the differences in functionality come down to the scope of the two projects. For {anndataR}, we focused on what was needed for interoperability, so some way to read and write H5AD files, conversion to SingleCellExperiment and Seurat and a connection to Python3. In contrast, this new package focuses on a direct port of the Python package. It’s implementation of that part is more complete, and I suspect the code would be more familiar to the Python anndata developers. This vignette, describes differences from Python that were noted during the development process.
The design of the core AnnData object itself is interesting. In {anndataR} we have an abstract class for shared functionality and then concrete classes for different backends which inherit from that. I can’t remember the details of why we went with that approach but I think it was at least partially to allow people to work on different parts during the initial hackathon. The new implementation also uses R6, but with a single class with a lot of different functionality. This more closely matches the Python approach but it would be interesting to look more into the pros and cons of each design.
Because this was a port, the conversion functions to different R formats were deliberately out of scope. This is something we have spent a lot of time on in {anndataR}, partly because it requires a deep understanding of the differences between each of the objects, and partly because it is challenging to design an interface that makes conversion easy but is still flexible enough to give users control when needed (which it often is because of the wide range of ways these objects are used in practice). It would be interesting to see how the agents go at this more creative task where there isn’t a clear reference implementation, I might look at running that as a follow up at some.
Completeness
This table summarises the results of round trip testing, where a file containing a specific data type is written by Python, read an written by R, and then read by Python and compared to the original value:
| Result | {anndataR} | {anndata.claude} |
|---|---|---|
| Equal, same encoding and dtype | 111 | 155 |
| Equal, different encoding or dtype | 0 | 2 |
| Python reads it but values differ | 31 | 0 |
| Python cannot read the file | 14 | 0 |
| R did not write the element | 4 (plus 4 nested AnnData dropped) |
3 (by design) |
This shows some of the advantages of an agentic approach. A human eventually runs out of time or patience but an agent is happy to run until all tasks are complete and all tests pass. We have a fairly extensive round trip testing framework in {anndataR} but this (and issues people have reported) show that there are some things we missed. At least partly it is because we check that values match after a round trip while the new package also checks for the dtype and encoding. To get everything to pass the agents created a system using attributes to record data types, which makes sense but may be brittle in real usage.
Along the way, the agent solved some issues we currently have open, such as support for non-ASCII strings and consistent handling of recarrays. Solving the recarray round trip required a new C helper to access functionality not exposed by {rhdf5}, something I would never have attempted but for which we now have a prototype fix for.
Performance
This table shows the time (in seconds) taken for different reading and writing tasks on the PBMC 3K dataset and a 4 GB file of 84k vascular cells downloaded from CELLxGENE:
| File | Operation | {anndataR} | {anndata.claude} | Python |
|---|---|---|---|---|
| PBMC 3k | read_h5ad |
0.8 secs | 1.7 secs | 0.09 secs |
| PBMC 3k | write_h5ad |
1.3 | 1.1 | 0.09 |
| PBMC 3k | write_zarr |
1.8 | 2.8 | 0.71 |
| PBMC 3k | read_zarr |
0.5 | 1.8 | 0.13 |
| 4 GB | read_h5ad |
10.9 | 35.7 | 5.8 |
| 4 GB | write_h5ad |
5.5 | 24.5 | 15.3 |
| 4 GB | write_zarr |
20.6 | 85.1 | 16.2 |
| 4 GB | read_zarr |
13.0 | 112.3 | 10.0 |
This table gives information about memory usage on the 4 GB file:
| 4 GB file | {anndataR} | {anndata.claude} |
|---|---|---|
Peak memory, read_h5ad() only |
3.0 GB | 5.4 GB |
| Peak memory, all four operations | 8.4 GB | 9.4 GB |
| Memory after loading the package | 0.25 GB | 0.53 GB |
| H5AD written | 1.9 GB | 3.8 GB |
| Zarr written | 1.6 GB | 0.6 GB |
We have put some work into optimising {anndataR} but not a lot and I had expected the AI-written code to be more performant by default. In fact, we see that {anndataR} is much overall. That said, it’s not exactly a fair comparison. There is no support for the raw slot in {anndataR} so it’s actually doing less work than the new package. The Zarr files written by {anndataR} don’t use any compression by default, while they new package matches the Python zstd default which explains why the {anndataR} Zarr is so much larger.
You can find a more detailed comparison from Claude here.
Lessons learned
- This process works, and it’s easy. I didn’t put much effort into refining the development approach or the design and created something very functional within a week. If we were creating {anndataR} now, I would definitely use this as a starting point.
- It’s not as fun, but it’s fast. Agentic coding doesn’t give you the same flow state and satisfaction of solving complex problems as doing it yourself but it greatly expands your capacity to get things done. To get to where we are with {anndataR} has taken multiple people several years, mostly as a side project. With this technology, I was able to recreate a large chunk of it within a week. I can think of several other projects I have wanted to do but never got around to that could be approached this way.
- It’s thorough, when there’s truth. The new package solves several existing issues in {anndataR}, and covers others that we weren’t even aware of. Computers are good at trying every possible option and coding agents are able to fix errors and edge cases but I think it can only be this thorough because there is a Python implementation to use as the ground truth. You would need to be more careful in a open-ended task were it is more difficult to validate the results.
To see more about what I did, check out GitHub and the pkgdown site.
Footnotes
When writing this post, I found a bunch of tools for setting up and running a Ralph loop but I just let Claude set it up for me. Something to try next time.↩︎
All costs were covered by my subscription, I didn’t pay any additional API costs↩︎
This was initially out of scope and I was reluctant to add it but was eventually convinced there are a few use cases where it is worth having↩︎
Citation
@misc{zappia2026,
author = {Zappia, Luke},
title = {Re-Building Anndata in {R} with {Claude} {Code}},
date = {2026-10-25},
url = {https://lazappi.id.au/posts/2026-10-25-anndata-r-claude/},
langid = {en}
}