This post is my submission for DEV Education Track: Build Multi-Agent Systems with ADK.
A course-creation service with a built-in quality gate. You give it a topic, and roughly two minutes later you get a structured course module β researched, reviewed, and only then written.
The interesting part is the reviewed. Single-agent generation pipelines happily write authoritative-sounding courses from whatever their first research pass dredged up. This system puts a judge between research and writing: the researcher's findings go to a separate judge agent with a structured verdict schema, and the loop only exits when the judge says pass
β or when it hits an iteration cap, which turned out to be more interesting than expected (see learnings).
It runs as five separate Cloud Run services in europe-north1
: a public web app, an orchestrator, and three leaf agents that are full standalone A2A microservices. The orchestrator talks to them over authenticated HTTP β it never imports them.
course-creator (web app, public)
ββ orchestrator SequentialAgent
ββ research_loop LoopAgent, max_iterations=3
β ββ researcher RemoteA2aAgent β researcher service (google_search)
β ββ judge RemoteA2aAgent β judge service (output_schema)
β ββ escalation_checker local BaseAgent β the loop's exit condition
ββ content_builder RemoteA2aAgent β content-builder service
Ask it to "Create a comprehensive course on:" anything. A full run takes about two minutes; you'll see progress events as each agent takes its turn, then the finished module.
Researcher β gathers findings with the google_search
tool. On every loop iteration it gets the judge's previous feedback and revises. Its instruction is deliberately strict about output shape ("return the markdown only"), because anything chatty it emits flows straight into the next agent's input.
Judge β the quality gate. It returns a JudgeFeedback
pydantic model via output_schema
: a status
of pass/fail plus specific, actionable criticism. Making the verdict structured rather than prose is what makes the loop mechanical instead of vibes-based.
Escalation checker β the one agent that is not a remote service: a small local BaseAgent
that reads the judge's verdict out of session state and decides whether the loop exits. It escalates only on an explicit status == "pass"
. A missing or unparseable verdict means "go round again", never "exit" β failing open would ship unreviewed research, which is the one thing the loop exists to prevent.
Content builder β takes the approved findings and writes the actual course module: objectives, sections, exercises.
Orchestrator β a SequentialAgent
wrapping a LoopAgent
. The three remote agents are wired in as RemoteA2aAgent
s pointed at each service's A2A agent card. Handoff between agents is session state (research_findings
, judge_feedback
, course_module
), written by an after-agent callback β not chat history.
The A2A part is what makes this feel like a real distributed system rather than a monolith with extra steps: each leaf service is independently deployable, has its own dependency set, and the only contract between them is the agent card and the state keys.
The best error message is the one that tells you what it actually found. I lost most of a day to a container that died on boot with No root_agent found
plus a helpful diagram of a directory structure I already had. The real problem: I'd exported an ADK App
under the name root_agent
, and the logs the type mismatch at WARNING level, discards its own diagnosis, and reports "not found" instead. I filed it upstream as google/adk-python#6606; two contributors sent fixes, the maintainers asked me to test both PRs against my repro, and the fix merged the next morning. The lab taught me ADK; the debugging taught me the ADK 's dispatch paths well enough to review its patches. File your issues β this one went from filed to fixed in nine days.
Deploy with the region pinned, always. gcloud run deploy
without an explicit region fell through to us-central1
with only a WARNING:
β and quietly built me a complete second five-service stack on the wrong continent. GOOGLE_CLOUD_LOCATION=europe-north1 ./deploy.sh
, every time, no exceptions.
Your result stream contains everyone's output unless you filter it. My first "working" version returned 16,441 characters of which only the last ~7,700 were the course β the rest was two full research dumps and two raw JSON verdicts glued on top, which also broke the markdown rendering. The fix is one line of author filtering, but the lesson generalises: in a multi-agent pipeline, every agent talks, and the frontend has to know whose words are the product.
The iteration cap is a policy decision wearing a config value's clothes. During final verification, my test topic failed the judge three times in a row, hit max_iterations=3
, and the system shipped a perfectly normal-looking course built from research the judge never approved β with nothing labelling it as such. Fail-closed loop exit and a bounded loop are both correct individually; together they create a third path (cap-exit) that needs its own handling. That's the top of my TODO now, and it only surfaced because I watched the orchestrator's logs instead of just the output.
Keep a TODONT. Alongside TODO.md
I keep a TODONT.md
: every approach that was tried and rejected, with the measurement or the failure that killed it. Half the entries above started life there. In a system with five moving services, "we already tried that and here's why it doesn't work" is the most valuable documentation you can own.