<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Chigozirim Eke</title>
    <description>The latest articles on DEV Community by Chigozirim Eke (@chigo_e).</description>
    <link>https://dev.to/chigo_e</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3827548%2Ffa4c449d-6e13-4024-9a75-271e3f45d6ec.jpg</url>
      <title>DEV Community: Chigozirim Eke</title>
      <link>https://dev.to/chigo_e</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/chigo_e"/>
    <language>en</language>
    <item>
      <title>Can we actually build the thing now?</title>
      <dc:creator>Chigozirim Eke</dc:creator>
      <pubDate>Sun, 18 Oct 2026 04:58:27 +0000</pubDate>
      <link>https://dev.to/chigo_e/can-we-actually-build-the-thing-now-23f9</link>
      <guid>https://dev.to/chigo_e/can-we-actually-build-the-thing-now-23f9</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 🙃&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;a href="https://strandsagents.com/docs/user-guide/harness/" rel="noopener noreferrer"&gt;a whole agent harness&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;We'll start with what using it looks like, then build it up side by side the way we did in Part 1.&lt;/p&gt;

&lt;p&gt;Everything here ran between 27 and 29 September 2026, on the same versions as Part 1 (&lt;code&gt;strands-agents 1.55.1&lt;/code&gt; and &lt;code&gt;google-adk 2.9.0&lt;/code&gt;), and the code is in the &lt;a href="https://github.com/coolchigi/strands-vs-adk/tree/main/part2-learning-system" rel="noopener noreferrer"&gt;companion repo&lt;/a&gt;. Both frameworks are actively developed, and &lt;code&gt;strands-agents 1.61.1&lt;/code&gt; and &lt;code&gt;google-adk 2.12.0&lt;/code&gt; were out by the time this went up, so some APIs may have changed since.&lt;/p&gt;

&lt;h2&gt;
  
  
  What should a learning system actually do?
&lt;/h2&gt;

&lt;p&gt;When I started over, I wrote down what I actually want from this thing:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;I say what I want to learn, and it asks a couple of questions to get the course right.&lt;/li&gt;
&lt;li&gt;It covers what people actually do with it at work. For example, pinning a shared module's version.&lt;/li&gt;
&lt;li&gt;It's as current as the docs on the day it's built.&lt;/li&gt;
&lt;li&gt;I learn by doing, and there are tests that verify my work.&lt;/li&gt;
&lt;li&gt;I have to try before I see the answer.&lt;/li&gt;
&lt;li&gt;It doesn't cost a fortune to build, and it tells me what it spent.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What does using it look like?
&lt;/h2&gt;

&lt;p&gt;You'll need &lt;a href="https://docs.astral.sh/uv/getting-started/installation/" rel="noopener noreferrer"&gt;uv&lt;/a&gt;. Then install it once, straight from the repo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;uv tool &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="s2"&gt;"git+https://github.com/coolchigi/strands-vs-adk#subdirectory=part2-learning-system"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and start a course from anywhere:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;learn &lt;span class="s2"&gt;"I want to learn TypeScript so I can build a small web app"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first time, it asks for a &lt;a href="https://aistudio.google.com/apikey" rel="noopener noreferrer"&gt;Gemini API key&lt;/a&gt; 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 &lt;code&gt;--engine strands&lt;/code&gt;. You'll need AWS credentials and &lt;a href="https://docs.aws.amazon.com/bedrock/latest/userguide/model-access.html" rel="noopener noreferrer"&gt;access to Claude in Bedrock&lt;/a&gt;, and the &lt;a href="https://strandsagents.com/docs/user-guide/sdk/quickstart/python/" rel="noopener noreferrer"&gt;Strands Python quickstart&lt;/a&gt; walks through setting both up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;learn &lt;span class="nt"&gt;--engine&lt;/span&gt; strands &lt;span class="s2"&gt;"I want to learn TypeScript so I can build a small web app"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you already have AWS credentials and no Gemini key, it picks Strands on its own and doesn't ask for a key.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Then it asks what it needs to know, and you answer right there:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A few questions first, so I build the right course:
  What is your background with programming, particularly JavaScript?
&amp;gt; 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)?
&amp;gt; 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.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a trimmed output of the real run. Once the build finishes, you get a folder with a &lt;code&gt;README.md&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;You work through it with &lt;code&gt;learn&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ~/courses/typescript
learn next      &lt;span class="c"&gt;# where you are, and what's next&lt;/span&gt;
learn check     &lt;span class="c"&gt;# run the exercise's tests&lt;/span&gt;
learn hint      &lt;span class="c"&gt;# one hint at a time&lt;/span&gt;
learn solution  &lt;span class="c"&gt;# the answer, when you want it&lt;/span&gt;
learn quiz      &lt;span class="c"&gt;# answer first, then see the answer&lt;/span&gt;
learn review    &lt;span class="c"&gt;# questions you missed come back after 1, 3, 7 and 21 days&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lesson 1's exercise gives you a &lt;code&gt;formatter.ts&lt;/code&gt; 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 &lt;code&gt;learn check&lt;/code&gt; says if you fix the code but skip the type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ 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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add the type and it passes, then points you to &lt;code&gt;learn quiz&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That error comes from the TypeScript compiler itself, because &lt;code&gt;learn check&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  How is it built?
&lt;/h2&gt;

&lt;p&gt;Behind those &lt;code&gt;learn&lt;/code&gt; commands is the system we sketched in Part 1, with one new agent at the front:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;learner ─▶ intake ─▶ researcher ─▶ curriculum builder ─▶ teacher ─▶ your course
                         ▲                  ▲                ▲
                         └──── the feedback agent reviews each stage, sends it back with reasons
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The intake&lt;/strong&gt; asks what it needs to know, like where you're starting from and what you want to build.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The researcher&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The curriculum builder&lt;/strong&gt; turns that list into units and lessons, and picks an exercise or a quiz to check each one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The teacher&lt;/strong&gt; writes each lesson, including the tests that check your exercise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The feedback agent&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Both frameworks plug in through one method:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Brain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Protocol&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;ask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;instruction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;research&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;One agent call, returning `schema`. With research=True the agent can search and read.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each framework implements &lt;code&gt;ask&lt;/code&gt; its own way and runs the stages in its own graph. The Strands side uses Claude Sonnet 4.6 on Amazon Bedrock, which is &lt;a href="https://strandsagents.com/docs/user-guide/sdk/model-providers/amazon-bedrock/#basic-usage" rel="noopener noreferrer"&gt;Strands' default&lt;/a&gt;, 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does the researcher reach the docs?
&lt;/h2&gt;

&lt;p&gt;The researcher needs tools to find the right pages and read them. Some of those tools come from MCP servers. &lt;a href="https://modelcontextprotocol.io" rel="noopener noreferrer"&gt;MCP&lt;/a&gt; (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.&lt;/p&gt;

&lt;p&gt;Here's how they both do it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;strong&gt;Strands&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;ADK&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No search in the package&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;google_search&lt;/code&gt;, Google Search built into Gemini only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MCP servers go straight into &lt;code&gt;tools&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;MCP servers come in through &lt;code&gt;McpToolset&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;web_fetch&lt;/code&gt; reads a page live&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;load_web_page&lt;/code&gt;, which I couldn't use&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both sides also get the same two extra tools. One is &lt;a href="https://awslabs.github.io/mcp/servers/aws-knowledge-mcp-server#aws-authentication" rel="noopener noreferrer"&gt;AWS's documentation server&lt;/a&gt;, which searches and reads AWS's docs with no key. The other is &lt;code&gt;list_docs_pages&lt;/code&gt;, a small tool we wrote that lists every page a docs site has, from the site's own sitemap.&lt;/p&gt;

&lt;h3&gt;
  
  
  Strands
&lt;/h3&gt;

&lt;p&gt;Strands &lt;a href="https://strandsagents.com/docs/user-guide/sdk/tools/vended-tools/#available-tools" rel="noopener noreferrer"&gt;ships tools for fetching pages&lt;/a&gt; and none for searching, so I used an MCP server. Here we create an &lt;a href="https://strandsagents.com/docs/user-guide/sdk/tools/mcp-tools/#integration-approaches" rel="noopener noreferrer"&gt;&lt;code&gt;MCPClient&lt;/code&gt;&lt;/a&gt; and pass it straight into &lt;code&gt;tools&lt;/code&gt;, and Strands opens and closes the connection for us. This is what connecting to the AWS docs server looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;strands&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;strands.tools.mcp&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;MCPClient&lt;/span&gt;

&lt;span class="n"&gt;aws_docs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MCPClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://knowledge-mcp.global.api.aws&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;researcher&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;system_prompt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Answer from the AWS documentation, in one sentence.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                   &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;aws_docs&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;callback_handler&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;researcher&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;How big can a single S3 object be?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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** ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep that 5 TB in mind, because we'll ask the ADK side the same question.&lt;/p&gt;

&lt;p&gt;For general web search I used &lt;a href="https://exa.ai/docs/get-started/exa-mcp#authentication" rel="noopener noreferrer"&gt;Exa's hosted MCP server&lt;/a&gt; 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 &lt;code&gt;list_docs_pages&lt;/code&gt; finds the pages on the docs site itself.&lt;/p&gt;

&lt;p&gt;To read a page, &lt;a href="https://strandsagents.com/docs/user-guide/sdk/tools/vended-tools/#web-fetch" rel="noopener noreferrer"&gt;&lt;code&gt;web_fetch&lt;/code&gt;&lt;/a&gt; has two modes. The default, &lt;code&gt;agentic&lt;/code&gt;, hands the page to a second agent and returns that agent's summary, and &lt;code&gt;markdown&lt;/code&gt; 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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;strands.vended_tools.web_fetch&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;make_web_fetch&lt;/span&gt;

&lt;span class="n"&gt;fetch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;make_web_fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;markdown&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_content_chars&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;50_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It needs the &lt;code&gt;web-fetch&lt;/code&gt; extra, so &lt;code&gt;pip install "strands-agents[web-fetch]"&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  ADK
&lt;/h3&gt;

&lt;p&gt;ADK ships search as &lt;code&gt;google_search&lt;/code&gt;, and &lt;a href="https://adk.dev/integrations/google-search/" rel="noopener noreferrer"&gt;it only works on Gemini&lt;/a&gt;. On any other model, like Claude, the agent still builds, but the first call fails with &lt;code&gt;ValueError: Google search tool is not supported for model ...&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;MCP servers come in through &lt;a href="https://adk.dev/tools-custom/mcp-tools/#direct-mcp-tool-integration-mcptoolset" rel="noopener noreferrer"&gt;&lt;code&gt;McpToolset&lt;/code&gt;&lt;/a&gt;. This is the same AWS docs server, connected next to &lt;code&gt;google_search&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;google.adk.agents&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;google.adk.runners&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;InMemoryRunner&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;google.adk.tools&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;google_search&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;google.adk.tools.mcp_tool&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;McpToolset&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;StreamableHTTPConnectionParams&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;google.genai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;types&lt;/span&gt;

&lt;span class="n"&gt;aws_docs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;McpToolset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;connection_params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;StreamableHTTPConnectionParams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://knowledge-mcp.global.api.aws&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;researcher&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;researcher&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gemini-3.8-flash&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;instruction&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Answer from the AWS documentation, in one sentence.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;google_search&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;aws_docs&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;generate_content_config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;types&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;GenerateContentConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;tool_config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;types&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ToolConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;include_server_side_tool_invocations&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;runner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;InMemoryRunner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;researcher&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;app_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;docs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;runner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session_service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_session&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;docs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;me&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;types&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Content&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;role&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;types&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Part&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;How big can a single S3 object be?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)])&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;runner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run_async&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;me&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new_message&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_final_response&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;part&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;aws_docs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;


&lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;According to AWS documentation, a single Amazon S3 object can be up to 50 TB in size ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Strands agent said 5 TB and the ADK agent said 50 TB, and &lt;a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/UsingObjects.html" rel="noopener noreferrer"&gt;AWS's own page&lt;/a&gt; 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 &lt;a href="https://aws.amazon.com/s3/faqs/" rel="noopener noreferrer"&gt;S3 FAQ page&lt;/a&gt; itself before answering. That's why nothing a model says goes into a course unless it comes with a quote from the live page.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvr50haoqirmtz3hslthj.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvr50haoqirmtz3hslthj.gif" alt="The same S3 question asked on both sides, against the same AWS docs server. Claude searches once and answers 5 TB. Gemini searches, reads the S3 FAQ page, searches again, and answers 50 TB" width="800" height="382"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you use &lt;code&gt;McpToolset&lt;/code&gt; 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 &lt;code&gt;asyncio.run&lt;/code&gt; for every agent call, and &lt;code&gt;asyncio.run&lt;/code&gt; closes its loop when it's done, so every call after the first found its session on a closed loop.&lt;/p&gt;

&lt;p&gt;ADK has its own page loader, &lt;a href="https://github.com/google/adk-python/blob/v2.9.0/src/google/adk/tools/load_web_page.py" rel="noopener noreferrer"&gt;&lt;code&gt;load_web_page&lt;/code&gt;&lt;/a&gt;, 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 &lt;code&gt;Failed to fetch url: ...&lt;/code&gt; 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, &lt;code&gt;$ terraform init&lt;/code&gt; and &lt;code&gt;required_providers {&lt;/code&gt; included. So on the ADK side, the agents find the pages, and our own code fetches them in full.&lt;/p&gt;

&lt;p&gt;The ADK example also had a &lt;code&gt;generate_content_config&lt;/code&gt; line we haven't explained yet. It's there because of the very first real call.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happened on the first real call?
&lt;/h3&gt;

&lt;p&gt;The researcher's first job is to find the official docs and the current version, and hand them back as a typed plan through &lt;a href="https://adk.dev/agents/llm-agents/#data-handling" rel="noopener noreferrer"&gt;&lt;code&gt;output_schema&lt;/code&gt;&lt;/a&gt;, which is how ADK asks a model for a typed answer. The first real call came back with this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;400 INVALID_ARGUMENT: Please enable tool_config.include_server_side_tool_invocations
to use Built-in tools with Function calling.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Hmm, function calling. That's weird because I hadn't given it a function.&lt;/p&gt;

&lt;p&gt;ADK had. When an agent has both &lt;code&gt;output_schema&lt;/code&gt; and tools, ADK quietly adds a function tool called &lt;a href="https://github.com/google/adk-python/blob/v2.9.0/src/google/adk/flows/llm_flows/_output_schema_processor.py" rel="noopener noreferrer"&gt;&lt;code&gt;set_model_response&lt;/code&gt;&lt;/a&gt;, 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.&lt;/p&gt;

&lt;p&gt;I tried &lt;a href="https://adk.dev/tools/limitations/#workaround-2-bypass_multi_tools_limit" rel="noopener noreferrer"&gt;&lt;code&gt;GoogleSearchTool(bypass_multi_tools_limit=True)&lt;/code&gt;&lt;/a&gt; first and got the same 400, because ADK adds &lt;code&gt;set_model_response&lt;/code&gt; after that setting is checked.&lt;/p&gt;

&lt;p&gt;The fix was what the error asked for, turning on &lt;a href="https://ai.google.dev/gemini-api/docs/generate-content/tool-combination#enable" rel="noopener noreferrer"&gt;&lt;code&gt;include_server_side_tool_invocations&lt;/code&gt;&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;google.adk.agents&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;google.adk.runners&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;InMemoryRunner&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;google.adk.tools&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;google_search&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;google.genai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;types&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseModel&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Plan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;official_domains&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;current_version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;


&lt;span class="n"&gt;planner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;plan&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gemini-3.8-flash&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;instruction&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Find the official docs domain and the current release of the subject. Use google_search.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;google_search&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;output_schema&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;Plan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;output_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;plan&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;generate_content_config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;types&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;GenerateContentConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;tool_config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;types&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ToolConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;include_server_side_tool_invocations&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;runner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;InMemoryRunner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;planner&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;app_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;plan&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;runner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session_service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_session&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;plan&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;me&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;types&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Content&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;role&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;types&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Part&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Terraform&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)])&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;runner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run_async&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;me&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new_message&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;pass&lt;/span&gt;
    &lt;span class="n"&gt;done&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;runner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session_service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_session&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;plan&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;me&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;done&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;plan&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;


&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{'official_domains': ['developer.hashicorp.com', 'terraform.io'], 'current_version': '1.16.4'}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without the &lt;code&gt;generate_content_config&lt;/code&gt; 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which one found the current version?
&lt;/h3&gt;

&lt;p&gt;The plan step asks for the subject's current version. On 27 September Terraform's was 1.16.4, per &lt;a href="https://api.releases.hashicorp.com/v1/releases/terraform?after=2026-09-28T00:00:00Z&amp;amp;limit=5" rel="noopener noreferrer"&gt;HashiCorp's own release API&lt;/a&gt;. Here's every plan step I ran while the Strands side searched through Exa:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Answer&lt;/th&gt;
&lt;th&gt;Strands (Claude + Exa)&lt;/th&gt;
&lt;th&gt;ADK (Gemini + Google Search)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1.16.4&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1.16.3&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1.16.2&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"1.9.x (1.9 series, latest stable as of mid-2024)"&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Twice Claude quoted the &lt;a href="https://developer.hashicorp.com/terraform/install" rel="noopener noreferrer"&gt;Terraform install page&lt;/a&gt; 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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;web_fetch&lt;/code&gt; to read the live page, it got 1.16.4 too.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do we get typed answers back?
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Here's how they both do it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;strong&gt;Strands&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;ADK&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;structured_output_model=&lt;/code&gt; on the call or the agent&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;output_schema=&lt;/code&gt; on the agent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The object comes back on &lt;code&gt;result.structured_output&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;A &lt;code&gt;dict&lt;/code&gt; lands in session state under &lt;code&gt;output_key&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Works next to any tools&lt;/td&gt;
&lt;td&gt;Adds a function tool, so built in tools need &lt;code&gt;include_server_side_tool_invocations&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Strands
&lt;/h3&gt;

&lt;p&gt;We &lt;a href="https://strandsagents.com/docs/user-guide/sdk/agents/structured-output/#basic-usage" rel="noopener noreferrer"&gt;pass the model on the call&lt;/a&gt;, and get the object back on the result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseModel&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;strands&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Question&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;stem&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;  &lt;span class="c1"&gt;# index into options
&lt;/span&gt;    &lt;span class="n"&gt;explanation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;


&lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;system_prompt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;You write quiz questions about Terraform.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;callback_handler&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;One question on what terraform init does.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;structured_output_model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;Question&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;question&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;structured_output&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;type&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;__name__&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stem&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;result.structured_output&lt;/code&gt; is a real &lt;code&gt;Question&lt;/code&gt;. Strands gives the model &lt;a href="https://strandsagents.com/docs/user-guide/sdk/agents/structured-output/#how-it-works" rel="noopener noreferrer"&gt;a tool shaped like our schema&lt;/a&gt; and makes it call it, and if the model never does, you get &lt;code&gt;StructuredOutputException&lt;/code&gt;. So the Strands side catches that and asks again, with the reason.&lt;/p&gt;

&lt;h3&gt;
  
  
  ADK
&lt;/h3&gt;

&lt;p&gt;We put the schema on the agent, and &lt;a href="https://adk.dev/sessions/state/#how-state-is-updated-recommended-methods" rel="noopener noreferrer"&gt;the answer lands in session state&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;google.adk.agents&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;google.adk.runners&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;InMemoryRunner&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;google.genai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;types&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseModel&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Question&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;stem&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;  &lt;span class="c1"&gt;# index into options
&lt;/span&gt;    &lt;span class="n"&gt;explanation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;


&lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;quiz&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gemini-3.8-flash&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;instruction&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;You write quiz questions about Terraform.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;output_schema&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;Question&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;output_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;question&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;runner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;InMemoryRunner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;app_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;quiz&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;runner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session_service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_session&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;quiz&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;me&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;types&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Content&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;role&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;types&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Part&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;One question on what terraform init does.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)])&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;runner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run_async&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;me&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new_message&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;pass&lt;/span&gt;
    &lt;span class="n"&gt;done&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;runner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session_service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_session&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;quiz&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;me&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;done&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;question&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;type&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;__name__&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;question&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Question&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;model_validate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stem&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;


&lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It comes back as &lt;a href="https://adk.dev/agents/llm-agents/#data-handling" rel="noopener noreferrer"&gt;a plain &lt;code&gt;dict&lt;/code&gt;&lt;/a&gt;, so turning it into a &lt;code&gt;Question&lt;/code&gt; 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 &lt;code&gt;nothing was written to session state under 'answer'&lt;/code&gt;, so the ADK side also asks again with the reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where does the feedback loop live?
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Here's how they both do it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;strong&gt;Strands&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;ADK&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;GraphBuilder&lt;/code&gt;, conditional edges&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Workflow&lt;/code&gt;, routed edges&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;set_max_node_executions()&lt;/code&gt; caps the loop&lt;/td&gt;
&lt;td&gt;Your router stops the loop&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A node has to be an agent or a &lt;code&gt;MultiAgentBase&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;A node can be any function&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hitting the cap ends the graph as &lt;code&gt;FAILED&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Nothing to hit&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Strands
&lt;/h3&gt;

&lt;p&gt;In Strands, &lt;a href="https://strandsagents.com/docs/user-guide/sdk/multi-agent/graph-components/#graph-components" rel="noopener noreferrer"&gt;a graph node&lt;/a&gt; has to be an agent or a &lt;code&gt;MultiAgentBase&lt;/code&gt;, and you only find that out when the graph runs. &lt;code&gt;add_node&lt;/code&gt; accepts a plain function, so does &lt;code&gt;build()&lt;/code&gt;, and then the graph reaches that node and stops with &lt;code&gt;ValueError: Node 'stage' of type '&amp;lt;class 'function'&amp;gt;' is not supported&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So each stage gets wrapped in &lt;a href="https://strandsagents.com/docs/user-guide/sdk/multi-agent/graph/#custom-node-types" rel="noopener noreferrer"&gt;a small &lt;code&gt;MultiAgentBase&lt;/code&gt; subclass&lt;/a&gt;. Here's the loop with stand-in stages that just print, so it runs without a model or any keys:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;strands.multiagent&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;GraphBuilder&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;strands.multiagent.base&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;MultiAgentBase&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;MultiAgentResult&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Status&lt;/span&gt;

&lt;span class="n"&gt;board&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reviews&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;verdict&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Stage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MultiAgentBase&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;work&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="nf"&gt;super&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;work&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;work&lt;/span&gt;

    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;invoke_async&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;invocation_state&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="c1"&gt;# the graph runs nodes on its event loop, and real stages block, so off it they go
&lt;/span&gt;        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_thread&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;work&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="c1"&gt;# results stays empty: Strands walks into it to build the next node's input,
&lt;/span&gt;        &lt;span class="c1"&gt;# so it has to be real results or nothing
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;MultiAgentResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;COMPLETED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{})&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;teach&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;teaching&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;review&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;board&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reviews&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="n"&gt;board&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;verdict&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;revise&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;board&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reviews&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;approved&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;review &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;board&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;reviews&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;board&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;verdict&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="n"&gt;builder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;GraphBuilder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Stage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;teaching&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;teach&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;teaching&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Stage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;feedback&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;review&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;feedback&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_edge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;teaching&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;feedback&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_edge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;feedback&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;teaching&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;condition&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;board&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;verdict&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;revise&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_entry_point&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;teaching&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_max_node_executions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reset_on_revisit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;graph&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;build the course&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;teaching
review 1: revise
teaching
review 2: revise
teaching
review 3: approved
Status.COMPLETED
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In &lt;code&gt;Stage&lt;/code&gt;, &lt;code&gt;results={}&lt;/code&gt; has to stay empty. If you put the stage's output there, Strands tries to read it as the next node's input and crashes with &lt;code&gt;AttributeError: 'str' object has no attribute 'results'&lt;/code&gt;. So the stages share their work through &lt;code&gt;board&lt;/code&gt;, a plain dict, and the graph only decides what runs next.&lt;/p&gt;

&lt;p&gt;If the reviewer never approves, the same file stops after 10 node executions and &lt;a href="https://strandsagents.com/docs/user-guide/sdk/multi-agent/graph/#sdk-differences" rel="noopener noreferrer"&gt;ends &lt;code&gt;Status.FAILED&lt;/code&gt;&lt;/a&gt;. It doesn't raise, so check &lt;code&gt;result.status&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Without &lt;a href="https://strandsagents.com/docs/api/python/strands.multiagent.graph/#set_max_node_executions" rel="noopener noreferrer"&gt;&lt;code&gt;set_max_node_executions&lt;/code&gt;&lt;/a&gt;, &lt;code&gt;build()&lt;/code&gt; only logs a warning, "Graph without execution limits may run indefinitely if cycles exist".&lt;/p&gt;

&lt;h3&gt;
  
  
  ADK
&lt;/h3&gt;

&lt;p&gt;In ADK, &lt;a href="https://adk.dev/graphs/routes/#nodes" rel="noopener noreferrer"&gt;a node can be a plain function&lt;/a&gt;, no subclass needed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;google.adk&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Workflow&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;google.adk.runners&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;InMemoryRunner&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;google.genai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;types&lt;/span&gt;

&lt;span class="n"&gt;board&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reviews&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;verdict&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;MAX_REVIEWS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;


&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;teaching&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# nodes run on the workflow's event loop, and real stages block, so off it they go
&lt;/span&gt;    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_thread&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;print&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;teaching&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;feedback&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;board&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reviews&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="n"&gt;board&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;verdict&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;revise&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;board&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reviews&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;approved&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;review &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;board&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;reviews&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;board&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;verdict&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;board&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;verdict&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;approved&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;board&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reviews&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;MAX_REVIEWS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stop&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;revise&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;


&lt;span class="n"&gt;root_agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Workflow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;revision_loop&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;edges&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;START&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;teaching&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;feedback&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;revise&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;teaching&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stop&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;])&lt;/span&gt;


&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;runner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;InMemoryRunner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;root_agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;app_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;loop&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;runner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session_service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_session&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;loop&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;me&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;types&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Content&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;role&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;types&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Part&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;build the course&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)])&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;runner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run_async&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;me&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new_message&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;pass&lt;/span&gt;


&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It prints the same thing, without the status line. In ADK, &lt;code&gt;route&lt;/code&gt; is the only thing that ends this loop. Without the &lt;code&gt;board["reviews"] &amp;gt;= MAX_REVIEWS&lt;/code&gt; check, a reviewer that never approves keeps it looping, because &lt;a href="https://adk.dev/graphs/routes/#loop-and-escalation-exit" rel="noopener noreferrer"&gt;nothing in ADK limits how many times a node runs&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the real system does with it
&lt;/h3&gt;

&lt;p&gt;The learning system's graphs are those two shapes, with each review as a node of its own:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc3xb1p5rn01cltx8lj1y.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc3xb1p5rn01cltx8lj1y.gif" alt="A run through the learning system. The research review sends research round again with its reasons, then forward. The curriculum review finds the problem started in research and sends the run back upstream, and research keeps what it verified before the run goes forward to done" width="720" height="288"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;After each stage, a review decides what happens next. The run moves on, the stage runs again with the reviewer's reasons, or the run goes back to research if that's where the problem started. A stage gets 3 tries, and after that the run moves on and the course lists what's still wrong.&lt;/p&gt;

&lt;p&gt;Going back to research used to start it again from nothing. On the Strands side, a reviewer found a few gaps, the rerun threw away 306 facts that had already been checked, and that one trip cost $11.81. Now research that comes back keeps everything it already checked and only looks for what's missing.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do we keep the bill down?
&lt;/h2&gt;

&lt;p&gt;A course takes hundreds of model calls, from picking pages to writing and reviewing every lesson, and more when a review sends something back. The Strands side runs on Claude Sonnet 4.6, which costs 4 times what Gemini 3.8 Flash does per token.&lt;/p&gt;

&lt;p&gt;Part 1 covered the limits each framework has built in. Strands takes &lt;a href="https://strandsagents.com/docs/user-guide/sdk/agents/agent-loop/#invocation-limits" rel="noopener noreferrer"&gt;&lt;code&gt;limits=&lt;/code&gt; on a call&lt;/a&gt;, and ADK takes &lt;a href="https://adk.dev/runtime/runconfig/#configure-runtime-limits-and-debugging" rel="noopener noreferrer"&gt;&lt;code&gt;RunConfig(max_llm_calls=...)&lt;/code&gt;&lt;/a&gt; on a run. But those only count within one call or one run, and a course is hundreds of them, so I wanted one number for the whole course, in dollars.&lt;/p&gt;

&lt;p&gt;I added a spending cap to every course, and a meter that counts what every agent has spent. Before each model call, the cap checks the meter and refuses the call if we've exceeded the cap. Both frameworks give us a hook in exactly the right place, and they attach differently.&lt;/p&gt;

&lt;p&gt;Here's how they both do it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;strong&gt;Strands&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;ADK&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;add_hook&lt;/code&gt; on an agent you already have&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;before_model_callback&lt;/code&gt; on the agent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Usage is on &lt;code&gt;agent.event_loop_metrics&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Usage is on each &lt;code&gt;llm_response&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Raise, and it comes out as is, or wrapped once a tool has run&lt;/td&gt;
&lt;td&gt;Raise, and it comes back as an error event first&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Strands
&lt;/h3&gt;

&lt;p&gt;Strands already &lt;a href="https://strandsagents.com/docs/user-guide/sdk/observability-evaluation/metrics/#agent-loop-metrics" rel="noopener noreferrer"&gt;counts each agent's tokens&lt;/a&gt; in &lt;code&gt;agent.event_loop_metrics&lt;/code&gt;, so the hook just reads them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;strands&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;strands.hooks&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BeforeModelCallEvent&lt;/span&gt;

&lt;span class="n"&gt;PRICE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;3.00&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;15.00&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# USD per million tokens, in and out, for Claude Sonnet 4.6
&lt;/span&gt;&lt;span class="n"&gt;CAP&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.001&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BudgetExceeded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;pass&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;spent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;usage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;event_loop_metrics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;accumulated_usage&lt;/span&gt;
    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;inputTokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;PRICE&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;outputTokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;PRICE&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1_000_000&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;enforce_cap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;BeforeModelCallEvent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;spent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;CAP&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;BudgetExceeded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;spent $&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;spent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, the cap is $&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;CAP&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;system_prompt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Answer in one short paragraph.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;callback_handler&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_hook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;enforce_cap&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;What does terraform init do?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;spent $&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;spent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;And terraform plan?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;BudgetExceeded&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stopped:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;`terraform init` initializes a Terraform working directory by downloading ...
spent $0.0018
stopped: spent $0.0018, the cap is $0.001
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;BeforeModelCallEvent&lt;/code&gt; also has &lt;a href="https://strandsagents.com/docs/api/python/strands.hooks.events/#beforemodelcallevent" rel="noopener noreferrer"&gt;a &lt;code&gt;cancel&lt;/code&gt; field&lt;/a&gt;, which looks like the obvious tool. Cancelling hands the loop a fake assistant message, "model call denied by hook", so a plain call returns that as if the model had said it, and a structured call fails blaming a model that never got the call. Raising says what actually happened.&lt;/p&gt;

&lt;p&gt;There's one more catch, and it only shows up in a real run. On the first model call of an invocation, your exception comes out exactly as you raised it, which is all the example above ever does. On any call after a tool has run, Strands wraps it in its own &lt;a href="https://github.com/strands-agents/harness-sdk/blob/545d9ed070248d73e3fc50b5d4b51914dd4c5d3d/strands-py/src/strands/event_loop/event_loop.py#L403-L409" rel="noopener noreferrer"&gt;&lt;code&gt;EventLoopException&lt;/code&gt;&lt;/a&gt;. My research agents use tools, so the first time the cap fired for real, &lt;code&gt;except BudgetExceeded&lt;/code&gt; didn't match and the run died with a traceback. The fix is to look through the exception's causes.&lt;/p&gt;

&lt;h3&gt;
  
  
  ADK
&lt;/h3&gt;

&lt;p&gt;Here we pass ADK &lt;a href="https://adk.dev/callbacks/" rel="noopener noreferrer"&gt;the callbacks&lt;/a&gt; when we build the agent, and count the tokens as each response comes back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;google.adk.agents&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;google.adk.runners&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;InMemoryRunner&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;google.genai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;types&lt;/span&gt;

&lt;span class="n"&gt;PRICE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.75&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;3.75&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# USD per million tokens, in and out, for Gemini 3.8 Flash
&lt;/span&gt;&lt;span class="n"&gt;CAP&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0001&lt;/span&gt;
&lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;in&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;out&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BudgetExceeded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;pass&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;spent&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;in&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;PRICE&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;out&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;PRICE&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1_000_000&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;enforce_cap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;callback_context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;llm_request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;spent&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;CAP&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;BudgetExceeded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;spent $&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;spent&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, the cap is $&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;CAP&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;callback_context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;llm_response&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;usage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;llm_response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;usage_metadata&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;in&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prompt_token_count&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
        &lt;span class="c1"&gt;# thinking tokens are billed as output and reported separately
&lt;/span&gt;        &lt;span class="n"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;out&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;candidates_token_count&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;thoughts_token_count&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tutor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gemini-3.8-flash&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;instruction&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Answer in one short paragraph.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;before_model_callback&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;enforce_cap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;after_model_callback&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;ask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;runner&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;types&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Content&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;role&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;types&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Part&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)])&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;runner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run_async&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;me&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new_message&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;error_code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error event:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;error_code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_final_response&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;runner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;InMemoryRunner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;app_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tutor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;runner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session_service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_session&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tutor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;me&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;ask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;runner&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;What does terraform init do?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;spent $&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;spent&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;ask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;runner&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;And terraform plan?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;BudgetExceeded&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stopped:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The `terraform init` command initializes a working directory containing Terraform ...
spent $0.001424
error event: BudgetExceeded
stopped: spent $0.001424, the cap is $0.0001
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things are different here. Gemini 3.8 Flash thinks, and &lt;a href="https://ai.google.dev/gemini-api/docs/generate-content/thinking#pricing" rel="noopener noreferrer"&gt;thinking is billed as output&lt;/a&gt; but reported separately as &lt;code&gt;thoughts_token_count&lt;/code&gt;, so a meter that only adds &lt;code&gt;candidates_token_count&lt;/code&gt; undercounts every call. And the exception comes back twice, first as an error event with &lt;code&gt;error_code&lt;/code&gt; &lt;a href="https://github.com/google/adk-python/blob/v2.9.0/src/google/adk/workflow/_node_runner.py" rel="noopener noreferrer"&gt;set to its class name&lt;/a&gt;, then raised again when the stream ends. So every loop over ADK's events checks for an error event and stops straight away.&lt;/p&gt;

&lt;p&gt;The bigger difference is that ADK can put the cap on every agent at once. In both frameworks we can add &lt;code&gt;enforce_cap&lt;/code&gt; to an agent after it's built, with &lt;a href="https://strandsagents.com/docs/user-guide/sdk/agents/hooks/#registering-individual-hook-callbacks" rel="noopener noreferrer"&gt;&lt;code&gt;add_hook&lt;/code&gt;&lt;/a&gt; on Strands, or by setting the agent's &lt;code&gt;before_model_callback&lt;/code&gt; on ADK. But ADK also has &lt;a href="https://adk.dev/plugins/#how-do-plugins-work" rel="noopener noreferrer"&gt;plugins&lt;/a&gt;, which we register once for the whole app. A plugin's &lt;code&gt;before_model_callback&lt;/code&gt; runs before every model call, for every agent. Strands has nothing like that, so each Strands agent needs its own hook.&lt;/p&gt;

&lt;h3&gt;
  
  
  What did it cost?
&lt;/h3&gt;

&lt;p&gt;Here's what each course cost, from the meters, at list prices:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Course&lt;/th&gt;
&lt;th&gt;Built with&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Terraform&lt;/td&gt;
&lt;td&gt;ADK, Gemini 3.8 Flash&lt;/td&gt;
&lt;td&gt;$3.35 for the lessons (research and planning ran before the meter saved anything)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Terraform&lt;/td&gt;
&lt;td&gt;Strands, Claude Sonnet 4.6&lt;/td&gt;
&lt;td&gt;$18.36: research $7.06, planning $0.87, 23 lessons $10.43&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TypeScript&lt;/td&gt;
&lt;td&gt;ADK, Gemini 3.8 Flash, through &lt;code&gt;learn&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;$2.94, everything included&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Across the 2 days of building, with the lost runs, the restarts and the bugs, Claude on my Bedrock account came to about $59, going by AWS's Cost Explorer.&lt;/p&gt;

&lt;p&gt;Two bugs let it get that high. The Strands meter remembered each agent's usage by &lt;code&gt;id(agent)&lt;/code&gt;, and Python hands a new object the id of one that's been thrown away, so fresh agents inherited dead ones' totals and the meter undercounted. One run reported -834,706 input tokens for a single role. And the cap lived in the process, so every restart got a fresh budget. Both are fixed now. Each agent's count lives in its own hooks, and spend is saved to disk the moment it's counted, so a restarted course carries on from what it already spent.&lt;/p&gt;

&lt;p&gt;A cap that just stops leaves you with half a course, though. So now, halfway to the cap, the course switches to a cheaper model to finish, and tells you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  $5.00 spent of your $10.00 cap, so the rest of this course is written by a cheaper model
  (global.anthropic.claude-haiku-4-5-20251001-v1:0).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Strands that's Claude Haiku 4.5, at &lt;a href="https://aws.amazon.com/bedrock/pricing/" rel="noopener noreferrer"&gt;$1 in and $5 out per million tokens&lt;/a&gt; against Sonnet 4.6's $3 and $15. On ADK it's Gemini 3.5 Flash-Lite, at &lt;a href="https://ai.google.dev/gemini-api/docs/pricing#gemini-3.5-flash-lite" rel="noopener noreferrer"&gt;$0.30 and $2.50&lt;/a&gt;. Before trusting either, I asked each one to find Terraform's current version through the system's own &lt;code&gt;Brain&lt;/code&gt;, with its tools and typed answers. Both found 1.16.4 on the live install page.&lt;/p&gt;

&lt;p&gt;Swapping the model mid-course is easy on both sides, because each call builds a fresh agent anyway. Strands takes &lt;a href="https://strandsagents.com/docs/user-guide/sdk/model-providers/amazon-bedrock/#custom-configuration" rel="noopener noreferrer"&gt;a different &lt;code&gt;BedrockModel&lt;/code&gt;&lt;/a&gt;, and &lt;a href="https://adk.dev/agents/models/" rel="noopener noreferrer"&gt;ADK takes a different model name&lt;/a&gt;. The cap, the switch point and the cheaper model are all settings in one file.&lt;/p&gt;

&lt;p&gt;Bedrock's US-only inference profile costs 10% more than the global one, which I only found by asking &lt;a href="https://docs.aws.amazon.com/awsaccountbilling/latest/aboutv2/price-changes.html" rel="noopener noreferrer"&gt;AWS's price list&lt;/a&gt;. And Gemini 3.8 Flash's &lt;a href="https://ai.google.dev/gemini-api/docs/pricing#gemini-3.8-flash" rel="noopener noreferrer"&gt;price doubles on 1 January 2027&lt;/a&gt;, to $1.50 in and $7.50 out.&lt;/p&gt;

&lt;p&gt;Newer, cheaper Claude models came out while I was writing this. &lt;a href="https://aws.amazon.com/about-aws/whats-new/2026/09/claude-sonnet-5-5-aws/" rel="noopener noreferrer"&gt;Sonnet 5.5 came to Bedrock on 28 September&lt;/a&gt; at $2 in and $10 out, and &lt;a href="https://aws.amazon.com/about-aws/whats-new/2026/10/claude-haiku-5-5-aws/" rel="noopener noreferrer"&gt;Haiku 5.5 on 7 October&lt;/a&gt;, from $0.10 in and $0.50 out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is the course any good?
&lt;/h2&gt;

&lt;p&gt;Here's what each side produced from the same request, "I'd like to deeply understand Terraform and build projects along the way."&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;
&lt;strong&gt;Strands&lt;/strong&gt; (Claude)&lt;/th&gt;
&lt;th&gt;
&lt;strong&gt;ADK&lt;/strong&gt; (Gemini)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Facts checked against a live page&lt;/td&gt;
&lt;td&gt;546, from 39 sources&lt;/td&gt;
&lt;td&gt;162, from 19 sources&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lessons&lt;/td&gt;
&lt;td&gt;23 in 5 units&lt;/td&gt;
&lt;td&gt;12 in 4 units&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Exercises that pass every check&lt;/td&gt;
&lt;td&gt;12 of 23&lt;/td&gt;
&lt;td&gt;12 of 12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lessons that ship with a known problem&lt;/td&gt;
&lt;td&gt;23&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost&lt;/td&gt;
&lt;td&gt;$18.36&lt;/td&gt;
&lt;td&gt;$3.35, plus research and planning&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The TypeScript course from earlier was built through &lt;code&gt;learn&lt;/code&gt;. It has 12 lessons, all 12 exercises pass every check, and nothing is left on its known problems list, for $2.94.&lt;/p&gt;

