Part 1 ended with "Now we can actually build it." So in Part 2 we build it on both frameworks, and give each one the same request to compare the courses they make.
The first iteration built by yours truly Claude Opus 5.5 had 235 tests, and every single one of them passed. But when I started poking (ok, so this has been my huge limitation with Claude. It doesn't surface gaps until I start poking), I realized not one of those tests had ever talked to a real model 🙃
When I pointed it at a real model, it failed on the very first call. And when it finally did produce a Terraform course, the quiz answers sat right under the questions and every exercise came with the finished files.
So I deleted the entire folder and started from scratch. The rebuild came out much better, and it's the one we'll walk through in this post.
Rant Aside. Because accuracy is super matters to me, whenever I'm learning something new, I search for "the most up to date resource", and more than once I've bought a course only to realise a few lessons in that it's already out of date. Case in point: I started Part 1 a couple of weeks ago, and Strands has already shipped a whole agent harness.
We'll start with what using it looks like, then build it up side by side the way we did in Part 1.
Everything here ran between 27 and 29 September 2026, on the same versions as Part 1 (strands-agents 1.55.1 and google-adk 2.9.0), and the code is in the companion repo. Both frameworks are actively developed, and strands-agents 1.61.1 and google-adk 2.12.0 were out by the time this went up, so some APIs may have changed since.
What should a learning system actually do?
When I started over, I wrote down what I actually want from this thing:
- I say what I want to learn, and it asks a couple of questions to get the course right.
- It covers what people actually do with it at work. For example, pinning a shared module's version.
- It's as current as the docs on the day it's built.
- I learn by doing, and there are tests that verify my work.
- I have to try before I see the answer.
- It doesn't cost a fortune to build, and it tells me what it spent.
What does using it look like?
You'll need uv. Then install it once, straight from the repo:
uv tool install "git+https://github.com/coolchigi/strands-vs-adk#subdirectory=part2-learning-system" and start a course from anywhere:
learn "I want to learn TypeScript so I can build a small web app" The first time, it asks for a Gemini API key and saves it, so you never set it up again. That key is for the ADK build, which runs on Gemini and is the default. To build with Strands, which runs Claude on Amazon Bedrock, add --engine strands. You'll need AWS credentials and access to Claude in Bedrock, and the Strands Python quickstart walks through setting both up:
learn --engine strands "I want to learn TypeScript so I can build a small web app" If you already have AWS credentials and no Gemini key, it picks Strands on its own and doesn't ask for a key.
Every course has a spending cap, $5 on Gemini and $10 on Claude by default, and we'll get to how it works in the bill section.
Then it asks what it needs to know, and you answer right there:
A few questions first, so I build the right course: What is your background with programming, particularly JavaScript? > I know JavaScript well but have never used TypeScript What kind of web app are you looking to build, and do you have a specific framework in mind (e.g., React, Next.js, Node.js)? > a small web app with a backend API Got it. Building your TypeScript course in ~/courses/typescript This usually takes 30 to 90 minutes. You can stop it any time, and running the same command again picks up where it left off. Looking for the official TypeScript documentation ✓ Found it: TypeScript, version 7.0 ✓ Worked out 15 things you'll need to be able to do ✓ Checked 126 facts, word for word, against 17 official pages ✓ Planned 5 units, 12 lessons Writing lesson 1 of 12: Compiler CLI and Strict Type Validation ✓ passed its checks ... Your course is ready: ~/courses/typescript 12 lessons, and it cost about $2.94 to build. That's a trimmed output of the real run. Once the build finishes, you get a folder with a README.md for each lesson. Each one has the explanation, where every fact links to the page it came from, then a worked example, then an exercise with files for you to change.
You work through it with learn:
cd ~/courses/typescript learn next # where you are, and what's next learn check # run the exercise's tests learn hint # one hint at a time learn solution # the answer, when you want it learn quiz # answer first, then see the answer learn review # questions you missed come back after 1, 3, 7 and 21 days Lesson 1's exercise gives you a formatter.ts with two TODO comments, which mark the code you have to write. One of them is to give a parameter a type. Here's what learn check says if you fix the code but skip the type:
$ learn check Checking units/u1-strict-compiler-configuration-and-compilation/u1-l1-compiler-cli-and-strict-type-validation/exercise ... formatter.ts(1,28): error TS7006: Parameter 'input' implicitly has an 'any' type. Not yet. Read the messages above, change your files, and check again. Stuck? learn hint Add the type and it passes, then points you to learn quiz.
That error comes from the TypeScript compiler itself, because learn check runs each subject's real tools. So what you need installed depends on what you're learning. A Terraform course needs Terraform, a Python course needs Python 3.9 or newer, and a JavaScript or TypeScript course needs Node. Anything else has no tests, so you compare your work with the solution.
How is it built?
Behind those learn commands is the system we sketched in Part 1, with one new agent at the front:
learner ─▶ intake ─▶ researcher ─▶ curriculum builder ─▶ teacher ─▶ your course ▲ ▲ ▲ └──── the feedback agent reviews each stage, sends it back with reasons - The intake asks what it needs to know, like where you're starting from and what you want to build.
- The researcher reads the official docs and makes a list of what the course has to teach (i.e. its objectives). It only keeps facts from the live pages.
- The curriculum builder turns that list into units and lessons, and picks an exercise or a quiz to check each one.
- The teacher writes each lesson, including the tests that check your exercise.
- The feedback agent checks each stage's work and sends it back if something's wrong. Code checks what it can, like running every exercise, and a model reviews the rest.
Most of the system, like fetching pages and running tests, is plain Python that both sides share. The frameworks handle the parts this post compares: reaching the docs, getting typed answers back, running the feedback loop and keeping the bill down.
Both frameworks plug in through one method:
class Brain(Protocol): def ask(self, name: str, instruction: str, prompt: str, schema: type[T], research: bool = False) -> T: """One agent call, returning `schema`. With research=True the agent can search and read.""" Each framework implements ask its own way and runs the stages in its own graph. The Strands side uses Claude Sonnet 4.6 on Amazon Bedrock, which is Strands' default, and the ADK side uses Gemini 3.8 Flash. So when the two sides behave differently, it can be the framework or the model, and we'll say which one it was each time.
How does the researcher reach the docs?
The researcher needs tools to find the right pages and read them. Some of those tools come from MCP servers. MCP (Model Context Protocol) is an open source standard for connecting AI apps to outside tools and data, so the same server works with both Strands and ADK.
Here's how they both do it:
| Strands | ADK |
|---|---|
| No search in the package | google_search, Google Search built into Gemini only |
MCP servers go straight into tools | MCP servers come in through McpToolset |
web_fetch reads a page live | load_web_page, which I couldn't use |
Both sides also get the same two extra tools. One is AWS's documentation server, which searches and reads AWS's docs with no key. The other is list_docs_pages, a small tool we wrote that lists every page a docs site has, from the site's own sitemap.
Strands
Strands ships tools for fetching pages and none for searching, so I used an MCP server. Here we create an MCPClient and pass it straight into tools, and Strands opens and closes the connection for us. This is what connecting to the AWS docs server looks like:
from strands import Agent from strands.tools.mcp import MCPClient aws_docs = MCPClient(url="https://knowledge-mcp.global.api.aws") researcher = Agent(system_prompt="Answer from the AWS documentation, in one sentence.", tools=[aws_docs], callback_handler=None) print(researcher("How big can a single S3 object be?")) A single Amazon S3 object can be a maximum of **5 terabytes (TB)** in size, though the largest object that can be uploaded in a single PUT request is **5 GB** ... Keep that 5 TB in mind, because we'll ask the ADK side the same question.
For general web search I used Exa's hosted MCP server because it didn't need an API key. Its free tier ran out partway through a single course build, so on Strands, web search stays off unless you add an Exa or Tavily key. For now, you don't need one, because list_docs_pages finds the pages on the docs site itself.
To read a page, web_fetch has two modes. The default, agentic, hands the page to a second agent and returns that agent's summary, and markdown returns the page itself. We use markdown mode because, for every fact, the researcher copies the sentence on the page that backs it up, and our code checks that sentence is really there.
from strands.vended_tools.web_fetch import make_web_fetch fetch = make_web_fetch(mode="markdown", max_content_chars=50_000) It needs the web-fetch extra, so pip install "strands-agents[web-fetch]".
ADK
ADK ships search as google_search, and it only works on Gemini. On any other model, like Claude, the agent still builds, but the first call fails with ValueError: Google search tool is not supported for model ....
MCP servers come in through McpToolset. This is the same AWS docs server, connected next to google_search:
import asyncio from google.adk.agents import Agent from google.adk.runners import InMemoryRunner from google.adk.tools import google_search from google.adk.tools.mcp_tool import McpToolset, StreamableHTTPConnectionParams from google.genai import types aws_docs = McpToolset(connection_params=StreamableHTTPConnectionParams(url="https://knowledge-mcp.global.api.aws")) researcher = Agent( name="researcher", model="gemini-3.8-flash", instruction="Answer from the AWS documentation, in one sentence.", tools=[google_search, aws_docs], generate_content_config=types.GenerateContentConfig( tool_config=types.ToolConfig(include_server_side_tool_invocations=True) ), ) async def main() -> None: runner = InMemoryRunner(agent=researcher, app_name="docs") session = await runner.session_service.create_session(app_name="docs", user_id="me") message = types.Content(role="user", parts=[types.Part(text="How big can a single S3 object be?")]) async for event in runner.run_async(user_id="me", session_id=session.id, new_message=message): if event.is_final_response() and event.content and event.content.parts: print("".join(part.text or "" for part in event.content.parts)) await aws_docs.close() asyncio.run(main()) According to AWS documentation, a single Amazon S3 object can be up to 50 TB in size ... The Strands agent said 5 TB and the ADK agent said 50 TB, and AWS's own page says "each object can be up to 50 TB in size". Both agents had the same AWS docs server. Claude searched once, got a result that mentioned both 5 TB and 50 TB, and answered 5 TB. Gemini kept going, with more searches, and in both runs I traced it read the S3 FAQ page itself before answering. That's why nothing a model says goes into a course unless it comes with a quote from the live page.
If you use McpToolset yourself, run all your agent calls on one event loop. The toolset keeps its MCP session on the loop it first ran on. My first version called asyncio.run for every agent call, and asyncio.run closes its loop when it's done, so every call after the first found its session on a closed loop.
ADK has its own page loader, load_web_page, but it kept losing the pages we needed. Terraform's docs redirect a lot, and it won't follow a redirect, so it came back with Failed to fetch url: ... instead of the page. When a page did load, it threw away every line of 3 words or fewer, which took out 84 of the 181 lines in one tutorial's code examples, $ terraform init and required_providers { included. So on the ADK side, the agents find the pages, and our own code fetches them in full.
The ADK example also had a generate_content_config line we haven't explained yet. It's there because of the very first real call.
What happened on the first real call?
The researcher's first job is to find the official docs and the current version, and hand them back as a typed plan through output_schema, which is how ADK asks a model for a typed answer. The first real call came back with this:
400 INVALID_ARGUMENT: Please enable tool_config.include_server_side_tool_invocations to use Built-in tools with Function calling. Hmm, function calling. That's weird because I hadn't given it a function.
ADK had. When an agent has both output_schema and tools, ADK quietly adds a function tool called set_model_response, and the model hands back its typed answer by calling it. So my agent was sending Google Search, a built in tool, together with a function tool, and the Gemini API wants you to say that's on purpose.
I tried GoogleSearchTool(bypass_multi_tools_limit=True) first and got the same 400, because ADK adds set_model_response after that setting is checked.
The fix was what the error asked for, turning on include_server_side_tool_invocations:
import asyncio from google.adk.agents import Agent from google.adk.runners import InMemoryRunner from google.adk.tools import google_search from google.genai import types from pydantic import BaseModel class Plan(BaseModel): official_domains: list[str] current_version: str | None = None planner = Agent( name="plan", model="gemini-3.8-flash", instruction="Find the official docs domain and the current release of the subject. Use google_search.", tools=[google_search], output_schema=Plan, output_key="plan", generate_content_config=types.GenerateContentConfig( tool_config=types.ToolConfig(include_server_side_tool_invocations=True) ), ) async def main() -> None: runner = InMemoryRunner(agent=planner, app_name="plan") session = await runner.session_service.create_session(app_name="plan", user_id="me") message = types.Content(role="user", parts=[types.Part(text="Terraform")]) async for _ in runner.run_async(user_id="me", session_id=session.id, new_message=message): pass done = await runner.session_service.get_session(app_name="plan", user_id="me", session_id=session.id) print(done.state["plan"]) if __name__ == "__main__": asyncio.run(main()) {'official_domains': ['developer.hashicorp.com', 'terraform.io'], 'current_version': '1.16.4'} Without the generate_content_config line, the 400 comes back. None of my 235 tests could have caught this, because they ran against a fake model, and a fake model accepts any request.
Which one found the current version?
The plan step asks for the subject's current version. On 27 September Terraform's was 1.16.4, per HashiCorp's own release API. Here's every plan step I ran while the Strands side searched through Exa:
| Answer | Strands (Claude + Exa) | ADK (Gemini + Google Search) |
|---|---|---|
| 1.16.4 | 1 | 8 |
| 1.16.3 | 5 | 0 |
| 1.16.2 | 3 | 0 |
| "1.9.x (1.9 series, latest stable as of mid-2024)" | 1 | 0 |
Twice Claude quoted the Terraform install page as saying "Version: 1.16.3" when the live page said 1.16.4. Exa's copy of the page was a few days behind, and Claude was quoting the copy. ADK's search is Google's index, and Strands' search is whatever server you plug in, so checking how fresh it is falls to you.
So now the version gets checked like every other fact. The plan has to name an official page that states it, with the exact words, and our code fetches that page and checks the words are there. Once Strands had web_fetch to read the live page, it got 1.16.4 too.
There's one bug left here. Every course's README says which version it was written against. If the feedback agent sends research back for more facts, a version our code couldn't find on the live page, like Claude's 1.16.3, can still end up in that README.
How do we get typed answers back?
Every agent in the system hands back a typed object, like a plan, a list of facts or a lesson. Both frameworks take a Pydantic model and give you something shaped like it, and they differ in where you pick it up.
Here's how they both do it:
| Strands | ADK |
|---|---|
structured_output_model= on the call or the agent | output_schema= on the agent |
The object comes back on result.structured_output | A dict lands in session state under output_key |
| Works next to any tools | Adds a function tool, so built in tools need include_server_side_tool_invocations |
Strands
We pass the model on the call, and get the object back on the result:
from pydantic import BaseModel from strands import Agent class Question(BaseModel): stem: str options: list[str] answer: int # index into options explanation: str agent = Agent(system_prompt="You write quiz questions about Terraform.", callback_handler=None) result = agent("One question on what terraform init does.", structured_output_model=Question) question = result.structured_output print(type(question).__name__) print(question.stem) print(question.options[question.answer]) Question What is the primary purpose of the `terraform init` command? It initializes a working directory by downloading providers, modules, and setting up the backend result.structured_output is a real Question. Strands gives the model a tool shaped like our schema and makes it call it, and if the model never does, you get StructuredOutputException. So the Strands side catches that and asks again, with the reason.
ADK
We put the schema on the agent, and the answer lands in session state:
import asyncio from google.adk.agents import Agent from google.adk.runners import InMemoryRunner from google.genai import types from pydantic import BaseModel class Question(BaseModel): stem: str options: list[str] answer: int # index into options explanation: str agent = Agent( name="quiz", model="gemini-3.8-flash", instruction="You write quiz questions about Terraform.", output_schema=Question, output_key="question", ) async def main() -> None: runner = InMemoryRunner(agent=agent, app_name="quiz") session = await runner.session_service.create_session(app_name="quiz", user_id="me") message = types.Content(role="user", parts=[types.Part(text="One question on what terraform init does.")]) async for _ in runner.run_async(user_id="me", session_id=session.id, new_message=message): pass done = await runner.session_service.get_session(app_name="quiz", user_id="me", session_id=session.id) raw = done.state["question"] print(type(raw).__name__) question = Question.model_validate(raw) print(question.stem) print(question.options[question.answer]) asyncio.run(main()) dict Which of the following actions is performed by running the 'terraform init' command? Initializes the backend configuration, downloads provider plugins, and installs referenced modules It comes back as a plain dict, so turning it into a Question is our job. And if the agent ends its turn without writing an answer, state stays empty. That's what stopped the first TypeScript build, with nothing was written to session state under 'answer', so the ADK side also asks again with the reason.
Where does the feedback loop live?
As we saw in Part 1, both frameworks can loop inside a graph. Where they differ is what a node can be, and what stops the loop.
Here's how they both do it:
| Strands | ADK |
|---|---|
GraphBuilder, conditional edges | Workflow, routed edges |
set_max_node_executions() caps the loop | Your router stops the loop |
A node has to be an agent or a MultiAgentBase | A node can be any function |
Hitting the cap ends the graph as FAILED | Nothing to hit |
The first difference matters here, because our stages aren't agents. The research stage, for example, is Python code that calls several agents and checks every quote in between.
Strands
In Strands, a graph node has to be an agent or a MultiAgentBase, and you only find that out when the graph runs. add_nodeSource:https://dev.to/chigo_e/can-we-actually-build-the-thing-now-23f9

