Rapid Learning Skill for Code

AI
R
Author

Nic Crane

Published

September 24, 2026

I posted last year about using AI to help me learn. I had read a paper that suggested how ChatGPT could help with learning, based on a framework of self-directed learning, and tried it out by pasting a whole chunk of the paper into Claude. It worked really well, and since then I’ve been using the same framework to level up my skills and learn about new codebases.

Today I published it as a skill to share it with others and make it easier to work with from a CLI.

In this blog post I’ll explain more about it, and show you how to use it yourself.

The framework and my additions

The original framework by Lin (2003) described various steps relating to self-direccted learning, from setting goals, to selecting activities, and assessing strategies - see my previous post for more details on this. I extended it specifically for practices I find useful when learning a new codebase, via trial and error:

  • timeboxing the activities to a particular length to make them predictable and avoid falling into a rabbit hole
  • saving notes to refer back to later, with specific sections for human-generated content vs. AI-generated content
  • using checkpoint questions where the agent asks me questions before we move on, to make sure I have good understanding
  • having the agent create visual diagrams via ASCII drawings of code flow to use as a reference when learning the structure of the code
  • using the Socratic method - the agent asks questions, I answer, and the agent patiently guides through any mistakes
  • the agents asks me to guess what a particular piece of code doe, and then we verify what was correct/incorrect

A concrete example

The problem

Here’s a concrete example of where I’ve used this. I was working on ellmer, an R package for working with LLMs directly from R. The particular problem I was trying to solve required me to understand some internals of ellmer I’d not worked with before - how tool calls work.

To really understand this properly, I needed to trace the code from input to output via complex internals, so I could see where tool calls happened in the flow. There’s a lot of code here, some packages I’ve never worked with before, and concepts I was unfamiliar with, but the nice thing about using the skill was that it reduced the amount of cognitive load by just pointing me to the relevant information.

What it did

You can see the full output file saved from the session in the collapsible block below, but essential the key things it did were:

  • create an ASCII diagram of code flow to refer to
  • get me to read the relevant bits of code by asking me small scoped questions
  • correcting any mistakes I make in my response
  • summarising my responses and the connection between what I’d learned and the original goal

I found it really useful - I’m not sure how long it would have taken to understand this bit of the codebase without the extra help, but I suspect it would have been a long week of intense frustration!