&lt;p&gt;I planned to judge the courses by their review scores. Then I did the lessons myself, and found more than the reviewers had.&lt;/p&gt;

&lt;h3&gt;
  
  
  The exercises
&lt;/h3&gt;

&lt;p&gt;The first finished ADK course had 5 exercises out of 12 that failed their own checks, and 4 of them hit the same error, &lt;code&gt;Unknown condition value ... This means you have executed a run block with command = plan&lt;/code&gt;. That one was my fault.&lt;/p&gt;

&lt;p&gt;I'd told the teacher to write the tests with &lt;a href="https://developer.hashicorp.com/terraform/language/tests#integration-or-unit-testing" rel="noopener noreferrer"&gt;&lt;code&gt;command = plan&lt;/code&gt;&lt;/a&gt;, and at plan time things like IDs don't exist yet. The tests run against &lt;a href="https://developer.hashicorp.com/terraform/language/tests/mocking#mock-providers" rel="noopener noreferrer"&gt;a mock provider&lt;/a&gt; (i.e. a fake AWS), so &lt;code&gt;command = apply&lt;/code&gt; doesn't create anything real or need an AWS account. It also makes up values like IDs, so the tests have something to check.&lt;/p&gt;

&lt;p&gt;After that fix, I rebuilt the ADK course and worked through lesson 1 myself. I left one of its 3 TODOs alone, filled in the other two, and &lt;code&gt;learn check&lt;/code&gt; still said "Passed". The system's rule was that an untouched exercise (i.e. every TODO left alone) has to fail its tests before the course ships. If the tests check just one TODO, that rule passes, so the TODO I left alone was never tested.&lt;/p&gt;

&lt;p&gt;So now the system also checks each TODO on its own. It fills in every TODO with the answer except one, and the tests have to fail. It does that for each TODO in turn, so you can't skip any of them and still pass. 3 of the 12 lessons in that course had a TODO you could skip.&lt;/p&gt;

&lt;p&gt;Even with every TODO checked on its own, a wrong answer can still pass. In unit 3, lesson 3 of the same course, a TODO asks you to output the ID of the EC2 instance the lesson builds. If you output its ARN instead, &lt;code&gt;learn check&lt;/code&gt; still says "Passed". The mock provider makes up both values as &lt;a href="https://developer.hashicorp.com/terraform/language/tests/mocking#generated-data" rel="noopener noreferrer"&gt;random 8-character strings&lt;/a&gt;, so the test can't tell them apart.&lt;/p&gt;

&lt;p&gt;TypeScript had a sneakier version. Node runs TypeScript by &lt;a href="https://nodejs.org/api/typescript.html#type-stripping" rel="noopener noreferrer"&gt;stripping the types out&lt;/a&gt; without checking them, so an exercise about types could pass with no types at all. So TypeScript exercises also go through the real compiler in strict mode, which is the &lt;a href="https://www.typescriptlang.org/tsconfig/#noImplicitAny" rel="noopener noreferrer"&gt;&lt;code&gt;TS7006&lt;/code&gt;&lt;/a&gt; you saw earlier.&lt;/p&gt;

&lt;h3&gt;
  
  
  The quizzes
&lt;/h3&gt;

&lt;p&gt;Here's where the right answer sat:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Right answer is option&lt;/th&gt;
&lt;th&gt;1&lt;/th&gt;
&lt;th&gt;2&lt;/th&gt;
&lt;th&gt;3&lt;/th&gt;
&lt;th&gt;4&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ADK (Gemini)&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Strands (Claude)&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Gemini put the right answer first 83% of the time, and Claude put it second 58% of the time, so a learner who always picked 1 would pass most of ADK's quizzes. The course writer shuffles the options now, and after shuffling, the ADK course's 36 right answers are spread 10, 7, 9 and 10 across the 4 positions.&lt;/p&gt;

&lt;h3&gt;
  
  
  The reviewers
&lt;/h3&gt;

&lt;p&gt;Gemini's reviewer signed off on all 12 of ADK's final lessons. Claude's reviewer, with the same prompt, didn't sign off on any of the Strands lessons it reviewed. So I swapped them. Claude reviewed ADK's 12 lessons and found 29 problems, at least one in every lesson, and most were real. One was a worked example that says &lt;code&gt;terraform fmt&lt;/code&gt; scans the &lt;code&gt;.terraform&lt;/code&gt; folder (that's &lt;a href="https://developer.hashicorp.com/terraform/cli/init#working-directory-contents" rel="noopener noreferrer"&gt;where providers are cached&lt;/a&gt;), and another was a lesson about &lt;code&gt;terraform state mv&lt;/code&gt; whose exercise never has you run it. Gemini reviewed Strands' 23 and passed 13 of them.&lt;/p&gt;

&lt;p&gt;So Claude's course looks worse on paper because its reviewer is harder to please, and Gemini's looks clean partly because its reviewer isn't.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the courses missed
&lt;/h3&gt;

&lt;p&gt;Remember item 2 on my list, a course that covers what people actually do at work? Where I work, we publish Terraform modules, and every team that uses one &lt;a href="https://developer.hashicorp.com/terraform/language/modules/configuration#install-a-specific-version-of-the-module" rel="noopener noreferrer"&gt;pins a version&lt;/a&gt;, with &lt;code&gt;?ref=&lt;/code&gt; on a Git source or &lt;code&gt;version&lt;/code&gt; on a registry module, and bumps it when there's a new release. We do that all the time.&lt;/p&gt;

&lt;p&gt;Neither course taught it as something you do. The ADK course mentions it in one lesson's worked example and quiz, and the Strands course mentions it once in passing. Both researchers built their plans from how the docs are organised, and the docs don't put "publish a module and pin it downstream" on the front page.&lt;/p&gt;

&lt;p&gt;That's a gap in the research, so that's where the fix went. The researcher now writes a list of what someone who uses the subject at work does routinely, and names the objective that teaches each item. A plain check flags any item with no objective, and the research reviewer reads each pairing and says when an objective only teaches part of an item.&lt;/p&gt;

&lt;p&gt;Then I ran the same Terraform research twice more. The check did its job, and in both runs the reviewer caught items that were only half taught, and the research added objectives for them. Module pinning still didn't make it in, because neither run put it on the list in the first place. Of the 6 research runs that wrote this list, 2 put module pinning on it. The check can only hold the objectives to the list, and the list is still the model's idea of what people do at work.&lt;/p&gt;

&lt;p&gt;What I think fixes it is asking. The intake already asks where you're starting from. If it also asked how your team uses the subject, I'd have told it we pin shared modules, and the research would have had to cover that.&lt;/p&gt;

&lt;p&gt;Both courses say their exercises run against LocalStack, &lt;a href="https://docs.localstack.cloud/aws/getting-started/" rel="noopener noreferrer"&gt;an AWS emulator&lt;/a&gt; (i.e. it pretends to be AWS on your own machine), but neither shows you how to set LocalStack up. In the ADK course, that just means the LocalStack steps fail. The Strands course could cost you money, though, because its last 6 lessons set Terraform up to talk to real AWS. Two of those lessons tell you to run &lt;code&gt;terraform apply&lt;/code&gt;, so if you have AWS credentials on your machine, you'd create real VPCs and EC2 instances in your account.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;learn check&lt;/code&gt; is safe in every lesson, because its tests run against the mock provider and never touch AWS. The Strands course's README warns about this at the top now.&lt;/p&gt;

&lt;p&gt;What I think fixes it is running AWS exercises against an emulator by default, and letting you switch to your own AWS account if you want the real thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  So which one?
&lt;/h2&gt;

&lt;p&gt;Here's the whole comparison in one place:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Strands&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;ADK&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Web search&lt;/td&gt;
&lt;td&gt;Bring an MCP server&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;google_search&lt;/code&gt;, Gemini only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reading a page&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;web_fetch&lt;/code&gt;, markdown or agentic&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;load_web_page&lt;/code&gt;, which I couldn't use&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Typed answers&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;structured_output_model=&lt;/code&gt; on the call or the agent, the object on the result&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;output_schema&lt;/code&gt; on the agent, a &lt;code&gt;dict&lt;/code&gt; in session state, plus a flag next to built in tools&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The feedback loop&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;GraphBuilder&lt;/code&gt;, capped by &lt;code&gt;set_max_node_executions&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Workflow&lt;/code&gt;, stopped by your router&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A stage that isn't an agent&lt;/td&gt;
&lt;td&gt;Subclass &lt;code&gt;MultiAgentBase&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Any function&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A spending cap&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;add_hook&lt;/code&gt; on each agent&lt;/td&gt;
&lt;td&gt;A callback on each agent, or one plugin for every agent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Errors from your hooks&lt;/td&gt;
&lt;td&gt;Wrapped in &lt;code&gt;EventLoopException&lt;/code&gt; once a tool has run&lt;/td&gt;
&lt;td&gt;An error event first, raised at the end&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both frameworks ran the same stages through the same kind of graph, with the same typed answers.&lt;/p&gt;

&lt;p&gt;How good the courses came out was down to the models and to my checks. Claude found 3 times as many facts and wrote twice as many lessons, at 4 times Gemini's price per token, and its reviewer was much harder to please. Gemini was cheaper, and its reviewer passed its own side's lessons. Most of the problems I found doing the lessons came from a check I hadn't written yet, or one I'd written wrong.&lt;/p&gt;

&lt;p&gt;So which one would I pick? For myself, Strands. But ADK did some things better, and if those matter more for what you're building, it's the better pick.&lt;/p&gt;

&lt;p&gt;Strands was easier to start with. Once AWS credentials and access to the model in Bedrock are set up, an agent is one Python file and three lines of code, and as it runs it &lt;a href="https://strandsagents.com/docs/user-guide/sdk/streaming/callback-handlers/#default-callback-handler" rel="noopener noreferrer"&gt;prints each tool it calls&lt;/a&gt;. It also had &lt;code&gt;set_max_node_executions&lt;/code&gt;, which stops the graph once it has run a set number of nodes. ADK has nothing like it, and it was my backstop in case the feedback loop ever went around forever.&lt;/p&gt;

&lt;p&gt;ADK is lighter to set up, because a Gemini API key is enough to run it on your machine. &lt;a href="https://adk.dev/runtime/web-interface/" rel="noopener noreferrer"&gt;&lt;code&gt;adk web&lt;/code&gt;&lt;/a&gt; gives you a chat in the browser with a trace of every call next to it. Any plain function can be a stage in the workflow, where Strands needs it wrapped in a class. And its &lt;code&gt;google_search&lt;/code&gt; tool found the current Terraform version 8 times out of 8, though it only works with Gemini.&lt;/p&gt;

&lt;p&gt;And whichever framework you pick, get a second model to review the first.&lt;/p&gt;

&lt;p&gt;Both Terraform courses and the TypeScript course are in the &lt;a href="https://github.com/coolchigi/strands-vs-adk/tree/main/part2-learning-system/out" rel="noopener noreferrer"&gt;repo&lt;/a&gt;, and each lesson lists its known problems at the top. If you work through one and find something they missed, &lt;a href="https://github.com/coolchigi/strands-vs-adk/issues" rel="noopener noreferrer"&gt;open an issue&lt;/a&gt; and tell me.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>aws</category>
      <category>python</category>
      <category>googlecloud</category>
    </item>
    <item>
      <title>Can we build an AI agent that'll kill Udemy?</title>
      <dc:creator>Chigozirim Eke</dc:creator>
      <pubDate>Tue, 06 Oct 2026 05:44:59 +0000</pubDate>
      <link>https://dev.to/chigo_e/can-we-build-an-ai-agent-thatll-kill-udemy-2p8l</link>
      <guid>https://dev.to/chigo_e/can-we-build-an-ai-agent-thatll-kill-udemy-2p8l</guid>
      <description>&lt;p&gt;Lol, not a chance! But I'm glad that caught your attention.&lt;/p&gt;

&lt;p&gt;I do want to build a replacement for it for myself, though.&lt;/p&gt;

&lt;p&gt;Don't get me wrong, I love Udemy. I upskilled on Udemy, and it saw me through quite a few certifications. I still remember preparing for my very first cert with Stéphane Maarek.&lt;/p&gt;

&lt;p&gt;But I'm in a different phase of life now, and long form video courses just aren't doing it for me anymore. I still use them when they're short and straight to the point. Maybe that's the ADHD speaking. Raise your hands if you can relate 🙋‍♀️&lt;/p&gt;

&lt;p&gt;So I started thinking about what I'd want instead. I want to give a system some documentation and have it turn that material into a learn by doing curriculum.&lt;/p&gt;

&lt;p&gt;What makes it different from the million other course generators out there?&lt;/p&gt;

&lt;p&gt;Well, duh... it's ✨ agentic ✨.&lt;/p&gt;

&lt;p&gt;And since I've been wanting to properly understand AWS Strands Agents and Google's Agent Development Kit (ADK), why not use this as an excuse to put both through their paces?&lt;/p&gt;

&lt;p&gt;AWS and Google have done a fantastic job building their respective frameworks. The good thing is that you get a lot of capability out of the box. The bad thing, at least for me, is that there is a lot to cover.&lt;/p&gt;

&lt;p&gt;So grab your coffee, blanket, or emotional support animal. Not sure why I put emotional support animal, but hey, why not.&lt;/p&gt;

&lt;p&gt;In Part 1, we'll walk through Strands and ADK side by side, starting with a basic Hello World and gradually building up from there.&lt;/p&gt;

&lt;p&gt;For Part 2, we'll take what we've learned and actually build the learning system.&lt;/p&gt;

&lt;p&gt;So yes, we're &lt;em&gt;technically&lt;/em&gt; building a low budget “Udemy replacement.”&lt;/p&gt;

&lt;h2&gt;
  
  
  Before we write code: what are we actually comparing?
&lt;/h2&gt;

&lt;p&gt;Rather than starting with a feature checklist, I want to answer a few basic questions about how each framework works:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;How do I define an agent?&lt;/li&gt;
&lt;li&gt;How does the agent interact with the model?&lt;/li&gt;
&lt;li&gt;How are tools defined and connected?&lt;/li&gt;
&lt;li&gt;Where does state live?&lt;/li&gt;
&lt;li&gt;What control points do I have during execution?&lt;/li&gt;
&lt;li&gt;How do multiple agents work together?&lt;/li&gt;
&lt;li&gt;What does the framework provide for evaluation and observability?&lt;/li&gt;
&lt;li&gt;And finally, how can I deploy what I've built?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We'll use those questions throughout the comparison.&lt;/p&gt;

&lt;p&gt;And because I have a Computer Science degree and apparently cannot start a technical project any other way...&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; The examples in this series were written and tested with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;AWS Strands Agents: &lt;code&gt;1.55.1&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Google ADK: &lt;code&gt;2.9.0&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Python: &lt;code&gt;3.13&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pin them if you install both into one environment. Unpinned, &lt;code&gt;pip install strands-agents google-adk&lt;/code&gt; quietly gives you &lt;code&gt;google-adk 1.14.1&lt;/code&gt;, a whole major version back, because ADK caps OpenTelemetry lower than Strands does and pip backtracks through ADK to satisfy both. Pinning the OpenTelemetry pair too keeps that from coming back when either framework moves:&lt;/p&gt;


&lt;pre class="highlight plaintext"&gt;&lt;code&gt;strands-agents==1.55.1
google-adk==2.9.0
opentelemetry-api==1.42.1
opentelemetry-sdk==1.42.1
&lt;/code&gt;&lt;/pre&gt;


&lt;p&gt;Both frameworks are actively developed. &lt;code&gt;strands-agents 1.58.0&lt;/code&gt; and &lt;code&gt;google-adk 2.11.0&lt;/code&gt; were out by the time this went up, and APIs may change after the versions above. Everything here was checked against the installed packages on 27 September 2026, and the code in this post was assembled and run.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Every example below is in a companion repo: &lt;strong&gt;&lt;a href="https://github.com/coolchigi/strands-vs-adk" rel="noopener noreferrer"&gt;github.com/coolchigi/strands-vs-adk&lt;/a&gt;&lt;/strong&gt;. The code is in &lt;code&gt;part1-hello-world/&lt;/code&gt;, with one numbered folder per section, from &lt;code&gt;01-hello-world&lt;/code&gt; to &lt;code&gt;08-deployment&lt;/code&gt;, and both frameworks side by side in each. It ships a &lt;code&gt;smoke_test.py&lt;/code&gt; that parses every file, resolves every import, and builds every agent it can without calling a model, so you can confirm your setup works before spending a token.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hello World
&lt;/h2&gt;

&lt;p&gt;Here's how they both do it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;strong&gt;Strands&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;ADK&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pip install strands-agents&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pip install google-adk&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One Python file&lt;/td&gt;
&lt;td&gt;A folder with &lt;code&gt;agent.py&lt;/code&gt; and &lt;code&gt;__init__.py&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;agent = Agent()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;root_agent = Agent(...)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;python hello-world.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;adk run course_agent&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bedrock with Claude Sonnet 4.6 by default&lt;/td&gt;
&lt;td&gt;Model named on the agent&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Strands
&lt;/h3&gt;

&lt;p&gt;The smallest Strands agent is this, and you run it with &lt;code&gt;python hello-world.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# hello-world.py
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;strands&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;

&lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="nf"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Hello, world!&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Strands works with several model providers. By default it uses Claude Sonnet 4.6 on Amazon Bedrock, so you need AWS credentials and access to that model in Bedrock to run this. The &lt;a href="https://strandsagents.com/docs/user-guide/sdk/quickstart/python/" rel="noopener noreferrer"&gt;Strands Python quickstart&lt;/a&gt; walks through setting that up. To use a different model, pass its id to &lt;code&gt;Agent&lt;/code&gt; as &lt;code&gt;model="..."&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://strandsagents.com/docs/api/python/strands.agent.agent/" rel="noopener noreferrer"&gt;&lt;code&gt;Agent&lt;/code&gt;&lt;/a&gt; is the main thing you work with in Strands. When we call it, it sends our message to the model, and once we add tools, it also runs them and passes their results back to the model.&lt;/p&gt;

&lt;h3&gt;
  
  
  ADK
&lt;/h3&gt;

&lt;p&gt;ADK expects a folder.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;course_agent/
├── __init__.py     # from . import agent
├── agent.py        # defines root_agent
└── .env            # GOOGLE_API_KEY="..."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;ADK finds the agent by importing &lt;code&gt;agent.py&lt;/code&gt; and reading a variable called &lt;code&gt;root_agent&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# course_agent/agent.py
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;google.adk.agents&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;

&lt;span class="n"&gt;root_agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gemini-flash-latest&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;root_agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Says hello.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;instruction&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;You are a helpful assistant.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Agent&lt;/code&gt; and &lt;code&gt;LlmAgent&lt;/code&gt; are the same class, so you can use either name.&lt;/p&gt;

&lt;p&gt;Then we run it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;adk run course_agent
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or run &lt;code&gt;adk web&lt;/code&gt; to chat with the same agent in your browser, with a trace of each call next to the chat.&lt;/p&gt;

&lt;h3&gt;
  
  
  Putting the two side by side
&lt;/h3&gt;

&lt;p&gt;A Strands agent is a Python object that we create and call in our own script. An ADK agent is a folder, and ADK's commands find it and run it for us. The &lt;a href="https://adk.dev/get-started/python/" rel="noopener noreferrer"&gt;ADK Python quickstart&lt;/a&gt; walks through the full setup.&lt;/p&gt;

&lt;p&gt;Once they're running, both work the same way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Your application
        │
        ▼
      Agent
        │
        ▼
      Model
        │
        ▼
    Response
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's Hello World on both frameworks. Next, we'll give the agent a tool, or "hands", as they're often called.&lt;/p&gt;

&lt;h2&gt;
  
  
  Giving our agent a tool
&lt;/h2&gt;

&lt;p&gt;Our learning system will eventually need to do things like retrieve documentation, create exercises, and keep track of what we've covered. For now, let's start with something much smaller.&lt;/p&gt;

