# A course generator with a quality gate: five ADK agents on Cloud Run

> Source: <https://dev.to/tommy_leonhardsen_81d1f4e/a-course-generator-with-a-quality-gate-five-adk-agents-on-cloud-run-hnm>
> Published: 2026-08-14 11:27:54+00:00

*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 loader 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](https://github.com/google/adk-python/issues/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 loader'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.