(I’d already examined this loop in a previous session, so the agent drew a diagram to help me refresh my understanding and trace through how #858 fits into it.)

The ASCII diagram created by the agent

chat$chat("hello")
  |
  +-- complete_dangling_tool_requests()  [no-op on happy path]
  +-- user_turn("hello")                [wraps input into Turn with ContentText]
  |
  +-- chat_impl(turn)                   [THE LOOP]
       |
       +-- submit_turns(user_turn)
       |    +-- chat_perform(provider, ...)
       |    |    +-- chat_request()      [GENERIC: provider builds HTTP request]
       |    |    |    +-- chat_body()    [GENERIC: provider builds JSON body]
       |    |    |         +-- as_json() [GENERIC: per provider + content type]
       |    |    +-- req_perform()       [httr2 sends HTTP request]
       |    |
       |    +-- accumulator$add_turn()   [parses response]
       |         +-- value_turn()        [GENERIC: provider parses JSON -> Turn/Content]
       |
       +-- set user_turn = NULL
       +-- assistant has tool requests?
       |    +-- NO  -> loop exits (user_turn is already NULL)
       |    +-- YES -> invoke_tools() -> tool_results_as_turn() -> reassign user_turn, loop back
       |
       +-- return assistant turn

What the LLM saved as output from our conversation - including the speech-to-text transcription I used to chat with the model (tidied up slightly for readability) - can be seen below.

Understanding the ellmer chat path

The chat path at a glance

chat$chat("hello")
  |
  +-- complete_dangling_tool_requests()  [no-op on happy path]
  +-- user_turn("hello")                [wraps input into Turn with ContentText]
  |
  +-- chat_impl(turn)                   [THE LOOP]
       |
       +-- submit_turns(user_turn)
       |    +-- chat_perform(provider, ...)
       |    |    +-- chat_request()      [GENERIC: provider builds HTTP request]
       |    |    |    +-- chat_body()    [GENERIC: provider builds JSON body]
       |    |    |         +-- as_json() [GENERIC: per provider + content type]
       |    |    +-- req_perform()       [httr2 sends HTTP request]
       |    |
       |    +-- accumulator$add_turn()   [parses response]
       |         +-- value_turn()        [GENERIC: provider parses JSON -> Turn/Content]
       |
       +-- set user_turn = NULL
       +-- assistant has tool requests?
       |    +-- NO  -> loop exits (user_turn is already NULL)
       |    +-- YES -> invoke_tools() -> tool_results_as_turn() -> reassign user_turn, loop back
       |
       +-- return assistant turn

Key concepts

  • Turns contain a list of Content objects. A Turn is either a user turn or an assistant turn.
  • “User turn” in LLM API terms means “anything that isn’t the assistant” – this includes tool results, not just human input.
  • The tool loop is the core engine: submit a turn, check if the assistant wants tools called, if yes invoke them and loop, if no we’re done. On the happy path (no tools) it runs exactly once.
  • Provider-specific behavior lives in only a few generics. On the outbound side: chat_request(), chat_body(), and as_json(). On the inbound side: value_turn(). Everything else is shared machinery.
  • as_json() uses double dispatch on both provider and content type, so there’s a method per combination (e.g. ProviderAnthropic + ContentText).
  • chat_perform() is deliberately not generic – it takes the provider as an argument but doesn’t dispatch on it. The streaming vs non-streaming modes are httr2 delivery mechanisms, not provider differences.

Content lifecycle

  1. Created from user input: raw strings/images -> as_content() -> Content objects in a Turn
  2. Serialized outbound: Content objects -> as_json() (per provider + content type) -> JSON in HTTP request body
  3. Parsed inbound: JSON response -> value_turn() (per provider) -> Content objects in an assistant Turn
  4. Read by user: Turn -> turn@text -> returned string

Connection to #858

Issue #858 is about the tool result path: when a tool’s R function returns a value, that value needs to become JSON to send back to the LLM. Currently the coercion from R object to JSON is deferred to tool_string(). The issue proposes doing it earlier, at invoke time in invoke_tools(), so the value is validated up front. This lives in the “YES” branch of the tool loop in chat_impl.

Files touched

  • R/chat.RChat class, chat() method, chat_impl, submit_turns
  • R/httr2.Rchat_perform() (not generic, shared across providers)
  • R/provider.Rchat_request() generic and default method
  • R/provider-anthropic.R (and other provider files) – chat_body(), value_turn() methods
  • R/as-json.Ras_json() generic (double dispatch on provider + content/turn/type)
  • R/chat-tools.Rinvoke_tools(), tool_results_as_turn()

Extra notes

  • What is stream_controller?

🤖: “A mechanism for the caller to pause/resume/cancel streaming – like a remote control for the stream. shinychat uses it so the UI can stop generation if the user navigates away. Not relevant to the data flow itself.”

  • What is a generator / what does yield do?

🤖: “A generator (from the coro package) is a function that can return values one at a time and pause in between. yield(x) sends x back to the caller and suspends the function right there. When the caller asks for the next value, execution resumes from where it paused. This is how streaming works – each chunk gets yielded as it arrives. coro::collect() in the chat() method just drains all the yielded values at once.”

  • How do generators work with self/private in R6?

🤖: “The generators in ellmer aren’t R6 methods – they’re standalone functions that receive self and private as regular arguments. coro::generator() can’t create R6 methods directly, so the workaround is to pass the R6 object’s self and private into the generator as parameters. Inside the generator they work the same as they would in an R6 method.”

  • Design decision: why pass provider into chat_perform rather than making it generic?

🤖: “Because it does the same thing for every provider – build the request (delegating to generics for the provider-specific parts), then call httr2 to send it. Making it generic would let providers override the entire HTTP call pattern, which would be a footgun. By keeping it non-generic, the design forces provider differences into the narrower, well-defined generics (chat_request, chat_body, value_turn).”


Session detail: questions and answers

1. Can you find where the chat() method is defined? What file is it in, and roughly what line? What’s the first thing it does?

The first thing that it does is that it completes any dangling tool requests which is some defensive code that was added in case this is an assistant turn, I think, and so it wouldn’t actually do much on a normal happy path and it would do something in a more complex flow.

🤖: “The dangling tool request completion is defensive code. On the happy path with chat$chat("hello") it’s a no-op. It matters when there are unfinished tool calls from a previous turn.”

2. After the dangling tool request code, the next line is turn <- user_turn(!!!finish_tools, ...). What does user_turn() do? What goes in and what comes out?

The output of that is the results from those dangling tool requests—things like the results of function calls. It creates a user turn, and we have to be careful about language here because it doesn’t mean a message from the user—it could be the result of a tool call. But because LLM APIs only have assistant and user roles, it’s called a user turn. What goes into it are those dangling tool calls and anything passed in the ellipses, which could be the actual user’s message that’s later passed into the chat implementation.

🤖: “You’re right that ‘user turn’ in LLM API terms means ‘anything that isn’t the assistant,’ which includes tool results. On the happy path, finish_tools is empty so it’s really just wrapping \"hello\" into a Turn object.”

3. What structure does user_turn() wrap the input into? (Look at the function.)

It takes the results of those dangling tool calls and the user input and wraps them. If there are multiple content objects (like ContentText, ContentImage, ContentPDF), it wraps them in a list. If it’s a single content object, it handles that. If something isn’t already a content object (like if someone passes a string), it converts it into one (like ContentText). So you end up with a list of content objects.

🤖: “user_turn() takes raw inputs (strings, images, etc.) and produces a Turn object whose contents are a list of Content objects (like ContentText). On the happy path with chat$chat("hello"), you get a Turn with a single ContentText("hello").”

4. Before you look at chat_impl, just from the context of the chat() method: what do you think chat_impl is responsible for? What goes in and what comes out? (Predict first, don’t read it yet.)

a turn goes in which is that user turn that we just created and we set a few things about like whether we want streaming or echo and I don’t know what the stream controller is but I think we get back and assistant turn and I think that it probably adds it to the list of turns in the chat object somewhere

🤖: “Good prediction. A Turn goes in, and it does add turns to the chat’s history. The stream controller is about controlling streaming flow – not core to the path. Park it as a dangling question.”

5. Now go read chat_impl (it’s a private method in the same file). What are the major steps it takes? Remember: what it does, not how. Don’t get lost in the details.

ok so it’s a generator method which I don’t know what that is yet so that is something to come back to later and it operates on the chat object itself it takes the user turn and the configuration of streaming and things and a few other things. first it handles any tool errors then it does some telemetry stuff which is tracking which we can leave alone and then we start a while loop. So basically while user turn isn’t null—something later will set it to null—it submits the user turn (which I’d already examined) and it receives a response back. It yields the chunk, sets the assistant turn to the last turn (which submit_turns creates), and then sets the user turn to null. If the assistant has a tool request, it calls invoke_tools to run those tools, pulls out the results, and converts them into a Turn that goes back into the loop.

🤖: “chat_impl is a loop. (1) Submit user turn, get assistant turn. (2) Did the assistant request tools? If no, done. If yes, invoke the tools, package results as a new user turn, loop back to step 1. On the happy path (no tools), the loop runs exactly once. The generator/yield stuff is about how results are delivered (lazily), not what happens.”

6. You mentioned submit_turns inside chat_impl. This is the next layer down – it’s where the Turn gets sent to the actual LLM. Find it and describe what it does at a high level. What’s its job?

ok so the user turn is whatever is going to be passed to the LLM and I don’t understand how these generator methods work with S7 classes because it’s interesting passing self the object itself and then private which I wonder if it’s the private methods but I know we can park that and come back to it but it passes in the user turn and then the config stuff and then it does some telemetry stuff which we don’t need to think about now either and then it calls chat_perform which I think literally is another sort of layer of implementation it does the chat it gets response back and it calls the accumulator which literally accumulates these turns together and without jumping too far into that it basically populates this turn object and then depending on various bits of config and what’s returned it returns something so basically to zoom out a second and try to answer the original question it submits the turn to the provider and it gets whatever the provider has returned in the form of accumulated turns

🤖: “submit_turns orchestrates the call: it sends the turn to the provider via chat_perform, and accumulates what comes back into Turn objects. It’s the bridge between the chat loop and the actual HTTP call.”

7. You found chat_perform inside submit_turns. This is where we cross from the Chat object into the Provider. Find chat_perform and tell me: what does it do, and where does provider-specific behavior start?

ok so this function is interesting it’s in a file called httr2 and there’s a comment saying that it’s not generic because it doesn’t vary between providers and they actually pass in the provider which is an interesting design decision that I’d like to revisit but park it so it takes the provider and some config and the turns and tools and telemetry stuff and there’s a function chat_request which the provider and all of this stuff is passed into to get this request and then depending on the mode we call different functions to literally perform the chat thing so the provider specific behaviour happens inside the request construction in chat_request and also in the chat_perform functions for stream or async_stream or just req_perform which I don’t really know what those modes are

🤖: “chat_perform is deliberately not generic – it’s the same for every provider. The three modes (req_perform_stream, req_perform_async_stream, req_perform) are httr2 functions for streaming vs non-streaming delivery – a ‘how’ detail, not a ‘what’ detail. The provider-specific behavior lives in chat_request(), which builds the HTTP request differently for each provider.”

8. Find chat_request. It’s an S7 generic. What does it take in, and what does it produce? You don’t need to read any specific provider method – just the generic and maybe one example to get the idea.

ok so chat_request is a generic that dispatches on the provider and it takes the provider and the config and then it calls chat_body which creates the chat body in JSON, it does path construction stuff, adds any headers and gives back the request that’s been constructed from all the things that have been put into it so it probably takes R objects and makes the overall JSON thing at the very end of it

🤖: “chat_request dispatches on the provider to build the HTTP request. It delegates to sub-generics like chat_body() for the request body. The body is built as R objects, then serialized to JSON at the end. This is the deepest provider-specific layer on the outbound path.”

9. We’ve traced the outbound path. Now trace the return: back in submit_turns, after chat_perform returns, what happens to the response? Look at what the “accumulator” does – how does raw HTTP response data become an AssistantTurn?

ok so I guess chat_perform returns a response and the response is in JSON response data so we start a turn accumulator and skip to the non-streaming one because it’s probably simpler. It gets the JSON body from that response, pulls out things like the duration, does some telemetry stuff, then creates a variable turn which is made from getting the accumulator to add the turn now that it’s extracted the relevant fields from the response, and then it calls yield to give it back. Yield is probably a little bit like return as a function but it’s different

🤖: “The non-streaming path is simpler and the result is the same. The response comes back as JSON, and the accumulator’s job is to parse it into a Turn object. Your yield intuition is correct – it’s like return but the generator can keep going afterwards.”

10. Inside the accumulator or the functions it calls, there’s a provider-specific generic that parses the JSON response into Turn/Content objects (the mirror of chat_body on the outbound side). Can you find it?

So the turn accumulator calls add_turn which then calls the value_turn method. The value_turn method first calls value_finish_reason (which you added) to extract the reason the turn finished and check for warnings, then it calls another value_turn function which is the provider-specific generic. It takes the provider and the results and constructs a turn from it. It’s a generic that dispatches on provider and for each item in the response JSON content, depending on the type it constructs the matching content object.

🤖: “value_turn() is the mirror of chat_body(). Each provider implements both: chat_body() converts Turn/Content objects into provider-specific JSON for the request, and value_turn() converts provider-specific JSON from the response back into Turn/Content objects. The accumulator’s add_turn method calls value_turn() to do this parsing, and you also spotted that value_finish_reason() (which you added!) runs first to check how the turn ended.”

11. In chat_body(), how do Content objects get converted into the provider’s JSON format?

So chat_request is a generic that dispatches on the provider, but interestingly it’s not defined for every single provider—there’s a default one that works for most. Some providers override it to do extra work before or after calling chat_body(). Inside chat_body, you can see where it calls as_json() to construct messages, but I won’t go too deeply into that yet.

🤖: “Good spot – chat_request has a default that works for most providers, and some providers override it to add extra work before/after chat_body(). Inside chat_body, you found as_json – that’s the bridge from Content objects to provider-specific JSON.”

12. as_json is a generic too. What does it dispatch on?

so the as_json function in as_json.R dispatches on both the provider and the type—that’s the type of the thing that’s passed in. So whether it’s a content object, a turn object, or other things that need to get converted to JSON, it dispatches on the provider and the individual thing. That means whether it’s a ContentText or a ContentToolRequest, you’d have different methods for each provider-type combination.

🤖: “as_json dispatches on both the provider and the content type. So there’s a method for every combination: (ProviderAnthropic, ContentText), (ProviderOpenAI, ContentImageRemote), etc. This matters for #858: if you add a new Content type or change what one carries, you need as_json methods per provider (outbound) and value_turn handling per provider (inbound). The type_* stuff you saw is the structured output / tool parameter schema system – separate from Content, park it.”

13. What Content types does value_turn() produce from JSON?

Ok, looking at the value_turn method for provider anthropic, the mapping seems straightforward. It takes whatever contents have been returned, uses the type field to check what type of content it is, extracts the relevant fields and wraps it in a content object of the right class.

🤖: “Correct. value_turn() checks the type field in each JSON content item and constructs the matching Content class (\"text\" -> ContentText, \"tool_use\" -> ContentToolRequest, etc.). Each provider has its own type names but the pattern is the same.”

14. Where does #858 live in the flow? Trace from invoke_tools() to the Turn that goes back to the LLM.

So invoke_tools is called from inside chat_impl in the loop. We call the invoke_tools function and get the tool calls. Then we cycle through that to populate the tool_results variable which gets turned into a turn via tool_results_as_turn(). That function takes the results, checks that they’re all tool results, and if so creates a user turn containing the tool results that goes back into the loop.

🤖: “invoke_tools() runs the R functions, produces tool results, then tool_results_as_turn() wraps them into a user Turn that goes back into the loop. #858 is about when the raw R return value gets converted to JSON – currently it’s deferred to tool_string(), but the issue says do it at invoke time so it’s validated up front. You’ve now connected your map to where #858 lives in the flow.”

Quiz

Q1. Name the major functions in order from chat$chat("hello") to HTTP request sent.

complete_dangling_tool_requests() -> user_turn() -> chat_impl -> submit_turns -> chat_perform -> chat_request -> req_perform().

Q2. Two provider-specific generics on the critical path?

chat_body() outbound, value_turn() inbound. (Initially said chat_perform but corrected – it takes provider as argument but doesn’t dispatch on it.)

Q3. What goes back as user turn after a tool call?

A Turn containing ContentToolResult objects, created via tool_results_as_turn().

Q4. What’s special about as_json dispatch?

Double dispatch on provider AND content type.

Q5. Explain chat_impl’s while loop in two sentences.

It submits the user turn to the LLM and gets an assistant turn back. If the assistant requested tool calls, it invokes them, packages the results as a new user turn, and loops back until the assistant gives a final response with no tool requests.

Working with the skill

Getting the skill

You can get the raw markdown for the skill at https://github.com/thisisnic/rapid-learning/blob/main/SKILL.md or check out the full repo

Installing via Claude CLI

Alternatively, if you’re using the Claude CLI, you can install it by running:

/plugin marketplace add thisisnic/rapid-learning
/plugin install rapid-learning@rapid-learning

Customizing for your needs

It is pretty tailored to me, and what I’d actually recommend is using the overall framework as a starting point and writing your own.

Caveats and Recommendations

Model performance varies

I’ve had different degrees of success using the skill based on the model I’ve been using. I’ve been working mostly with more capable Claude models like more recent versions of Sonnet, Opus, and Fable, and got pretty good results.

Before I released the skill, I did experiment with DeepSeek V4.1 Flash and Claude Haiku 4.5 to see if it was compatible with cheaper models, but unfortunately I got much worse results. Failure modes varied, but included things like incorrect assumptions made about the coding concepts I was trying to learn, overly complex or verbose explanations, and poor teaching practices like single letter variable names.

Best practices

Regardless of model, I recommend trying this out with a concrete problem to solve or concept to learn about an existing codebase, as that’s where it’s really helped me the most.

Try speech-to-text

Another thing that I found particularly helpful is using some sort of speech-to-text input so I can ask my questions naturally without having to type them. I’ve installed an app on my phone that allows it to function as a Bluetooth keyboard and talk directly to my laptop.