&lt;p&gt;We'll give the agent a function that returns a course topic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_course_topic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Get the topic for the course.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Building AI agents&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each framework has its own way of turning that function into a tool the agent can use.&lt;/p&gt;

&lt;p&gt;Here's how they both do it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;strong&gt;Strands&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;ADK&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;@tool&lt;/code&gt; on the function&lt;/td&gt;
&lt;td&gt;Nothing on the function&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tools=[get_course_topic]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tools=[get_course_topic]&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reads name, docstring, type hints, defaults&lt;/td&gt;
&lt;td&gt;Reads name, docstring, type hints, defaults&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Becomes a &lt;code&gt;DecoratedFunctionTool&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Wrapped in a &lt;code&gt;FunctionTool&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Strands
&lt;/h3&gt;

&lt;p&gt;In Strands, we turn a simple Python function into a tool for the agent using the &lt;code&gt;@tool&lt;/code&gt; decorator. The decorator uses the function's name, docstring, type hints and defaults to describe the tool. The name and docstring tell the model what the tool does, and the type hints tell it what to pass in. Then we pass it to the agent in &lt;code&gt;tools&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# hello-world.py
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;strands&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;

&lt;span class="nd"&gt;@tool&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_course_topic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Get the topic for the course.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Building AI agents&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;get_course_topic&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="nf"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;What topic should I teach?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  ADK
&lt;/h3&gt;

&lt;p&gt;In ADK, there's no decorator. We pass the plain function into &lt;code&gt;tools&lt;/code&gt;, and ADK reads the same things (the name, docstring, type hints and defaults), then wraps it in a &lt;code&gt;FunctionTool&lt;/code&gt; for us:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# course_agent/agent.py
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;google.adk.agents&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_course_topic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Get the topic for the course.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Building AI agents&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="n"&gt;root_agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;course_agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gemini-flash-latest&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Helps create learning materials.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;instruction&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;You help create learning materials.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;get_course_topic&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We run it the same way as before, from the &lt;code&gt;02-tools&lt;/code&gt; folder, this time with a question:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;adk run course_agent &lt;span class="s2"&gt;"What topic should I teach?"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Putting the two side by side
&lt;/h3&gt;

&lt;p&gt;Both sides use the same plain Python function. The only difference is that Strands wants &lt;code&gt;@tool&lt;/code&gt; on it, and ADK takes it as it is.&lt;/p&gt;

&lt;p&gt;We've given the agent the function, but we never told it to call it. So when we ask "What topic should I teach?", who decides whether it gets called?&lt;/p&gt;

&lt;p&gt;The answer is ... &lt;strong&gt;the agent loop&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What runs the agent loop?
&lt;/h2&gt;

&lt;p&gt;Here's how they both do it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;strong&gt;Strands&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;ADK&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;agent("What topic should I teach?")&lt;/code&gt;&lt;br&gt;&lt;br&gt;&lt;strong&gt;Agent → Model → Tool → Model → Response&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;runner.run_async(...)&lt;/code&gt;&lt;br&gt;&lt;br&gt;&lt;strong&gt;Runner → Agent → Model → Tool → Event → Runner → ...&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Agent&lt;/code&gt; runs the loop itself.&lt;/td&gt;
&lt;td&gt;A separate &lt;code&gt;Runner&lt;/code&gt; runs the loop and hands each step back to your code as an event.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Strands
&lt;/h3&gt;

&lt;p&gt;We call the agent ourselves. It's the last line of &lt;code&gt;hello-world.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# hello-world.py
&lt;/span&gt;&lt;span class="nf"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;What topic should I teach?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From there, &lt;code&gt;Agent&lt;/code&gt; runs the loop on its own. It goes back and forth between the model and your tools until the model gives a final answer.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://strandsagents.com/docs/user-guide/sdk/agents/agent-loop/" rel="noopener noreferrer"&gt;Strands agent loop documentation&lt;/a&gt; describes this as a repeating cycle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User input
    │
    ▼
  Model
    │
    ├── No tool call ───────► Final response
    │
    └── Tool call
          │
          ▼
        Tool
          │
          ▼
      Tool result
          │
          ▼
        Model
          │
          └──────────────► ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model decides whether it needs a tool. If it does, Strands executes the tool and adds the result to the conversation before calling the model again. If the model produces a final response instead, the loop ends.&lt;/p&gt;

&lt;p&gt;Strands also gives us controls around that loop. We can set &lt;a href="https://strandsagents.com/docs/user-guide/sdk/agents/agent-loop/#invocation-limits" rel="noopener noreferrer"&gt;limits for turns and tokens&lt;/a&gt;, &lt;a href="https://strandsagents.com/docs/user-guide/sdk/agents/agent-loop/#cancellation" rel="noopener noreferrer"&gt;cancel an invocation&lt;/a&gt;, and attach &lt;a href="https://strandsagents.com/docs/user-guide/sdk/agents/hooks/" rel="noopener noreferrer"&gt;hooks&lt;/a&gt; around different points in the agent lifecycle.&lt;/p&gt;

&lt;h3&gt;
  
  
  ADK
&lt;/h3&gt;

&lt;p&gt;In ADK, nothing in our code calls &lt;code&gt;root_agent&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In Hello World, we typed &lt;code&gt;adk run course_agent&lt;/code&gt; and the CLI called it for us. It built a &lt;code&gt;Runner&lt;/code&gt;, created a session, wrapped what we typed in a &lt;code&gt;types.Content&lt;/code&gt;, and passed it to &lt;code&gt;runner.run_async()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So to see the loop, we write those four steps ourselves.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;agent.py&lt;/code&gt; from the tools section doesn't change. The new code goes in &lt;code&gt;main.py&lt;/code&gt;, next to the &lt;code&gt;course_agent&lt;/code&gt; folder, and imports the agent from it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;course_agent/
├── __init__.py
├── agent.py        # root_agent lives here, unchanged
└── .env
main.py             # everything below goes here
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;First, a session service, which keeps track of the conversation, and the &lt;code&gt;Runner&lt;/code&gt; that runs the agent loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# main.py
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;course_agent.agent&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;root_agent&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;google.adk.runners&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Runner&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;google.adk.sessions&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;InMemorySessionService&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;google.genai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;types&lt;/span&gt;

&lt;span class="n"&gt;session_service&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;InMemorySessionService&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;runner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Runner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;app_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;course_agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;root_agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;session_service&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;session_service&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Creating a session and running the agent both have to be awaited, and &lt;code&gt;await&lt;/code&gt; only works inside an async function, so the rest of the code goes in one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# main.py, continued
&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;session_service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_session&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;app_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;course_agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We create the session first because &lt;code&gt;run_async&lt;/code&gt; needs its &lt;code&gt;session_id&lt;/code&gt;. The &lt;code&gt;Runner&lt;/code&gt; won't create one for us unless we build it with &lt;code&gt;auto_create_session=True&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Then the message itself. ADK expects a &lt;code&gt;types.Content&lt;/code&gt;, which is one message with a role saying who sent it, and a list of &lt;code&gt;types.Part&lt;/code&gt;s holding what it says. Ours comes from the user, with one part holding our question:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# main.py, still inside main()
&lt;/span&gt;    &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;types&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Content&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;role&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;types&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Part&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;What topic should I teach?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And now the loop itself. The last line, &lt;code&gt;asyncio.run(main())&lt;/code&gt;, is what starts it all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# main.py, the rest of the file
&lt;/span&gt;    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;runner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run_async&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;new_message&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_final_response&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no content: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;error_message&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before running it, export your key in the same terminal. &lt;code&gt;python main.py&lt;/code&gt; doesn't read the &lt;code&gt;.env&lt;/code&gt; file. Only the ADK commands that load your agent, like &lt;code&gt;adk run&lt;/code&gt; and &lt;code&gt;adk web&lt;/code&gt;, do that. Run it from &lt;code&gt;03-agent-loop&lt;/code&gt;, the folder that holds both &lt;code&gt;main.py&lt;/code&gt; and &lt;code&gt;course_agent&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;part1-hello-world/03-agent-loop
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;GOOGLE_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;
python main.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Windows PowerShell, set the key with &lt;code&gt;$env:GOOGLE_API_KEY="..."&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The important part of &lt;code&gt;main.py&lt;/code&gt; is the &lt;code&gt;async for&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;run_async()&lt;/code&gt; hands us a stream of events while the agent runs. According to the &lt;a href="https://adk.dev/runtime/event-loop/" rel="noopener noreferrer"&gt;ADK event loop documentation&lt;/a&gt;, the &lt;code&gt;Runner&lt;/code&gt; starts the agent, and each time the agent produces an event, the &lt;code&gt;Runner&lt;/code&gt; saves it and passes it on before the agent carries on.&lt;/p&gt;

&lt;p&gt;Simplified, the event loop looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Application
    │
    ▼
  Runner
    │
    ▼
  Agent
    │
    ▼
  Model
    │
    ├── Tool call
    │      │
    │      ▼
    │     Tool
    │      │
    │      ▼
    │   Tool result
    │
    ▼
  Event
    │
    ▼
  Runner
    │
    ├── Process event
    ├── Save state
    └── Forward event
           │
           ▼
        Agent continues
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An &lt;code&gt;Event&lt;/code&gt; can represent things such as user input, an agent response, a tool call or result, or a state change. The &lt;code&gt;Runner&lt;/code&gt; passes these events to the configured &lt;code&gt;SessionService&lt;/code&gt;, which can apply state changes and add the event to the session history before the event is passed back to the application. See the &lt;a href="https://adk.dev/runtime/event-loop/" rel="noopener noreferrer"&gt;ADK event loop documentation&lt;/a&gt; for the full flow.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;else&lt;/code&gt; in our loop is worth keeping. &lt;code&gt;is_final_response()&lt;/code&gt; returns &lt;code&gt;True&lt;/code&gt; for error events as well as answers, and an error event has no &lt;code&gt;content&lt;/code&gt;. Without the &lt;code&gt;else&lt;/code&gt;, a bad API key ends the run with &lt;code&gt;AttributeError: 'NoneType' object has no attribute 'parts'&lt;/code&gt;, which says nothing about the key. With it, you get &lt;code&gt;no content: 400 INVALID_ARGUMENT ... API key not valid&lt;/code&gt;, and the run ends on Google's own error instead.&lt;/p&gt;

&lt;h3&gt;
  
  
  Putting the two side by side
&lt;/h3&gt;

&lt;p&gt;So we already have our first significant difference.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvaj9lix0cw22vvyduyip.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvaj9lix0cw22vvyduyip.gif" alt="A pulse orbiting three components in Strands and five in ADK, showing the same tool call routed through each framework" width="760" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;With Strands, the &lt;code&gt;Agent&lt;/code&gt; owns the loop. We invoke the agent, and it manages the repeated model and tool calls for us.&lt;/p&gt;

&lt;p&gt;With ADK, the &lt;code&gt;Agent&lt;/code&gt; is one piece of a bigger setup. The &lt;code&gt;Runner&lt;/code&gt; runs it, and every step comes back as an &lt;code&gt;Event&lt;/code&gt; that the &lt;code&gt;Runner&lt;/code&gt; saves and passes on to our code.&lt;/p&gt;

&lt;p&gt;That shows up in the setup. Strands took one line. ADK took a session service, a runner, a session, and a message before anything reached the model.&lt;/p&gt;

&lt;p&gt;That extra piece, the &lt;code&gt;Runner&lt;/code&gt;, is going to matter when we look at where conversation state actually lives.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where does state live?
&lt;/h2&gt;

&lt;p&gt;Here's how they both do it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;strong&gt;Strands&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;ADK&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;agent.messages&lt;/code&gt;&lt;br&gt;&lt;br&gt;&lt;code&gt;agent.state&lt;/code&gt;&lt;br&gt;&lt;br&gt;Invocation state&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;session.events&lt;/code&gt;&lt;br&gt;&lt;br&gt;&lt;code&gt;session.state&lt;/code&gt;&lt;br&gt;&lt;br&gt;&lt;code&gt;SessionService&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Conversation history&lt;/strong&gt;&lt;br&gt;Messages exchanged with the model&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Conversation history&lt;/strong&gt;&lt;br&gt;Events recorded in the session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Agent state&lt;/strong&gt;&lt;br&gt;A key value store the agent, tools, and our code can read and write&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Session state&lt;/strong&gt;&lt;br&gt;A dictionary attached to the session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Invocation state&lt;/strong&gt;&lt;br&gt;Temporary data available during one invocation&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;State scopes&lt;/strong&gt;&lt;br&gt;Session, user, app, and temporary state&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Strands
&lt;/h3&gt;

&lt;p&gt;Strands keeps the conversation history on the &lt;code&gt;Agent&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;strands&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;

&lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="nf"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;My name is Chigo.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The messages contain the conversation between the user and assistant, including tool calls and tool results.&lt;/p&gt;

&lt;p&gt;Strands also has agent state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;course_topic&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Building AI agents&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;course_topic&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;course_topic&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;agent.state&lt;/code&gt; is a key value store. We go through &lt;code&gt;get&lt;/code&gt;, &lt;code&gt;set&lt;/code&gt; and &lt;code&gt;delete&lt;/code&gt;, because it isn't a real dictionary and &lt;code&gt;agent.state["course_topic"]&lt;/code&gt; raises &lt;code&gt;TypeError&lt;/code&gt;. Our code and our tools can both read and write it, and none of it reaches the model as part of the conversation.&lt;/p&gt;

&lt;p&gt;The keys are strings we choose. The values have to be JSON serializable: strings, numbers, booleans, lists, dicts or &lt;code&gt;None&lt;/code&gt;. Store a function as a value and Strands raises &lt;code&gt;ValueError&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Strands also has invocation state. It lives for one call to the agent and disappears afterwards. Our tools, and the hooks we'll meet in the next section, can read it while that call runs, and the model never sees it.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://strandsagents.com/docs/user-guide/sdk/agents/state/" rel="noopener noreferrer"&gt;Strands state documentation&lt;/a&gt; covers these different types of state.&lt;/p&gt;

&lt;p&gt;If we need state to survive beyond the current process, Strands provides session management. Sessions can persist agent state and conversation history using a configured session manager and storage backend. The &lt;a href="https://strandsagents.com/docs/user-guide/sdk/agents/session-management/" rel="noopener noreferrer"&gt;Strands session management documentation&lt;/a&gt; covers the available options.&lt;/p&gt;

&lt;p&gt;So for Strands, we can think about it like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Agent
├── Conversation ──┐
├── State ─────────┤
│                  ▼
│               Session
│                  │
│                  ▼
│               Storage
│
└── Invocation state   (gone when the call ends)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  ADK
&lt;/h3&gt;

&lt;p&gt;ADK keeps conversation history and state inside a &lt;code&gt;Session&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A session represents a conversation between a user and an agent. It contains the events generated during that interaction and the state associated with the session.&lt;/p&gt;

&lt;p&gt;The application works with a &lt;code&gt;SessionService&lt;/code&gt; to create and retrieve sessions. We can put starting state in while we create one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;session_service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_session&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;app_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;course_agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;course_topic&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Building AI agents&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reading it back is what we'd expect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;course_topic&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="c1"&gt;# Building AI agents
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So what about changing it once the session exists?&lt;/p&gt;

&lt;p&gt;The obvious move is to assign straight into &lt;code&gt;session.state&lt;/code&gt;. ADK's documentation has a warning section telling us not to. Modifying &lt;code&gt;session.state&lt;/code&gt; on a session we pulled out of the &lt;code&gt;SessionService&lt;/code&gt; bypasses the event history and won't reliably save. The &lt;a href="https://adk.dev/sessions/state/" rel="noopener noreferrer"&gt;ADK state documentation&lt;/a&gt; covers what else goes wrong.&lt;/p&gt;

&lt;p&gt;For anything an agent produces, we give the agent an &lt;code&gt;output_key&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;researcher&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;researcher&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gemini-flash-latest&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;instruction&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Find information from official documentation and other reliable sources.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;output_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;research&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Whatever &lt;code&gt;researcher&lt;/code&gt; returns now lands in &lt;code&gt;session.state["research"]&lt;/code&gt;, and the &lt;code&gt;Runner&lt;/code&gt; saves it through an event, the same way it saves everything else.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;output_key&lt;/code&gt; is how one agent's output becomes the next agent's input. We'll lean on that in Part 2.&lt;/p&gt;

&lt;p&gt;Inside a tool, or a callback (those come up in the next section), the rule is the same. We write to &lt;code&gt;tool_context.state&lt;/code&gt; or &lt;code&gt;callback_context.state&lt;/code&gt;, and ADK records the change on the event as its &lt;code&gt;state_delta&lt;/code&gt;, so it gets saved like everything else.&lt;/p&gt;

&lt;p&gt;ADK also supports different state scopes, and the scope is the prefix on the key. &lt;code&gt;user:topic&lt;/code&gt; is shared across every session for that user. &lt;code&gt;app:topic&lt;/code&gt; is shared across the whole application. &lt;code&gt;temp:topic&lt;/code&gt; lasts for the current invocation. No prefix means the key belongs to this session.&lt;/p&gt;

&lt;p&gt;Whether any of that survives a restart depends on the &lt;code&gt;SessionService&lt;/code&gt; we picked. &lt;code&gt;InMemorySessionService&lt;/code&gt; holds everything in memory, so session, &lt;code&gt;user:&lt;/code&gt; and &lt;code&gt;app:&lt;/code&gt; state die with the process. &lt;code&gt;DatabaseSessionService&lt;/code&gt; writes to our own database (install it with &lt;code&gt;pip install "google-adk[db]"&lt;/code&gt;) and &lt;code&gt;VertexAiSessionService&lt;/code&gt; to the managed one, and under either of those the same state comes back. &lt;code&gt;temp:&lt;/code&gt; is never written to storage at all.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://adk.dev/sessions/" rel="noopener noreferrer"&gt;ADK Sessions documentation&lt;/a&gt; explains how sessions, events, and state work together.&lt;/p&gt;

&lt;p&gt;So the ADK picture looks more like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SessionService
      │
      ▼
   Session
   ├── Events
   └── State
       ├── session
       ├── user
       ├── app
       └── temporary
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When ADK processes an event, the &lt;code&gt;Runner&lt;/code&gt; works with the &lt;code&gt;SessionService&lt;/code&gt; to persist the event and apply any state changes before continuing execution.&lt;/p&gt;

&lt;h3&gt;
  
  
  Putting the two side by side
&lt;/h3&gt;

&lt;p&gt;The difference shows up when we write state:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fls63sbmyyebt1kix844o.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fls63sbmyyebt1kix844o.gif" alt="A write landing directly on agent.state in Strands, versus travelling through Event, Runner and SessionService in ADK" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Strands exposes conversation history and agent state through the Agent, with session management available when we need persistence across invocations. ADK puts conversation history and session state behind a Session and SessionService.&lt;/p&gt;

&lt;h2&gt;
  
  
  What control points do we have?
&lt;/h2&gt;

&lt;p&gt;Here's how they both do it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;strong&gt;Strands&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;ADK&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;agent.add_hook(...)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;callbacks passed to &lt;code&gt;Agent(...)&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;BeforeInvocationEvent&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;before_agent_callback=...&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AfterInvocationEvent&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;after_agent_callback=...&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;BeforeModelCallEvent&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;before_model_callback=...&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AfterModelCallEvent&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;after_model_callback=...&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;BeforeToolCallEvent&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;before_tool_callback=...&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AfterToolCallEvent&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;after_tool_callback=...&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;limits={"turns": 5}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;RunConfig(max_llm_calls=200)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both frameworks let us run our own code at different points while an agent is running.&lt;/p&gt;

&lt;h3&gt;
  
  
  Strands
&lt;/h3&gt;

&lt;p&gt;Strands uses hooks.&lt;/p&gt;

&lt;p&gt;We create the agent and register a hook with &lt;code&gt;add_hook()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;strands&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;strands.hooks&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BeforeModelCallEvent&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;log_model_call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;BeforeModelCallEvent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Calling the model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_hook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;log_model_call&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The hook receives a &lt;code&gt;BeforeModelCallEvent&lt;/code&gt;, with details about the model call that's about to happen.&lt;/p&gt;

&lt;p&gt;Strands provides hooks for different stages of an invocation, including model calls, tool calls, and the overall invocation. There are multi-agent events too. &lt;code&gt;BeforeNodeCallEvent&lt;/code&gt; and &lt;code&gt;AfterNodeCallEvent&lt;/code&gt; fire around each node in a graph, which we'll build in the multi-agent section.&lt;/p&gt;

&lt;p&gt;For example, we can inspect a tool call before it runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;strands.hooks&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BeforeToolCallEvent&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;inspect_tool_call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;BeforeToolCallEvent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Calling: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tool_use&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_hook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inspect_tool_call&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The hook can also affect what happens next. Depending on the event, we can modify a request, prevent a tool call, retry a tool call, or modify a result.&lt;/p&gt;

&lt;p&gt;Strands also lets us put limits on an invocation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nf"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Create a lesson about photosynthesis.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;limits&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;turns&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;total_tokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;50_000&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;turns&lt;/code&gt; caps how many times the agent loop goes round, where one turn is a model call plus any tools that run after it. &lt;code&gt;output_tokens&lt;/code&gt; and &lt;code&gt;total_tokens&lt;/code&gt; cap token spend across the whole invocation.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://strandsagents.com/docs/user-guide/sdk/agents/hooks/" rel="noopener noreferrer"&gt;Strands hooks documentation&lt;/a&gt; covers the available hook events and how to register them. The &lt;a href="https://strandsagents.com/docs/user-guide/sdk/agents/agent-loop/" rel="noopener noreferrer"&gt;agent loop documentation&lt;/a&gt; covers invocation limits.&lt;/p&gt;

&lt;h3&gt;
  
  
  ADK
&lt;/h3&gt;

&lt;p&gt;ADK uses callbacks.&lt;/p&gt;

&lt;p&gt;With ADK, we pass callbacks when defining the agent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;before_model_callback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;callback_context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;llm_request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Calling the model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;root_agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;course_agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gemini-flash-latest&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;instruction&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;You help create learning materials.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;before_model_callback&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;before_model_callback&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are callbacks for different stages of execution, eight slots on the agent, and each one takes a function we write like the one above:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;before_agent_callback    after_agent_callback
before_model_callback    after_model_callback    on_model_error_callback
before_tool_callback     after_tool_callback     on_tool_error_callback
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The callback functions receive context about the current execution. Depending on the callback, we can inspect or modify what is about to happen, or change the result before execution continues.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://adk.dev/callbacks/" rel="noopener noreferrer"&gt;ADK callbacks documentation&lt;/a&gt; covers the different callback types and what each one can do.&lt;/p&gt;

&lt;h3&gt;
  
  
  Putting the two side by side
&lt;/h3&gt;

&lt;p&gt;With Strands, we register hooks on the agent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_hook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;log_model_call&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With ADK, we provide callbacks when defining the agent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;...,&lt;/span&gt;
    &lt;span class="n"&gt;before_model_callback&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;log_model_call&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Strands takes hooks at construction too, with &lt;code&gt;Agent(hooks=[...])&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The two frameworks also put execution limits in different places. Strands takes them on the call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nf"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;limits&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;turns&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;ADK puts them on the run instead, in a &lt;code&gt;RunConfig&lt;/code&gt; we hand to the runner:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;google.adk.agents&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;RunConfig&lt;/span&gt;

&lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;RunConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_llm_calls&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;runner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run_async&lt;/span&gt;&lt;span class="p"&gt;(...,&lt;/span&gt; &lt;span class="n"&gt;run_config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Go past the cap and ADK raises &lt;code&gt;LlmCallsLimitExceededError&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;ADK sets &lt;code&gt;max_llm_calls&lt;/code&gt; to 500 whether we ask for it or not. Strands leaves &lt;code&gt;limits&lt;/code&gt; unset, so nothing is capped until we say so. The &lt;a href="https://adk.dev/runtime/runconfig/" rel="noopener noreferrer"&gt;ADK RunConfig documentation&lt;/a&gt; covers the other run level settings, including streaming mode and artifact saving.&lt;/p&gt;

&lt;p&gt;So if we want to add code around a model or tool call, both frameworks give us a place to do it. Strands calls them hooks and ADK calls them callbacks. On Strands we can add a hook to an agent we already have, and on ADK the callbacks go in when we build the agent.&lt;/p&gt;

&lt;h2&gt;
  
  
  What if one agent isn't enough?
&lt;/h2&gt;

&lt;p&gt;We've seen how both frameworks let us control what happens while an agent is running.&lt;/p&gt;

&lt;p&gt;But what happens when one agent isn't enough?&lt;/p&gt;

&lt;p&gt;For our learning system, we need a few different agents working together:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Researcher&lt;/strong&gt;: Finds information from official documentation, blogs, and other reliable sources.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Curriculum Builder&lt;/strong&gt;: Takes the research and turns it into a structured curriculum.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Teacher&lt;/strong&gt;: Takes the curriculum and creates the actual lessons.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Feedback Agent&lt;/strong&gt;: Reviews the work produced by the other agents and identifies what needs revision.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The basic flow looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Researcher
     │
     ▼
Curriculum Builder
     │
     ▼
Teacher
     │
     ▼
Feedback
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But the Feedback Agent isn't only checking the lessons.&lt;/p&gt;

&lt;p&gt;It needs to check the research, curriculum, and lessons, and send each one back for another pass when something's wrong.&lt;/p&gt;

&lt;p&gt;So how do Strands and ADK let us put agents together?&lt;/p&gt;

&lt;p&gt;Here's how they both do it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;strong&gt;Strands&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;ADK&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;An agent can be passed directly into another agent's &lt;code&gt;tools&lt;/code&gt; list.&lt;/td&gt;
&lt;td&gt;An agent can have other agents as &lt;code&gt;sub_agents&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tools=[researcher]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sub_agents=[researcher]&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The parent agent can decide when to call another agent.&lt;/td&gt;
&lt;td&gt;A parent agent can delegate work to child agents.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Graphs can connect agents into a defined workflow.&lt;/td&gt;
&lt;td&gt;Graph based workflows can connect agents and other executable nodes into a defined workflow.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Strands
&lt;/h3&gt;

&lt;p&gt;The simplest way to have agents work together in Strands is to give one agent another agent as a tool.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;strands&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;

&lt;span class="n"&gt;researcher&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;researcher&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;system_prompt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Find information from official documentation, blogs, and other reliable sources.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;curriculum_builder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;curriculum_builder&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;system_prompt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Turn the research into a structured curriculum.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;researcher&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now &lt;code&gt;curriculum_builder&lt;/code&gt; can decide when it needs the researcher.&lt;/p&gt;

&lt;p&gt;Passing an Agent into &lt;code&gt;tools&lt;/code&gt; converts it into a tool that takes one string and hands back the agent's answer. Anything more structured going in has to be squeezed through that one string.&lt;/p&gt;

&lt;p&gt;We could continue this pattern by giving the &lt;code&gt;Teacher&lt;/code&gt; access to the &lt;code&gt;Curriculum Builder&lt;/code&gt;, and the &lt;code&gt;Feedback Agent&lt;/code&gt; access to the &lt;code&gt;Teacher&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;teacher&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;teacher&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;system_prompt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Create lessons from the curriculum.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;curriculum_builder&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;feedback&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;feedback&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;system_prompt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Review the research, curriculum, and lessons.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;teacher&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But there's a problem with representing our workflow this way.&lt;/p&gt;

&lt;p&gt;The relationship is controlled by the agent making the call. If &lt;code&gt;teacher&lt;/code&gt; needs the curriculum, it can call &lt;code&gt;curriculum_builder&lt;/code&gt;. If &lt;code&gt;feedback&lt;/code&gt; needs the lesson, it can call &lt;code&gt;teacher&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;But we already know the order we want. We also know that Feedback needs to review different parts of the system and potentially trigger a revision.&lt;/p&gt;

&lt;p&gt;For that, Strands provides &lt;strong&gt;Graphs&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A Graph lets us define the nodes and edges between agents ourselves. Agents become nodes, and the edges set the order they run in. An edge can also point back to an earlier node, which is how we get the feedback loop our system needs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Drop the &lt;code&gt;tools&lt;/code&gt; wiring before doing this.&lt;/strong&gt; The graph replaces it. Leave both in and every node can run the whole chain below it again through its tools: Feedback calls Teacher, which calls Curriculum Builder, which calls Researcher. And when a model asks for the same agent twice at once, Strands refuses the second call while the first is still running:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tool_name=&amp;lt;researcher&amp;gt;, tool_use_id=&amp;lt;...&amp;gt; | agent is already processing a request
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That comes back to the calling model as a tool error, so it asks again. My run took ten minutes instead of ninety seconds and never finished. So the four agents below are the same as before, minus &lt;code&gt;tools=[...]&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;researcher&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;researcher&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;system_prompt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;curriculum_builder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;curriculum_builder&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;system_prompt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;teacher&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;teacher&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;system_prompt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;feedback&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;feedback&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;system_prompt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the graph itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;strands.multiagent&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;GraphBuilder&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;GraphBuilder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;researcher&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;researcher&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;curriculum_builder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;curriculum&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;teacher&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;teacher&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;feedback&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;feedback&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_edge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;researcher&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;curriculum&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_edge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;curriculum&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;teacher&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_edge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;teacher&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;feedback&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Edges also take a condition, and that's how the graph gets its cycle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;lesson_needs_work&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;feedback&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;feedback&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;revise the lesson&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;feedback&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_edge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;feedback&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;teacher&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;condition&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;lesson_needs_work&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The condition gets the graph state. &lt;code&gt;state.results&lt;/code&gt; is a dict keyed by node id, and each entry's &lt;code&gt;.result&lt;/code&gt; holds what that node returned. So the Feedback Agent's own output decides whether the edge back to &lt;code&gt;teacher&lt;/code&gt; is taken.&lt;/p&gt;

&lt;p&gt;Then we bound it and build it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_entry_point&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;researcher&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_max_node_executions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reset_on_revisit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;graph&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Order matters here. &lt;code&gt;build()&lt;/code&gt; takes a snapshot of the edges, so an edge added afterwards never makes it into the graph and the loop silently isn't there.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;set_max_node_executions()&lt;/code&gt; caps the total number of node runs, which is what stops a feedback loop that never converges. Hit the cap and the graph ends with a &lt;code&gt;FAILED&lt;/code&gt; status, so check the result.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;set_execution_timeout()&lt;/code&gt; is the other way to put a boundary around it, with a time limit for the whole graph.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;reset_on_revisit(True)&lt;/code&gt; clears a node's previous state when the graph comes back to it, so a second pass starts clean.&lt;/p&gt;

&lt;p&gt;So our eventual workflow could look more like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Researcher
     │
     ▼
Curriculum Builder
     │
     ▼
Teacher
     │
     ▼
Feedback
  │     │     │
  ▼     ▼     ▼
Research Curriculum Lessons
  │     │     │
  └─────┴─────┘
        │
        ▼
     Revision
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part is that the Graph describes the relationships between the agents instead of leaving the entire workflow to one agent's decisions.&lt;/p&gt;

&lt;h3&gt;
  
  
  ADK
&lt;/h3&gt;

&lt;p&gt;ADK gives us a parent and child relationship between agents.&lt;/p&gt;

&lt;p&gt;An agent can contain other agents as &lt;code&gt;sub_agents&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;google.adk.agents&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;

&lt;span class="n"&gt;researcher&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;researcher&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gemini-flash-latest&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;instruction&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Find information from official documentation, blogs, and other reliable sources.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;curriculum_builder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;curriculum_builder&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gemini-flash-latest&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;instruction&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Turn the research into a structured curriculum.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;sub_agents&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;researcher&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here, &lt;code&gt;curriculum_builder&lt;/code&gt; is the parent and &lt;code&gt;researcher&lt;/code&gt; is its child.&lt;/p&gt;

&lt;p&gt;Whether the parent hands work to the child is the model's decision, the same as the Strands version. ADK's source says the &lt;code&gt;description&lt;/code&gt; field is what the model reads to decide whether to delegate. So &lt;code&gt;sub_agents&lt;/code&gt; sets up the hierarchy, and the model still picks the path through it.&lt;/p&gt;

&lt;p&gt;ADK also supports collaborative workflows, where a coordinator agent works with a set of sub agents.&lt;/p&gt;

&lt;p&gt;But our learning system has a defined sequence. In ADK 2.x that's a &lt;strong&gt;graph based workflow&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Same warning as the Strands side: drop the &lt;code&gt;sub_agents&lt;/code&gt; wiring first. Leave &lt;code&gt;sub_agents=[researcher]&lt;/code&gt; on &lt;code&gt;curriculum_builder&lt;/code&gt; and the model can hand control to &lt;code&gt;researcher&lt;/code&gt; in the middle of the run, so a node runs twice or the workflow stops early. So, four fresh agents:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;researcher&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;researcher&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gemini-flash-latest&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;instruction&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;curriculum_builder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;curriculum_builder&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gemini-flash-latest&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;instruction&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;teacher&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;teacher&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gemini-flash-latest&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;instruction&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;output_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lesson&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;feedback&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;feedback&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gemini-flash-latest&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;instruction&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A graph is a list of edges, and each node can be an agent, a plain function, a tool, or another workflow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;google.adk&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Workflow&lt;/span&gt;

&lt;span class="n"&gt;root_agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Workflow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;learning_system&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;edges&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;START&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;researcher&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;curriculum_builder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;teacher&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;feedback&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each edge lists nodes that run one after another, and one node's output becomes the next node's input.&lt;/p&gt;

&lt;p&gt;That covers the sequence. Now the feedback loop.&lt;/p&gt;

&lt;p&gt;ADK's graphs loop too. The &lt;a href="https://adk.dev/graphs/routes/" rel="noopener noreferrer"&gt;routes page&lt;/a&gt; calls it a back-edge: a router node returns a route, and one of its routes points at an earlier node.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;google.adk&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Workflow&lt;/span&gt;

&lt;span class="n"&gt;rounds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;review&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node_input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lesson&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;rounds&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;revise the lesson&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;node_input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;rounds&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;revise&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Your lesson: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;lesson&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;Feedback: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;node_input&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;done&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;node_input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;finish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node_input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;node_input&lt;/span&gt;

&lt;span class="n"&gt;root_agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Workflow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;learning_system&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;edges&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;START&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;researcher&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;curriculum_builder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;teacher&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;feedback&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;review&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;review&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;revise&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;teacher&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;done&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;finish&lt;/span&gt;&lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;review&lt;/code&gt; is a plain Python function, and ADK is happy to make it a node. It reads what &lt;code&gt;feedback&lt;/code&gt; said and picks the route. &lt;code&gt;revise&lt;/code&gt; goes back to &lt;code&gt;teacher&lt;/code&gt;, which is the cycle. Pass &lt;code&gt;output&lt;/code&gt; on each route, because the event is what carries the text on. Leave it off and the next node gets &lt;code&gt;None&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;On the revise route, &lt;code&gt;teacher&lt;/code&gt; only gets what &lt;code&gt;review&lt;/code&gt; hands it, because an agent in a workflow only sees what the node before it passes along. ADK calls this &lt;code&gt;single_turn&lt;/code&gt; mode. If &lt;code&gt;review&lt;/code&gt; only sends the feedback, &lt;code&gt;teacher&lt;/code&gt; never sees the lesson it's meant to fix, so it writes a brand new one.&lt;/p&gt;

&lt;p&gt;To check, I had the feedback agent send back any lesson that didn't end with a quiz question. I ran it 3 times, and none of the rewritten lessons were about photosynthesis. One was about budgeting and the other 2 were about Python variables.&lt;/p&gt;

&lt;p&gt;So we save the lesson and send it back with the feedback. &lt;code&gt;output_key="lesson"&lt;/code&gt; on &lt;code&gt;teacher&lt;/code&gt; puts its lesson in session state, and the &lt;code&gt;lesson&lt;/code&gt; parameter on &lt;code&gt;review&lt;/code&gt; reads it back, because ADK fills a function node's parameters from state. The revise route then hands &lt;code&gt;teacher&lt;/code&gt; both.&lt;/p&gt;

&lt;p&gt;Strands doesn't need any of this. When a graph comes back to a node, Strands hands it the original task along with the output of each node that points to it, which here means the plan and the feedback.&lt;/p&gt;

&lt;p&gt;ADK checks one thing about that cycle when you build it: it has to pass through a routed edge. Wire &lt;code&gt;feedback&lt;/code&gt; straight back to &lt;code&gt;teacher&lt;/code&gt; and construction fails with "Unconditional cycle detected". What it doesn't do is stop a routed cycle. The same page says so plainly: "A graph cycle is not bounded automatically." The &lt;code&gt;rounds&lt;/code&gt; counter is the only thing ending that loop, and it's ours.&lt;/p&gt;

&lt;p&gt;ADK also has &lt;code&gt;LoopAgent&lt;/code&gt;, one of its template workflows, which runs its sub agents over and over until one escalates or &lt;code&gt;max_iterations&lt;/code&gt; runs out. Build one on 2.9.0 and it raises a &lt;code&gt;DeprecationWarning&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;LoopAgent is deprecated in favor of Workflow and will be removed in a
future version. Workflow cannot yet be used as an LlmAgent sub-agent.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Python hides that warning unless the code runs as a script, under pytest, or with &lt;code&gt;-W default&lt;/code&gt;, so plenty of people will never see it. The graph is the way forward either way.&lt;/p&gt;

&lt;p&gt;So both frameworks declare the loop inside the graph. The difference is who stops it. Strands bounds it for you with &lt;code&gt;set_max_node_executions()&lt;/code&gt;. ADK leaves the exit to your router.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa8izjejqwhygi21b3xxj.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa8izjejqwhygi21b3xxj.gif" alt="The Feedback Agent sending work back to the Researcher, Curriculum Builder or Teacher. Both frameworks keep the revision paths as edges inside the graph. Strands caps the loop with set_max_node_executions, and in ADK the router has to stop it" width="720" height="474"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If the Feedback Agent finds a problem, we still need to decide which agent gets another pass. On both frameworks, we write that routing into the graph ourselves, so it doesn't depend on one agent choosing to call another.&lt;/p&gt;

&lt;p&gt;We'll come back to this in Part 2, because it decides how the Feedback Agent gets wired.&lt;/p&gt;

&lt;h3&gt;
  
  
  Putting the two side by side
&lt;/h3&gt;

&lt;p&gt;At this point, the two frameworks give us several ways to build the same system.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Strands&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;ADK&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Define a specialist agent&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Agent(...)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Agent(...)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Give one agent another agent to call&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tools=[researcher]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sub_agents=[researcher]&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Define a fixed workflow&lt;/td&gt;
&lt;td&gt;Graph&lt;/td&gt;
&lt;td&gt;Graph based workflow&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pass output between workflow steps&lt;/td&gt;
&lt;td&gt;Graph edges&lt;/td&gt;
&lt;td&gt;Edges, each node's output is the next one's input&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Represent a feedback loop&lt;/td&gt;
&lt;td&gt;Cyclic Graph, capped by &lt;code&gt;set_max_node_executions()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Routed back-edge, stopped by our router&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Let a coordinator choose which specialist to call&lt;/td&gt;
&lt;td&gt;Agents as Tools&lt;/td&gt;
&lt;td&gt;Collaborative workflow&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;With Strands, we can start with agents as tools and move to a Graph when we need the workflow to be explicit. Strands' Graph pattern supports sequential execution, parallel branches, conditional paths, and feedback loops.&lt;/p&gt;

&lt;p&gt;ADK's graph workflows cover the sequence and the loop. The loop just doesn't stop itself.&lt;/p&gt;

&lt;p&gt;For our learning system, we'll use the four agents we just defined, with the Feedback Agent checking each stage's work and sending it back when something's wrong.&lt;/p&gt;

&lt;p&gt;That gives us the structure we'll use when we build the actual system in Part 2.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does the framework provide for evaluation and observability?
&lt;/h2&gt;

&lt;p&gt;We've now got agents, tools, state, hooks and callbacks, and ways to connect multiple agents.&lt;/p&gt;

&lt;p&gt;Okay, but how do I know if it's actually working? There are two things I want us to look at:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Evaluation:&lt;/strong&gt; Did the agent do what I expected?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Observability:&lt;/strong&gt; What actually happened while it was running?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here's how they both do it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;strong&gt;Strands&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;ADK&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Evaluators we import and run in code&lt;/td&gt;
&lt;td&gt;Datasets we write to a file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ToolCalled&lt;/code&gt;, &lt;code&gt;Equals&lt;/code&gt;, &lt;code&gt;Contains&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;tool_trajectory_avg_score&lt;/code&gt;, &lt;code&gt;response_match_score&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;TrajectoryEvaluator&lt;/code&gt; and other LLM judges&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;final_response_match_v2&lt;/code&gt;, &lt;code&gt;hallucinations_v1&lt;/code&gt;, &lt;code&gt;safety_v1&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Metrics on the result object&lt;/td&gt;
&lt;td&gt;OpenTelemetry to Cloud Trace&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bring your own dashboard&lt;/td&gt;
&lt;td&gt;BigQuery Agent Analytics through the Agents CLI&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Evaluating a Strands agent
&lt;/h3&gt;

&lt;p&gt;Strands has something for both.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://strandsagents.com/docs/user-guide/evals-sdk/quickstart/" rel="noopener noreferrer"&gt;&lt;strong&gt;Strands Evals SDK&lt;/strong&gt;&lt;/a&gt; covers several types of evaluation, including checking the final output, tool usage, trajectories, and using an LLM as a judge. It ships separately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;strands-agents-evals
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Watch the name. You install &lt;code&gt;strands-agents-evals&lt;/code&gt; and you import &lt;code&gt;strands_evals&lt;/code&gt;. Guessing the install name from the import gets you &lt;code&gt;strands-evals&lt;/code&gt;, which is a squatted 4KB stub on PyPI by an unrelated author.&lt;/p&gt;

&lt;p&gt;Let's start with something simple. Say we want to make sure our course agent actually uses the &lt;code&gt;get_course_topic&lt;/code&gt; tool when it runs.&lt;/p&gt;

&lt;p&gt;A deterministic evaluator can check that directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;strands_evals.evaluators&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ToolCalled&lt;/span&gt;

&lt;span class="n"&gt;evaluator&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ToolCalled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tool_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;get_course_topic&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The evaluator simply checks the agent's trajectory and determines whether that tool was called. The &lt;a href="https://strandsagents.com/docs/user-guide/evals-sdk/evaluators/deterministic_evaluators/" rel="noopener noreferrer"&gt;deterministic evaluators documentation&lt;/a&gt; also includes evaluators such as &lt;code&gt;Equals&lt;/code&gt;, &lt;code&gt;Contains&lt;/code&gt;, &lt;code&gt;StartsWith&lt;/code&gt;, and &lt;code&gt;StateEquals&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;But some things are harder to check with an exact rule.&lt;/p&gt;

&lt;p&gt;For example, we might want to know whether the &lt;code&gt;Researcher&lt;/code&gt; used the right tools in the right order and avoided unnecessary calls. We could write deterministic checks for specific sequences, but that becomes harder when there are multiple valid ways for an agent to complete a task.&lt;/p&gt;

&lt;p&gt;Strands provides evaluators that can use a model to assess an agent's behaviour against a rubric. For example, the &lt;code&gt;TrajectoryEvaluator&lt;/code&gt; lets us define what a good tool usage trajectory should look like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;strands_evals.evaluators&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;TrajectoryEvaluator&lt;/span&gt;

&lt;span class="n"&gt;evaluator&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TrajectoryEvaluator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;rubric&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
    Evaluate the tool usage trajectory:
      1. Were the right tools chosen for the task?
      2. Were the tools used in a logical order?
      3. Were unnecessary tools avoided?

    Score 1.0 if the tools were used correctly and efficiently.
    Score 0.5 if the right tools were used but the sequence was suboptimal.
    Score 0.0 if the wrong tools were used or there were major inefficiencies.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;include_inputs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The evaluator can then look at the agent's trajectory and score it against the rubric. This is useful when we're testing behaviour that is difficult to reduce to a single expected string.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://strandsagents.com/docs/user-guide/evals-sdk/evaluators/trajectory_evaluator/" rel="noopener noreferrer"&gt;Trajectory Evaluator documentation&lt;/a&gt; goes further into evaluating the sequence of actions and tool calls made during an execution.&lt;/p&gt;

&lt;p&gt;We can also evaluate the actual response. Strands provides LLM based evaluators for things like helpfulness and correctness, where another model acts as the judge and scores the agent's response against the evaluation criteria.&lt;/p&gt;

&lt;p&gt;That gives us two useful approaches:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Deterministic evaluator
    ↓
"Did the agent call get_course_topic?"
    ↓
Exact check


LLM based evaluator
    ↓
"Did the agent use the right tools in a sensible way?"
    ↓
Judgement against a rubric
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For our learning system, we could use deterministic checks for things we know should always happen, such as making sure the &lt;code&gt;Researcher&lt;/code&gt; calls the documentation search tool. We could use an LLM based evaluator for things that require judgement, such as whether the research was actually useful.&lt;/p&gt;

&lt;h3&gt;
  
  
  Evaluating an ADK agent
&lt;/h3&gt;

&lt;p&gt;ADK comes at this from the dataset end. We describe the cases we want to test in a file, pick the metrics, and let ADK run the agent against them.&lt;/p&gt;

&lt;p&gt;There are two file formats. A &lt;code&gt;.test.json&lt;/code&gt; file holds a single agent interaction. An &lt;code&gt;.evalset.json&lt;/code&gt; file holds multiple, longer sessions.&lt;/p&gt;

&lt;p&gt;Evaluation isn't in the base install. Both routes below need the &lt;code&gt;eval&lt;/code&gt; extra, and the CLI tells you so if you forget:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="s2"&gt;"google-adk[eval]"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then we can run a dataset from the command line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;adk &lt;span class="nb"&gt;eval &lt;/span&gt;course_agent tests/researcher.evalset.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or from pytest, which keeps evaluation in the test suite next to everything else. The test is async, so it also needs &lt;code&gt;pip install pytest pytest-asyncio&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pytest&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;google.adk.evaluation.agent_evaluator&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AgentEvaluator&lt;/span&gt;


&lt;span class="nd"&gt;@pytest.mark.asyncio&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_researcher&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;AgentEvaluator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;agent_module&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;course_agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;eval_dataset_file_path_or_dir&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tests/researcher.evalset.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;ADK ships its own metrics. &lt;code&gt;tool_trajectory_avg_score&lt;/code&gt; does an exact match on the tool call trajectory. &lt;code&gt;response_match_score&lt;/code&gt; scores ROUGE-1 similarity against a reference response. For the judgement calls there's &lt;code&gt;final_response_match_v2&lt;/code&gt;, &lt;code&gt;rubric_based_final_response_quality_v1&lt;/code&gt;, &lt;code&gt;rubric_based_tool_use_quality_v1&lt;/code&gt;, &lt;code&gt;hallucinations_v1&lt;/code&gt;, and &lt;code&gt;safety_v1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So we end up with the same split Strands gave us. Exact checks for things with a right answer, LLM judged metrics for things that need an opinion.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://adk.dev/evaluate/" rel="noopener noreferrer"&gt;ADK evaluation documentation&lt;/a&gt; covers the datasets, the metrics, and the three ways to run them: the CLI, the &lt;code&gt;adk web&lt;/code&gt; UI, and pytest.&lt;/p&gt;

&lt;h4&gt;
  
  
  A detour, because this is about to get confusing
&lt;/h4&gt;

&lt;p&gt;Some of what I show you from here on isn't ADK.&lt;/p&gt;

&lt;p&gt;Google also ships the &lt;strong&gt;Agents CLI&lt;/strong&gt;, which its own docs describe as a way to "Build, evaluate, and deploy ADK agents with a single unified CLI." It's a separate tool that sits on top of ADK. It scaffolds a project, wires up telemetry, runs evaluations, and handles deployment.&lt;/p&gt;

&lt;p&gt;I'm flagging it because it changes what we're comparing. The Strands Evals SDK is a library we import into our code. The Agents CLI is a project toolchain we run from a terminal. They overlap, and the metric names are different on each side.&lt;/p&gt;

&lt;p&gt;An Agents CLI project comes with a default evaluation dataset at &lt;code&gt;tests/eval/datasets/basic-dataset.json&lt;/code&gt;. We add our own cases and run them with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;agents-cli &lt;span class="nb"&gt;eval &lt;/span&gt;run &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--dataset&lt;/span&gt; tests/eval/datasets/custom-dataset.json &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--metrics&lt;/span&gt; final_response_quality,grounding
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The command runs the agent against the dataset, collects the resulting traces and grades them against the metrics we selected. The &lt;a href="https://google.github.io/agents-cli/guide/evaluation/#the-eval-fix-loop" rel="noopener noreferrer"&gt;eval fix loop&lt;/a&gt; in the Agents CLI documentation recommends running the evaluation, inspecting what failed, making a change, and running it again.&lt;/p&gt;

&lt;p&gt;For our learning system, a test case could represent something we expect one of our agents to accomplish.&lt;/p&gt;

&lt;p&gt;For example, we could give the &lt;code&gt;Researcher&lt;/code&gt; a prompt like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"prompt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Research AWS S3 Lifecycle Management for a certification lesson."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We could then evaluate whether the &lt;code&gt;Researcher&lt;/code&gt; produced useful, grounded research and used the tools we expect.&lt;/p&gt;

&lt;p&gt;We could do something similar with the &lt;code&gt;Teacher&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"prompt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Create a lesson on S3 Lifecycle Management from the provided curriculum."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here, we aren't looking for one exact lesson. There are many ways the Teacher could explain the topic correctly.&lt;/p&gt;

&lt;p&gt;Instead, we care about whether the lesson meets the requirements we gave it. Does it accurately explain S3 Lifecycle Management? Does it stay grounded in the research? Does it cover the concepts required by the curriculum? Does the hands on exercise actually give the learner a chance to apply what they just learned?&lt;/p&gt;

&lt;p&gt;Those are judgement calls, so a model has to make them.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://google.github.io/agents-cli/guide/evaluation/#common-metrics-at-a-glance" rel="noopener noreferrer"&gt;Agents CLI metrics&lt;/a&gt; include &lt;code&gt;general_quality&lt;/code&gt;, &lt;code&gt;instruction_following&lt;/code&gt;, &lt;code&gt;tool_use_quality&lt;/code&gt;, &lt;code&gt;final_response_quality&lt;/code&gt;, &lt;code&gt;grounding&lt;/code&gt;, and &lt;code&gt;hallucination&lt;/code&gt;, and &lt;code&gt;agents-cli eval metric list&lt;/code&gt; prints the rest. The evaluation system also supports custom metrics, including rubric based evaluation using a judge model.&lt;/p&gt;

&lt;p&gt;For example, we could define a rubric for the &lt;code&gt;Teacher&lt;/code&gt; that asks the evaluator to judge whether the lesson accurately covers the concepts required by the curriculum, stays grounded in the research, and provides a meaningful hands on exercise.&lt;/p&gt;

&lt;p&gt;We can also evaluate the agent's execution trace rather than looking only at its final response. &lt;a href="https://adk.dev/evaluate/#evaluate-trajectory-and-tool-use" rel="noopener noreferrer"&gt;ADK's trajectory evaluation documentation&lt;/a&gt; covers evaluating the sequence of tools an agent used. The Agents CLI's &lt;a href="https://google.github.io/agents-cli/guide/evaluation/#common-metrics-at-a-glance" rel="noopener noreferrer"&gt;&lt;code&gt;tool_use_quality&lt;/code&gt; metric&lt;/a&gt; evaluates tool selection, parameter accuracy, and step sequence correctness.&lt;/p&gt;

&lt;p&gt;That gives us a few different ways to test our system.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Agent&lt;/th&gt;
&lt;th&gt;What we could evaluate&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Researcher&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Tool usage, source quality and research relevance&lt;/td&gt;
&lt;td&gt;Did it search the right documentation and gather useful information for the topic?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Curriculum Builder&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Instruction following, coverage and curriculum quality&lt;/td&gt;
&lt;td&gt;Did it cover the required concepts and turn them into a useful learn by doing progression?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Teacher&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Accuracy, grounding and instructional quality&lt;/td&gt;
&lt;td&gt;Does the lesson accurately teach the required concepts and give the learner a meaningful exercise?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Feedback Agent&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Feedback quality and correctness&lt;/td&gt;
&lt;td&gt;Did it identify real issues without inventing problems?&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The evaluation method depends on what we're trying to measure. A deterministic check works well for something concrete, such as whether the Researcher called a required tool. For a subjective check, like whether a &lt;code&gt;Teacher&lt;/code&gt;'s lesson is accurate and useful, an LLM based evaluator can judge the result against a set of criteria.&lt;/p&gt;

&lt;h3&gt;
  
  
  Evaluation vs Feedback
&lt;/h3&gt;

&lt;p&gt;There is one distinction worth calling out: &lt;strong&gt;the Feedback Agent is not the evaluation system.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The Feedback Agent is part of our application that reviews the work produced by the other agents and identifies what should be revised.&lt;/p&gt;

&lt;p&gt;Evaluation is part of our development workflow. It tells us whether the agents themselves are behaving the way we designed them to.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Learning system

Researcher
    ↓
Curriculum Builder
    ↓
Teacher
    ↓
Feedback Agent
    ↓
Revision
    └────────→ relevant agent


Development workflow

Agent
    ↓
Evaluation
    ↓
Identify failure
    ↓
Improve agent
    ↓
Evaluate again
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The learning system's loop improves the &lt;strong&gt;content&lt;/strong&gt;, and the development loop improves the &lt;strong&gt;agents&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Observing a Strands agent
&lt;/h3&gt;

&lt;p&gt;Strands builds observability in, with metrics, logs and traces. The &lt;a href="https://strandsagents.com/docs/user-guide/sdk/observability-evaluation/observability/" rel="noopener noreferrer"&gt;Strands observability documentation&lt;/a&gt; explains how these pieces fit together.&lt;/p&gt;

&lt;p&gt;We can access execution metrics directly from the result returned by an agent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;strands&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;
&lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Create a lesson about photosynthesis.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Total tokens: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;accumulated_usage&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;totalTokens&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Execution time: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cycle_durations&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; seconds&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Tools used: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tool_metrics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So if our &lt;code&gt;Teacher&lt;/code&gt; suddenly becomes much slower, we can look at the execution time and see which tools were used during that invocation. The &lt;a href="https://strandsagents.com/docs/user-guide/sdk/observability-evaluation/metrics/" rel="noopener noreferrer"&gt;Strands metrics documentation&lt;/a&gt; also exposes information about token usage, tool calls, execution time, errors, and agent loop cycles.&lt;/p&gt;

&lt;p&gt;Strands also collects local execution traces. We can inspect them through the result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Create a lesson about photosynthesis.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_summary&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The summary gives us a view of the execution hierarchy, including the agent's cycles, model calls, and tool calls.&lt;/p&gt;

&lt;p&gt;For deeper tracing, Strands uses &lt;a href="https://strandsagents.com/docs/user-guide/sdk/observability-evaluation/traces/" rel="noopener noreferrer"&gt;OpenTelemetry&lt;/a&gt;. A trace can show the model invocations and tool executions that happened during an agent run, including information such as model inputs and outputs, token usage, tool parameters, and execution time. The &lt;a href="https://strandsagents.com/docs/user-guide/sdk/observability-evaluation/traces/" rel="noopener noreferrer"&gt;Strands traces documentation&lt;/a&gt; covers how those traces are structured.&lt;/p&gt;

&lt;h3&gt;
  
  
  Observing an ADK agent
&lt;/h3&gt;

&lt;p&gt;ADK also uses OpenTelemetry, with Google Cloud's observability tools sitting around it.&lt;/p&gt;

&lt;p&gt;Suppose our &lt;code&gt;Teacher&lt;/code&gt; fails an evaluation because the lesson isn't grounded in the research we provided. How do we understand what happened while that lesson was being created? Which tools did our agent use? Did a tool call fail?&lt;/p&gt;

&lt;p&gt;To answer those questions, we need to see the execution itself.&lt;/p&gt;

&lt;h4&gt;
  
  
  Following an agent's execution
&lt;/h4&gt;

&lt;p&gt;The Agents CLI uses OpenTelemetry to collect information about an ADK agent's execution. That information can be sent to &lt;a href="https://google.github.io/agents-cli/guide/observability/cloud-trace/" rel="noopener noreferrer"&gt;Cloud Trace&lt;/a&gt;, a Google Cloud service for recording and inspecting the operations that happen during a request.&lt;/p&gt;

&lt;p&gt;A trace represents one execution. Within that trace, individual operations are recorded as spans. For an agent, those spans can include things such as model calls and tool executions.&lt;/p&gt;

&lt;p&gt;You can think of it as moving from:&lt;/p&gt;

&lt;p&gt;"What did the agent return?"&lt;/p&gt;

&lt;p&gt;to&lt;/p&gt;

&lt;p&gt;"What happened while the agent was producing that result?"&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://google.github.io/agents-cli/guide/observability/cloud-trace/" rel="noopener noreferrer"&gt;Cloud Trace documentation&lt;/a&gt; walks through how the Agents CLI connects agent executions to Cloud Trace.&lt;/p&gt;

&lt;p&gt;Let's try it.&lt;/p&gt;

&lt;p&gt;We've been running our agents locally, so we'll start there. The Agents CLI works inside a project it creates for us, following its &lt;a href="https://google.github.io/agents-cli/guide/getting-started/" rel="noopener noreferrer"&gt;getting started guide&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;agents-cli create my-agent
&lt;span class="nb"&gt;cd &lt;/span&gt;my-agent
agents-cli &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From inside it, we can run the agent and send the trace to Google Cloud at the same time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;agents-cli run &lt;span class="s2"&gt;"Create a lesson about S3 Lifecycle Management"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--otel-to-cloud&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In case the name didn't give it away, the &lt;code&gt;--otel-to-cloud&lt;/code&gt; flag sends the trace from our local run to Google Cloud.&lt;/p&gt;

&lt;p&gt;Once the run finishes, we can open &lt;code&gt;GCP Console&lt;/code&gt; → &lt;code&gt;Trace&lt;/code&gt; → &lt;code&gt;Trace Explorer&lt;/code&gt; and find the trace for that request.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://google.github.io/agents-cli/guide/observability/cloud-trace/#2-view-traces" rel="noopener noreferrer"&gt;View Traces section&lt;/a&gt; of the Agents CLI documentation shows where these traces appear in the Google Cloud Console.&lt;/p&gt;

&lt;p&gt;There, we can inspect the operations that made up the run.&lt;/p&gt;

&lt;p&gt;Instead of only seeing the lesson that came back from the Teacher, we can see the execution that produced it.&lt;/p&gt;

&lt;h4&gt;
  
  
  Local execution and deployed agents
&lt;/h4&gt;

&lt;p&gt;Currently we are sending the agent's trace to GCP so we can inspect it there, but the agent is still running locally. When we eventually deploy our agent, the execution itself happens in Google Cloud. Cloud Trace is enabled by default for deployed agents.&lt;/p&gt;

&lt;p&gt;Hopefully this diagram helps you make sense of where we're at:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Local agent
  │
  │ run agent
  ↓ Our machine
  │
  │ send trace
  ↓
  Google Cloud
  │
  ↓ Trace Explorer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Our development workflow would look something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Run locally
↓
Inspect the trace
↓
Find something interesting
↓
Change the agent
↓ Run it again
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When an execution fails, we can go from a failed evaluation case to the execution that produced it and inspect what the agent actually did. We're still missing something. A trace in itself can show us the operations that took place during the run, but how do we see the input and output associated with those calls?&lt;/p&gt;

&lt;h4&gt;
  
  
  Looking at prompts and responses
&lt;/h4&gt;

&lt;p&gt;Prompt response logging is separate from Cloud Trace. The Agents CLI can record prompt and response information in Google Cloud Storage as &lt;code&gt;JSONL&lt;/code&gt; and in a &lt;code&gt;BigQuery&lt;/code&gt; completions table.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://google.github.io/agents-cli/guide/observability/cloud-trace/#3-verify-prompt-response-logging-optional" rel="noopener noreferrer"&gt;prompt response logging documentation&lt;/a&gt; shows how this works.&lt;/p&gt;

&lt;p&gt;Now we have the execution from Cloud Trace, and the prompts and responses that went with it. That's enough to investigate one run in detail.&lt;/p&gt;

&lt;p&gt;But our agents are going to run many times. The &lt;code&gt;Researcher&lt;/code&gt; might run many times while gathering material, the &lt;code&gt;Teacher&lt;/code&gt; could generate hundreds of lessons, and our evaluations could run across all of them.&lt;/p&gt;

&lt;p&gt;We &lt;em&gt;&lt;strong&gt;could&lt;/strong&gt;&lt;/em&gt; inspect each trace individually, but eventually we'd want to ask questions across all of those executions.&lt;/p&gt;

&lt;p&gt;Which tools are being called most often? How many errors are we seeing? Are there patterns across our runs?&lt;/p&gt;

&lt;p&gt;Those are SQL questions.&lt;/p&gt;

&lt;h4&gt;
  
  
  Looking across many executions
&lt;/h4&gt;

&lt;p&gt;&lt;a href="https://cloud.google.com/bigquery" rel="noopener noreferrer"&gt;BigQuery&lt;/a&gt; is Google's data warehouse. It gives us a place to store structured data and query it using SQL.&lt;/p&gt;

&lt;p&gt;The Agents CLI provides &lt;strong&gt;BigQuery Agent Analytics&lt;/strong&gt; for ADK based agents. It's an opt in plugin that records detailed agent events in an &lt;code&gt;agent_events&lt;/code&gt; table, giving us a way to analyze our agent runs across many executions.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://google.github.io/agents-cli/guide/observability/bq-agent-analytics/" rel="noopener noreferrer"&gt;BigQuery Agent Analytics documentation&lt;/a&gt; explains how to enable it and configure the analytics plugin. When creating an ADK based project, we can include the plugin with the &lt;code&gt;--bq-analytics&lt;/code&gt; flag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;agents-cli create my-agent &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; cloud_run &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--bq-analytics&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That flag adds the BigQuery Agent Analytics plugin to the generated project. The configuration lives in &lt;code&gt;app/agent.py&lt;/code&gt;, where the plugin is added to the ADK application.&lt;/p&gt;

&lt;p&gt;The plugin needs a dependency the base install does not carry, so the import fails with&lt;br&gt;
&lt;code&gt;No module named 'google.api_core'&lt;/code&gt; until we add the extra:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="s2"&gt;"google-adk[bigquery-analytics]"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The generated configuration looks roughly like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;google.adk.apps&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;App&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;google.adk.plugins.bigquery_agent_analytics_plugin&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;BigQueryAgentAnalyticsPlugin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;BigQueryLoggerConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;bq_config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BigQueryLoggerConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;enabled&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;table_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;agent_events&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;bq_analytics_plugin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BigQueryAgentAnalyticsPlugin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;project_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GOOGLE_CLOUD_PROJECT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;dataset_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BQ_ANALYTICS_DATASET_ID&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;table_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;bq_config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;table_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;bq_config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;location&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GOOGLE_CLOUD_LOCATION&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;US&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;App&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;my-agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;root_agent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;root_agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;plugins&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;bq_analytics_plugin&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part here is the &lt;code&gt;BigQueryAgentAnalyticsPlugin&lt;/code&gt;. We configure it with the Google Cloud project and dataset where the events should be stored, then add the plugin to our ADK application.&lt;/p&gt;

&lt;p&gt;There are more configuration options available, including filtering which event types are recorded and controlling how larger pieces of content are handled. We don't need those for our example, so we'll keep the configuration simple.&lt;/p&gt;

&lt;p&gt;Once events are being recorded, we can query them with SQL.&lt;/p&gt;

&lt;p&gt;For example, we could look at tool calls and errors across our runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="nb"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;event_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;JSON_VALUE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'$.tool'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;tool_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;JSON_QUERY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'$.args'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;tool_args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;error_message&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="nv"&gt;`YOUR_PROJECT_ID.YOUR_AGENT_NAME_telemetry.agent_events`&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;event_type&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'TOOL_STARTING'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'TOOL_ERROR'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="nb"&gt;timestamp&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The docs' version of this query filters on &lt;code&gt;TOOL_COMPLETED&lt;/code&gt; and uses &lt;code&gt;JSON_VALUE&lt;/code&gt;, and its &lt;code&gt;tool_args&lt;/code&gt; column comes back empty every time. The plugin only writes the arguments on &lt;code&gt;TOOL_STARTING&lt;/code&gt; and &lt;code&gt;TOOL_ERROR&lt;/code&gt; rows, and they're a JSON object, which &lt;code&gt;JSON_VALUE&lt;/code&gt; returns as &lt;code&gt;NULL&lt;/code&gt;. &lt;code&gt;JSON_QUERY&lt;/code&gt; keeps them.&lt;/p&gt;

&lt;p&gt;Now we're asking questions across our executions instead of opening traces one at a time.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://google.github.io/agents-cli/guide/observability/bq-agent-analytics/#example-queries" rel="noopener noreferrer"&gt;example queries&lt;/a&gt; in the documentation show other ways to analyze the recorded events, including token usage.&lt;/p&gt;

&lt;p&gt;For our project, this gives us another way to investigate a failed evaluation. We can take the failed case, find the execution behind it, inspect what happened, and then look across our other runs to see whether the same problem is happening elsewhere.&lt;/p&gt;

&lt;h3&gt;
  
  
  Putting the two side by side
&lt;/h3&gt;

&lt;p&gt;At this point, we've looked at evaluation and observability separately. Here's how the pieces line up:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Strands&lt;/th&gt;
&lt;th&gt;ADK&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Evaluation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Evals SDK with deterministic and LLM based evaluators&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.test.json&lt;/code&gt; and &lt;code&gt;.evalset.json&lt;/code&gt; datasets, run with &lt;code&gt;adk eval&lt;/code&gt; or pytest&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Evaluating tool use&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ToolCalled&lt;/code&gt;, trajectory evaluators&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;tool_trajectory_avg_score&lt;/code&gt;, plus &lt;code&gt;tool_use_quality&lt;/code&gt; in the Agents CLI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Observability&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Metrics, logs, local traces and OpenTelemetry&lt;/td&gt;
&lt;td&gt;OpenTelemetry, with Cloud Trace and BigQuery wired up by the Agents CLI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Investigating one execution&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Execution metrics and traces&lt;/td&gt;
&lt;td&gt;Cloud Trace and prompt response logging (Agents CLI)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Investigating many executions&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Metrics and external observability integrations&lt;/td&gt;
&lt;td&gt;BigQuery Agent Analytics (Agents CLI)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  And finally, how can I deploy what I've built?
&lt;/h2&gt;

&lt;p&gt;We've been running everything locally so far.&lt;/p&gt;

&lt;p&gt;How do we deploy?&lt;/p&gt;

&lt;p&gt;Strands hands us a Python application and leaves the rest to us. ADK has a command for it, two in fact. &lt;code&gt;adk deploy&lt;/code&gt; ships in &lt;code&gt;google-adk&lt;/code&gt; itself with four targets: &lt;code&gt;agent_engine&lt;/code&gt;, &lt;code&gt;cloud_run&lt;/code&gt;, &lt;code&gt;docker&lt;/code&gt; and &lt;code&gt;gke&lt;/code&gt;. Google's Agents CLI is a separate tool that wraps a whole project around that, and it's the one I'll use below.&lt;/p&gt;

&lt;p&gt;Here's how they both do it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;strong&gt;Strands&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;ADK&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;We package the application&lt;/td&gt;
&lt;td&gt;&lt;code&gt;agents-cli deploy&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;We write the Dockerfile&lt;/td&gt;
&lt;td&gt;The CLI builds the container&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;We provision the infrastructure&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;-d cloud_run&lt;/code&gt;, &lt;code&gt;agent_runtime&lt;/code&gt; or &lt;code&gt;gke&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lambda, Fargate, App Runner, EKS, EC2, AgentCore&lt;/td&gt;
&lt;td&gt;Agent Runtime, Cloud Run, GKE&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Strands: bring your own runtime
&lt;/h3&gt;

&lt;p&gt;With Strands, our agent is part of a Python application. Once we're ready to deploy it, we need to package that application and decide where it should run.&lt;/p&gt;

&lt;p&gt;One option is Docker. &lt;a href="https://strandsagents.com/docs/user-guide/sdk/deploy/deploy_to_docker/" rel="noopener noreferrer"&gt;The Strands Docker deployment guide&lt;/a&gt; walks through containerizing a Strands agent so we can run the same application locally or on a cloud service.&lt;/p&gt;

&lt;p&gt;For example, we could have a Dockerfile like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; public.ecr.aws/docker/library/python:3.13-slim&lt;/span&gt;

&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; requirements.txt .&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--no-cache-dir&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;

&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8080"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;requirements.txt&lt;/code&gt; needs &lt;code&gt;strands-agents&lt;/code&gt;, &lt;code&gt;fastapi&lt;/code&gt; and &lt;code&gt;uvicorn&lt;/code&gt;. The &lt;code&gt;CMD&lt;/code&gt; starts a web server, because the app below is a FastAPI app with nothing to do if you just run it with &lt;code&gt;python&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The Dockerfile packages our Python application and its dependencies into an image. We can then run that image wherever we choose.&lt;/p&gt;

&lt;p&gt;AWS gives us &lt;a href="https://strandsagents.com/docs/user-guide/sdk/deploy/operating-agents-in-production/" rel="noopener noreferrer"&gt;several options&lt;/a&gt; such as Lambda, Fargate, App Runner, EKS, EC2, and Bedrock AgentCore.&lt;/p&gt;

&lt;p&gt;If we choose the &lt;a href="https://strandsagents.com/docs/user-guide/sdk/deploy/deploy_to_aws_fargate/" rel="noopener noreferrer"&gt;Fargate deployment&lt;/a&gt; for example, we would create a containerized FastAPI application. We'd use AWS CDK to create the Fargate infrastructure, build the Docker image, and deploy the service.&lt;/p&gt;

&lt;p&gt;The application might look something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FastAPI&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseModel&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;strands&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Question&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;


&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/ask&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;ask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Question&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;system_prompt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;You are a helpful certification teacher.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;response&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;FastAPI gives our application an HTTP endpoint. The Strands agent is still the same agent we've been working with.&lt;/p&gt;

&lt;p&gt;Two things in there are on purpose. The agent is built inside the handler, one per request. An &lt;code&gt;Agent&lt;/code&gt; holds its own conversation, so a single shared one would mix every caller's messages together, and Strands refuses a second call while the first is still running. And &lt;code&gt;str(result)&lt;/code&gt; is the text of the answer. &lt;code&gt;result.message&lt;/code&gt; is the whole message, role and content blocks included.&lt;/p&gt;

&lt;p&gt;From there, the deployment process depends on the AWS service we choose.&lt;/p&gt;

&lt;h3&gt;
  
  
  ADK: deploy with the Agents CLI
&lt;/h3&gt;

&lt;p&gt;With ADK, we have the Agents CLI from the evaluation section.&lt;/p&gt;

&lt;p&gt;The CLI can create a project with a deployment target already configured. For example, if we want to deploy our agent to Cloud Run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;agents-cli create my-agent &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; cloud_run
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;-d cloud_run&lt;/code&gt; option sets Cloud Run as the deployment target. The &lt;a href="https://google.github.io/agents-cli/guide/deployment/" rel="noopener noreferrer"&gt;Agents CLI deployment documentation&lt;/a&gt; covers the available deployment targets, which currently include Agent Runtime, Cloud Run, and GKE.&lt;/p&gt;

&lt;p&gt;Before deploying, we tell Google Cloud which project to use with Google's command line tool, &lt;code&gt;gcloud&lt;/code&gt;. It comes with the &lt;a href="https://docs.cloud.google.com/sdk/docs/install-sdk" rel="noopener noreferrer"&gt;Google Cloud SDK&lt;/a&gt;, which you install separately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud config &lt;span class="nb"&gt;set &lt;/span&gt;project YOUR_DEV_PROJECT_ID
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then we deploy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;agents-cli deploy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;agents-cli deploy&lt;/code&gt; knows where to deploy because we chose Cloud Run with &lt;code&gt;-d cloud_run&lt;/code&gt; when we created the project.&lt;/p&gt;

&lt;p&gt;For Cloud Run, the CLI builds a container from our source and deploys it as a Cloud Run service. We don't have to write a Dockerfile, the way we did for Strands.&lt;/p&gt;

&lt;p&gt;We can list what's deployed with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;agents-cli deploy &lt;span class="nt"&gt;--list&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://google.github.io/agents-cli/cli/" rel="noopener noreferrer"&gt;Agents CLI command reference&lt;/a&gt; also covers &lt;code&gt;--no-wait&lt;/code&gt;, which starts a deployment and returns straight away, and &lt;code&gt;--status&lt;/code&gt; for checking on one started that way.&lt;/p&gt;

&lt;p&gt;An agent also needs cloud resources around it, like service accounts, permissions and storage for telemetry. For a basic deployment, &lt;code&gt;agents-cli deploy&lt;/code&gt; on its own is enough. When we want to set those resources up ourselves, the Agents CLI has a separate command, &lt;code&gt;agents-cli infra&lt;/code&gt;, and the &lt;a href="https://google.github.io/agents-cli/guide/deployment/" rel="noopener noreferrer"&gt;deployment documentation&lt;/a&gt; explains how the two fit together.&lt;/p&gt;

&lt;p&gt;So with ADK, the CLI takes care of more of the steps between our local project and the deployed application.&lt;/p&gt;

&lt;h3&gt;
  
  
  Putting the two side by side
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Strands&lt;/th&gt;
&lt;th&gt;ADK&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;What are we deploying?&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Our Python application containing the Strands agent&lt;/td&gt;
&lt;td&gt;Our ADK project&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;How do we deploy?&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Choose a deployment option and configure it for our application&lt;/td&gt;
&lt;td&gt;&lt;code&gt;agents-cli deploy&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Containerization&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;We package the application when the chosen deployment uses containers&lt;/td&gt;
&lt;td&gt;The CLI handles the container build for supported targets such as Cloud Run&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Infrastructure&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;We configure the AWS resources needed by our chosen deployment&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;agents-cli infra&lt;/code&gt; can provision infrastructure when we need more control&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Deployment targets&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Lambda, Fargate, App Runner, EKS, EC2, AgentCore, and others&lt;/td&gt;
&lt;td&gt;Agent Runtime, Cloud Run, GKE&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;We started with:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How do I define an agent?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And ended up with:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How do I evaluate it, see what it is doing, and actually run it somewhere?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Here's what we have learned so far:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;strong&gt;Question&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Strands&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;ADK&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Define an agent&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Agent(...)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Agent(...)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Connect tools&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tools=[...]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tools=[...]&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Run the agent&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Agent&lt;/code&gt; runs the loop itself&lt;/td&gt;
&lt;td&gt;A separate &lt;code&gt;Runner&lt;/code&gt; runs the loop&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Store state&lt;/td&gt;
&lt;td&gt;Agent state, messages, sessions&lt;/td&gt;
&lt;td&gt;Sessions, events, state scopes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Add execution control&lt;/td&gt;
&lt;td&gt;Hooks, &lt;code&gt;limits=&lt;/code&gt; on the invocation&lt;/td&gt;
&lt;td&gt;Callbacks, &lt;code&gt;RunConfig&lt;/code&gt; on the run&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Connect agents&lt;/td&gt;
&lt;td&gt;Agents as tools, Graphs&lt;/td&gt;
&lt;td&gt;Sub agents, collaborative workflows, Graphs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Define workflows&lt;/td&gt;
&lt;td&gt;Graphs&lt;/td&gt;
&lt;td&gt;Graph based workflows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Evaluate behaviour&lt;/td&gt;
&lt;td&gt;Strands Evals SDK&lt;/td&gt;
&lt;td&gt;ADK evaluation tooling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Observe execution&lt;/td&gt;
&lt;td&gt;OpenTelemetry, metrics, logs&lt;/td&gt;
&lt;td&gt;OpenTelemetry, Cloud Trace, Google Cloud tooling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deploy&lt;/td&gt;
&lt;td&gt;Your own infrastructure or AgentCore&lt;/td&gt;
&lt;td&gt;Google Cloud deployment targets&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For our learning system, we now have enough pieces to make some decisions.&lt;/p&gt;

&lt;p&gt;We know what our agents look like.&lt;/p&gt;

&lt;p&gt;We know how they'll communicate.&lt;/p&gt;

&lt;p&gt;We know where their state can live.&lt;/p&gt;

&lt;p&gt;We know how to control execution.&lt;/p&gt;

&lt;p&gt;We know how to connect them into a workflow with a feedback loop.&lt;/p&gt;

&lt;p&gt;And we know how we'll eventually test, observe, and deploy the thing.&lt;/p&gt;

&lt;p&gt;Now we can actually build it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;That's &lt;a href="https://dev.to/chigo_e/can-we-actually-build-the-thing-now-23f9"&gt;Part 2&lt;/a&gt;.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>aws</category>
      <category>python</category>
      <category>googlecloud</category>
    </item>
    <item>
      <title>RAG Powered Apps with Amazon Bedrock, Part 2: Automating the RAG Pipeline with Terraform</title>
      <dc:creator>Chigozirim Eke</dc:creator>
      <pubDate>Fri, 07 Aug 2026 00:47:33 +0000</pubDate>
      <link>https://dev.to/chigo_e/rag-powered-apps-with-amazon-bedrock-part-2-automating-the-rag-pipeline-with-terraform-4po4</link>
      <guid>https://dev.to/chigo_e/rag-powered-apps-with-amazon-bedrock-part-2-automating-the-rag-pipeline-with-terraform-4po4</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Before you start:&lt;/strong&gt; This picks up where Part 1 left off. In Part 1, we walked through setting up a Bedrock Knowledge Base manually through the AWS Console.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Introduction &amp;amp; Motivation
&lt;/h2&gt;

&lt;p&gt;I started this project with a simple goal: build a reusable Terraform setup that could deploy the infrastructure behind a "Chat with PDF" application end to end.&lt;/p&gt;

&lt;p&gt;When Amazon Bedrock launched, I immediately started experimenting. Like many early GenAI workflows, my first few prototypes were built directly through the AWS Console. The console experience is great for quickly understanding how the pieces fit together, but once I started iterating, I wanted something more repeatable.&lt;/p&gt;

&lt;p&gt;I wanted to be able to spin environments up, tear them down, and quickly test different architecture decisions. For example, I wanted to compare the cost differences between using S3 Vectors and OpenSearch as the vector store without manually rebuilding the infrastructure every time.&lt;/p&gt;

&lt;p&gt;At the time, I couldn't find a Terraform module that covered the complete RAG pipeline I was looking for: S3, Bedrock Knowledge Bases, OpenSearch Serverless, Lambda, IAM, and all the glue in between.&lt;/p&gt;

&lt;p&gt;So I built my own. Partly because I needed it, and partly because understanding how these services connect together is the best way to learn.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Are We Building?
&lt;/h2&gt;

&lt;p&gt;A couple of terraform modules to automate everything we clicked through manually in Part 1. One &lt;code&gt;terraform apply&lt;/code&gt; brings up the full stack:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;S3 Bucket&lt;/strong&gt;: your document store. Encrypted at rest, versioning on, zero public access.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenSearch Serverless&lt;/strong&gt;: the vector database. Stores the embeddings Bedrock generates during ingestion.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bedrock Knowledge Base&lt;/strong&gt;: orchestrates the chunking, embedding, and storage of documents, and retrieval at query time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ingestion Lambda&lt;/strong&gt;: triggered automatically when you upload a file to S3. Starts a Bedrock ingestion job so documents are chunked, embedded, and indexed without ClickOps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Query Lambda&lt;/strong&gt;: accepts a natural language question, calls &lt;code&gt;RetrieveAndGenerate&lt;/code&gt;, and returns an answer with source citations.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Full source code + ReadMe: &lt;a href="https://github.com/coolchigi/rag-bedrock-project" rel="noopener noreferrer"&gt;Bedrock Project&lt;/a&gt;. If you run into issues or want to extend the module, feel free to open an issue.&lt;/p&gt;

&lt;h3&gt;
  
  
  Architecture
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flreeyqbqfifppubabz83.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flreeyqbqfifppubabz83.png" alt="Bedrock RAG Architecture Diagram" width="800" height="461"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Project Structure
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;rag-bedrock-project/
├── main.tf
├── variables.tf
├── outputs.tf
├── backend.tf
├── terraform.tfvars.example
├── bootstrap/
└── modules/
    ├── storage/
    ├── opensearch/
    ├── bedrock/
    └── lambda/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each module owns one piece of the infrastructure and exposes only what other modules need through outputs. The root &lt;code&gt;main.tf&lt;/code&gt; connects everything by passing outputs from one module as inputs to another.&lt;/p&gt;

&lt;p&gt;Multiple modules might feel like overkill, but I learned early in my development journey that clear boundaries make systems easier to reason about. Each module owns one responsibility, which makes debugging much easier when something breaks.&lt;/p&gt;

&lt;p&gt;The Lambda module does not need to know how OpenSearch is configured. It only receives the IDs and values it needs through variables.&lt;/p&gt;




&lt;h2&gt;
  
  
  Implementation
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1: Bootstrap Remote State First
&lt;/h3&gt;

&lt;p&gt;Before running &lt;code&gt;terraform apply&lt;/code&gt; on anything, you need somewhere to store your Terraform state.&lt;/p&gt;

&lt;p&gt;If you're new to Terraform, state is how Terraform keeps track of the infrastructure it manages. Every resource it creates is recorded in a &lt;code&gt;terraform.tfstate&lt;/code&gt;file so future runs know what already exists.&lt;/p&gt;

&lt;p&gt;Keeping state locally ties it to your machine. This project uses S3 remote state so Terraform has a durable, shared source of truth. With Terraform 1.10, native S3 state locking removes the need for a separate DynamoDB table.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;bootstrap/&lt;/code&gt; directory provisions the resources needed for Terraform's remote backend and deployment permissions. Run the Terraform commands below once before deploying the main stack.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;All commands use &lt;a href="https://github.com/99designs/aws-vault" rel="noopener noreferrer"&gt;aws-vault&lt;/a&gt;, which stores AWS credentials in your OS keychain and injects temporary credentials at runtime. The &lt;code&gt;--no-session&lt;/code&gt; flag skips STS session tokens, which some IAM operations reject. Ignore this if you're not using aws-vault&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws-vault &lt;span class="nb"&gt;exec &lt;/span&gt;YOUR_PROFILE &lt;span class="nt"&gt;--no-session&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  terraform &lt;span class="nt"&gt;-chdir&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;bootstrap init

aws-vault &lt;span class="nb"&gt;exec &lt;/span&gt;YOUR_PROFILE &lt;span class="nt"&gt;--no-session&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  terraform &lt;span class="nt"&gt;-chdir&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;bootstrap apply &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-var&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"project_name=my-rag"&lt;/span&gt; &lt;span class="nt"&gt;-var&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"environment=dev"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bootstrap creates two things: the S3 state bucket and a &lt;strong&gt;scoped deployer IAM policy&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The initial bootstrap requires &lt;code&gt;AdministratorAccess&lt;/code&gt; because the deployer policy does not exist yet. Once bootstrap completes, attach the generated policy to your IAM user and remove &lt;code&gt;AdministratorAccess&lt;/code&gt;. From that point forward, deployments run with least-privilege permissions.&lt;/p&gt;

&lt;p&gt;The bootstrap step outputs the S3 backend configuration and deployer policy ARN. Copy the backend configuration into &lt;code&gt;backend.tf&lt;/code&gt;, then initialize Terraform again to migrate state.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws-vault &lt;span class="nb"&gt;exec &lt;/span&gt;YOUR_PROFILE &lt;span class="nt"&gt;--no-session&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; terraform init
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then swap to the scoped policy (this can also be done through the AWS Console):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Attach the deployer policy&lt;/span&gt;
aws-vault &lt;span class="nb"&gt;exec &lt;/span&gt;YOUR_PROFILE &lt;span class="nt"&gt;--no-session&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; aws iam attach-user-policy &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--user-name&lt;/span&gt; YOUR_IAM_USER &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--policy-arn&lt;/span&gt; YOUR_DEPLOYER_POLICY_ARN

&lt;span class="c"&gt;# Drop AdministratorAccess&lt;/span&gt;
aws-vault &lt;span class="nb"&gt;exec &lt;/span&gt;YOUR_PROFILE &lt;span class="nt"&gt;--no-session&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; aws iam detach-user-policy &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--user-name&lt;/span&gt; YOUR_IAM_USER &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--policy-arn&lt;/span&gt; arn:aws:iam::aws:policy/AdministratorAccess
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  Step 2: Configure Your Variables
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cp &lt;/span&gt;terraform.tfvars.example terraform.tfvars
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The module only requires a few inputs. The setting worth paying attention to is &lt;code&gt;embedding_dimensions&lt;/code&gt; because it controls the size of the vectors stored in OpenSearch.&lt;/p&gt;

&lt;p&gt;For this development environment, we use &lt;code&gt;512&lt;/code&gt; dimensions. It reduces storage costs while keeping retrieval quality acceptable. We'll revisit this choice when looking at the Bedrock Knowledge Base configuration.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;embedding_dimensions&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;512&lt;/span&gt;  &lt;span class="c1"&gt;# 256 or 512, half the storage cost vs 1024&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;First time using Bedrock?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Bedrock subscribes your account to a foundation model automatically the first time you invoke it, but only if the identity making that first call has the AWS Marketplace permissions (&lt;code&gt;aws-marketplace:ViewSubscriptions&lt;/code&gt; and &lt;code&gt;aws-marketplace:Subscribe&lt;/code&gt;) to finish the subscription. There is a setup window of up to 15 minutes where your calls may even succeed while the subscription is still being finalized in the background.&lt;/p&gt;

&lt;p&gt;Here is the trap I fell into. My first invocation came from the scoped Lambda role, which does not carry those Marketplace permissions. Calls worked for a few minutes, then every request started failing with an access error once the subscription failed to complete. The fix is a one-time step: subscribe the model once using an identity that does have Marketplace permissions, such as your admin user, either by accepting the model agreement in the Bedrock console or by invoking the model once. The subscription is account-wide, so you do it once rather than per region. After that, the Lambda role needs only &lt;code&gt;bedrock:InvokeModel&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One extra step for Anthropic models like Claude: before that first invocation you also have to submit a one-time First Time Use form describing your use case. You do it once per account, or once at the organization's management account, from the model catalog in the Bedrock console or with the &lt;code&gt;PutUseCaseForModelAccess&lt;/code&gt; API. Access is granted as soon as the form goes through.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Step 3: Lambda The Query Handler and the Trigger
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/coolchigi/rag-bedrock-project/tree/main/modules/lambda" rel="noopener noreferrer"&gt;Lambda Module&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Lambda connects the application workflow together. There are two functions with both running Node.js 20 on ARM64. ARM64 (Graviton) is cheaper to run than &lt;code&gt;x86&lt;/code&gt; for the same memory allocation, and since these functions are mostly waiting on AWS services rather than doing heavy computation, there was no reason to pay the x86 premium.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;ingestion handler&lt;/strong&gt; (&lt;code&gt;ingest.mjs&lt;/code&gt;) handles the document ingestion flow. It is triggered by S3 uploads and starts a Bedrock ingestion job so new documents are processed and indexed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lambda/src/ingest.mjs&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;command&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;StartIngestionJobCommand&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;knowledgeBaseId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;KNOWLEDGE_BASE_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;dataSourceId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DATA_SOURCE_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;command&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;202&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;jobId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ingestionJob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ingestionJobId&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ConflictException&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// An ingestion job is already running. That's fine, the new file will be picked up.&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;202&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Ingestion already in progress&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One subtle issue: Bedrock only allows one ingestion job at a time. If several files are uploaded together, multiple S3 events can trigger Lambda invocations, and subsequent requests receive a &lt;code&gt;ConflictException&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;We treat this as expected behavior and return &lt;code&gt;202&lt;/code&gt;, because the existing ingestion job will pick up the newly uploaded files.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;query handler&lt;/strong&gt; (&lt;code&gt;query.mjs&lt;/code&gt;) handles retrieval and generation. It accepts a natural language question and uses Bedrock Knowledge Bases to retrieve relevant context and generate an answer with citations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lambda/src/query.mjs&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Parses event.body.query (API Gateway proxy format)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;command&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RetrieveAndGenerateCommand&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;retrieveAndGenerateConfiguration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;KNOWLEDGE_BASE&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;knowledgeBaseConfiguration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;knowledgeBaseId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;KNOWLEDGE_BASE_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;modelArn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MODEL_ARN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// Retries once on throttle/5xx if there's &amp;gt; 15 seconds left in the timeout budget&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;command&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;isRetryable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;hasTimeForRetry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;command&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;buildResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;503&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Service temporarily unavailable&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;buildResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;citations&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For observability, logging follows a strict rule: &lt;strong&gt;structured JSON, no PII&lt;/strong&gt;. We log request IDs, durations, and error types, but never store the actual user query or generated response.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;level&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;requestId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;level&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;requestId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;requestId&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Security:&lt;/strong&gt; Lambda dependencies are locked in &lt;code&gt;package-lock.json&lt;/code&gt;. No floating version ranges means deployments are reproducible and less likely to unexpectedly pull in a compromised dependency.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4: Deploy
&lt;/h2&gt;

&lt;p&gt;The Bedrock and OpenSearch modules have a circular dependency. The Bedrock Knowledge Base requires the AOSS collection endpoint, while the OpenSearch data access policy requires the Bedrock KB role ARN. On a fresh deployment, Terraform cannot resolve both resources at the same time.&lt;/p&gt;

&lt;p&gt;The solution is to break the cycle by hand: create the collection first, tell the rest of the stack where it lives, then apply everything else.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Create just the AOSS collection&lt;/span&gt;
aws-vault &lt;span class="nb"&gt;exec &lt;/span&gt;YOUR_PROFILE &lt;span class="nt"&gt;--no-session&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; terraform apply &lt;span class="nt"&gt;-target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;module.opensearch
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once it exists, grab its endpoint and paste it into &lt;code&gt;terraform.tfvars&lt;/code&gt; so Bedrock knows where to point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;opensearch_collection_endpoint&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://your-collection-id.us-east-1.aoss.amazonaws.com"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now deploy the rest. This first full run also pulls in the OpenSearch Terraform provider, so it needs an &lt;code&gt;init&lt;/code&gt; in front of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws-vault &lt;span class="nb"&gt;exec &lt;/span&gt;YOUR_PROFILE &lt;span class="nt"&gt;--no-session&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; terraform init
aws-vault &lt;span class="nb"&gt;exec &lt;/span&gt;YOUR_PROFILE &lt;span class="nt"&gt;--no-session&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; terraform apply
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first AOSS deployment takes a while (~10 minutes). After that the endpoint is captured in your state and tfvars, so day-to-day changes are just &lt;code&gt;terraform apply&lt;/code&gt;. The README has the exact commands if you want to follow along line by line.&lt;/p&gt;

&lt;p&gt;One thing worth knowing if you're using &lt;code&gt;aws-vault&lt;/code&gt;: some IAM operations reject the session tokens it generates by default. If you encounter an &lt;code&gt;InvalidClientTokenId&lt;/code&gt; error during deployment, retry with &lt;code&gt;--no-session&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.com/coolchigi/rag-bedrock-project" rel="noopener noreferrer"&gt;README&lt;/a&gt; contains the complete deployment reference.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 5: Test It
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Upload, check status, and invoke&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Upload a document. The ingestion Lambda triggers automatically through the S3 event:&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws-vault &lt;span class="nb"&gt;exec &lt;/span&gt;YOUR_PROFILE &lt;span class="nt"&gt;--no-session&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  aws s3 &lt;span class="nb"&gt;cp&lt;/span&gt; ./my-document.pdf &lt;span class="se"&gt;\&lt;/span&gt;
  s3://&lt;span class="si"&gt;$(&lt;/span&gt;terraform output &lt;span class="nt"&gt;-raw&lt;/span&gt; document_bucket_name&lt;span class="si"&gt;)&lt;/span&gt;/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;Before querying, verify that Bedrock has finished processing the document:&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws-vault &lt;span class="nb"&gt;exec &lt;/span&gt;YOUR_PROFILE &lt;span class="nt"&gt;--no-session&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  aws bedrock-agent list-ingestion-jobs &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--knowledge-base-id&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;terraform output &lt;span class="nt"&gt;-raw&lt;/span&gt; knowledge_base_id&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data-source-id&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;terraform output &lt;span class="nt"&gt;-raw&lt;/span&gt; data_source_id&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt; us-east-1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;Once the ingestion job is &lt;code&gt;COMPLETE&lt;/code&gt;, invoke the query Lambda:&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws-vault &lt;span class="nb"&gt;exec &lt;/span&gt;YOUR_PROFILE &lt;span class="nt"&gt;--no-session&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  aws lambda invoke &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--function-name&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;terraform output &lt;span class="nt"&gt;-raw&lt;/span&gt; query_function_name&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--payload&lt;/span&gt; &lt;span class="s1"&gt;'{"body":"{\"query\":\"What is the document about?\"}","requestContext":{"requestId":"test-1"},"headers":{}}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--cli-binary-format&lt;/span&gt; raw-in-base64-out &lt;span class="se"&gt;\&lt;/span&gt;
  /tmp/response.json &amp;amp;amp&lt;span class="p"&gt;;&lt;/span&gt;&amp;amp;amp&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;cat&lt;/span&gt; /tmp/response.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;The payload uses the API Gateway format expected by the Lambda handler, which is why the query is nested inside &lt;code&gt;body&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A successful response looks like:&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"statusCode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"headers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Content-Type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"application/json"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"body"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;answer&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;The document covers...&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;citations&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:[...]}"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;If you get an empty answer, check the ingestion status first. The Knowledge Base cannot retrieve documents until the ingestion job completes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 6: Cleanup
&lt;/h3&gt;

&lt;p&gt;Before running &lt;code&gt;terraform destroy&lt;/code&gt;, there is one thing to update. The Bedrock data source uses a &lt;code&gt;data_deletion_policy&lt;/code&gt; that defaults to &lt;code&gt;DELETE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;During teardown, Bedrock attempts to remove vectors from OpenSearch as part of deleting the data source. If the AOSS collection is destroyed in the same operation, Bedrock can no longer reach it and the deletion can get stuck.&lt;/p&gt;

&lt;p&gt;Set the policy to &lt;code&gt;RETAIN&lt;/code&gt; first, apply the change, then destroy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# modules/bedrock/main.tf&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_bedrockagent_data_source"&lt;/span&gt; &lt;span class="s2"&gt;"s3"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                 &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${var.config.environment}-${var.config.project_name}-s3-source"&lt;/span&gt;
  &lt;span class="nx"&gt;knowledge_base_id&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_bedrockagent_knowledge_base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;data_deletion_policy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"RETAIN"&lt;/span&gt;

  &lt;span class="c1"&gt;# ... rest of config&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Apply the change:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws-vault &lt;span class="nb"&gt;exec &lt;/span&gt;YOUR_PROFILE &lt;span class="nt"&gt;--no-session&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; terraform apply
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Full cleanup: tear down the stack&lt;/strong&gt;&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws-vault &lt;span class="nb"&gt;exec &lt;/span&gt;YOUR_PROFILE &lt;span class="nt"&gt;--no-session&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; terraform destroy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;To also remove the bootstrap resources, first empty the versioned Terraform state bucket, then destroy the bootstrap stack:&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Remove all object versions from the state bucket&lt;/span&gt;
aws-vault &lt;span class="nb"&gt;exec &lt;/span&gt;YOUR_PROFILE &lt;span class="nt"&gt;--no-session&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  aws s3api delete-objects &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--bucket&lt;/span&gt; YOUR_STATE_BUCKET_NAME &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--delete&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;aws-vault &lt;span class="nb"&gt;exec &lt;/span&gt;YOUR_PROFILE &lt;span class="nt"&gt;--no-session&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    aws s3api list-object-versions &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--bucket&lt;/span&gt; YOUR_STATE_BUCKET_NAME &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'{Objects: Versions[].{Key:Key,VersionId:VersionId}}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--output&lt;/span&gt; json&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# Destroy bootstrap&lt;/span&gt;
aws-vault &lt;span class="nb"&gt;exec &lt;/span&gt;YOUR_PROFILE &lt;span class="nt"&gt;--no-session&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  terraform &lt;span class="nt"&gt;-chdir&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;bootstrap destroy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Gotchas and Cost
&lt;/h2&gt;

&lt;p&gt;AOSS is the biggest cost driver in this stack. Even the minimum capacity configuration can add hundreds of dollars per month for a continuously running collection. If you are experimenting, destroy the stack when you are done.&lt;/p&gt;

&lt;p&gt;Common issues:&lt;/p&gt;

&lt;h3&gt;
  
  
  AOSS collection won't create
&lt;/h3&gt;

&lt;p&gt;You likely hit the policies-first timing issue. Make sure encryption, network, and access policies are included in &lt;code&gt;depends_on&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  KB ingestion fails
&lt;/h3&gt;

&lt;p&gt;Verify that the KB role has &lt;code&gt;s3:GetObject&lt;/code&gt; access to the bucket and &lt;code&gt;aoss:APIAccessAll&lt;/code&gt; access to the collection. Also confirm the AOSS collection is in an &lt;code&gt;ACTIVE&lt;/code&gt; state before starting ingestion.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lambda returns 400 (&lt;code&gt;query is required&lt;/code&gt;)
&lt;/h3&gt;

&lt;p&gt;The query Lambda expects &lt;code&gt;event.body.query&lt;/code&gt;. Make sure the payload wraps the query inside a &lt;code&gt;body&lt;/code&gt; field as shown in Step 5.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lambda returns 503
&lt;/h3&gt;

&lt;p&gt;The function collapses any Bedrock failure into a generic 503, so the real reason is in CloudWatch, not the response body. Two things cause it in practice.&lt;/p&gt;

&lt;p&gt;The boring one is throttling. The function retries once automatically, and sustained traffic may need backoff or a limit increase.&lt;/p&gt;

&lt;p&gt;The one that actually cost me time is IAM. This project generates with Claude Sonnet 4.5, which you reach through a cross-region inference profile rather than a plain model ID. An inference profile is really a router: it forwards your request to the underlying model in whichever US region has capacity. So IAM checks you twice, once on the profile and again on the foundation model it lands on. Grant &lt;code&gt;bedrock:InvokeModel&lt;/code&gt; on only the profile and the call fails with an &lt;code&gt;AccessDeniedException&lt;/code&gt; on &lt;code&gt;GetInferenceProfile&lt;/code&gt;, which the Lambda dutifully turns into a 503. The role needs &lt;code&gt;InvokeModel&lt;/code&gt; and &lt;code&gt;GetInferenceProfile&lt;/code&gt; on the profile, plus &lt;code&gt;InvokeModel&lt;/code&gt; on the foundation model in each region the profile can route to (&lt;code&gt;us-east-1&lt;/code&gt;, &lt;code&gt;us-east-2&lt;/code&gt;, &lt;code&gt;us-west-2&lt;/code&gt;). The Terraform in the repo already wires this up, but it is worth knowing why all those grants are there.&lt;/p&gt;

&lt;h3&gt;
  
  
  Empty citations array
&lt;/h3&gt;

&lt;p&gt;The document format may not be supported well by Bedrock chunking. Plain text, PDF, Markdown, and HTML generally work best.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;terraform destroy&lt;/code&gt; gets stuck
&lt;/h3&gt;

&lt;p&gt;The default &lt;code&gt;data_deletion_policy = "DELETE"&lt;/code&gt; can cause teardown issues when the AOSS collection is destroyed at the same time. Set it to &lt;code&gt;RETAIN&lt;/code&gt;, apply, and then destroy.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next?
&lt;/h2&gt;

&lt;p&gt;The Knowledge Base is live and queryable from the CLI. In Part 3, we're putting an API Gateway in front of the query Lambda and wiring up a React frontend. The query Lambda's response shape already works with API Gateway's proxy integration, and the only thing left is CORS headers and the API Gateway resource itself.&lt;/p&gt;




&lt;h2&gt;
  
  
  Disclaimer
&lt;/h2&gt;

&lt;p&gt;This is strictly for educational purposes. You will be charged for the resources created when you follow along. Remember to clean up after use.&lt;/p&gt;




&lt;h2&gt;
  
  
  Links &amp;amp; Resources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/bedrock/latest/userguide/knowledge-base.html" rel="noopener noreferrer"&gt;https://docs.aws.amazon.com/bedrock/latest/userguide/knowledge-base.html&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/serverless-getting-started.html" rel="noopener noreferrer"&gt;https://docs.aws.amazon.com/opensearch-service/latest/developerguide/serverless-getting-started.html&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/bedrockagent_knowledge_base" rel="noopener noreferrer"&gt;https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/bedrockagent_knowledge_base&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles.html" rel="noopener noreferrer"&gt;https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles.html&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.hashicorp.com/terraform/language/backend/s3" rel="noopener noreferrer"&gt;https://developer.hashicorp.com/terraform/language/backend/s3&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lock.html" rel="noopener noreferrer"&gt;https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lock.html&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>aws</category>
      <category>bedrock</category>
      <category>terraform</category>
      <category>rag</category>
    </item>
  </channel>
</rss>
