CIW workshop 2 · afternoon · 4 November 2026

A course, written by a small team of agents

Production-Ready AI Roadshow Lab 1, Building a multi-agent system. A researcher gathers facts, a judge says pass or fail, and a writer turns a pass into a Markdown course. The loop stops at three tries. This page replays the run that passed that afternoon. It does not call a model.

What the afternoon lab was

Calgary Innovation Week, 4 November 2026. The lab builds a course creator as separate services instead of one prompt that tries to do every job. All 10 codelab steps passed. Cleanup afterwards left no Cloud Run services.

The talk's picture is a team. The researcher has web search. The judge can send the work back. The writer formats the course. The orchestrator keeps the shared state. A sequential pipeline runs the research loop first and the writer second. Agent-to-agent calls let the specialists live apart.

The public codelab is Building a multi-agent system. The starter sample is prai-roadshow-lab-1-starter in GoogleCloudPlatform/devrel-demos, Apache-2.0, Copyright 2022 Google LLC.

The afternoon reused the morning workshop's cloud project. Sign-in was Vertex through Application Default Credentials. Cloud Run used region us-west1. The model location was global. The lab turned on Cloud Run, Artifact Registry, Cloud Build, Vertex AI, and Compute. This page leaves out the project id, the project number, service URLs, and machine paths.

How a course gets built

The same flowchart as the lab, drawn as HTML so it reflows. Solid steps are control flow inside the orchestrator. The A2A list is how that orchestrator actually reaches the three specialists.

Frontend · port 8000

Course creator

The page that takes a topic and shows progress.

calls

Orchestrator · port 8004

Orchestrator

Holds session state and runs the pipeline below.

SequentialAgent · course_creation_pipeline

LoopAgent · research_loop · max 3

  1. 1 · port 8001

    Researcher

    Gemini 3 and Google Search.

  2. 2 · port 8002

    Judge

    Pydantic JudgeFeedback. Pass or fail.

  3. 3 · BaseAgent

    Escalation checker

    On pass, escalate and leave the loop.

Status is fail. The checker does not escalate. The loop returns to the researcher, until the third try.

Status is pass (escalate is true). The sequential agent leaves the loop and continues.

Then · port 8003

Content builder

Writes the Markdown course from the approved research.

Ports are the local lab ports. Colour repeats the role and is not the only cue. The numbered list is the loop order.

What each agent does

Three specialists do the work. The orchestrator does not search, grade, or write. It only decides who runs next. Every model agent used gemini-3-flash-preview.

Specialist

Researcher

An agent with the Google Search tool. It gathers grounded notes on the topic. If the judge sends a critique, the next search uses that critique. The instruction that worked says "Use Google Search to find relevant information." It does not ask for a function named google_search.

Gate

Judge

An agent whose only output is a JudgeFeedback object: status is pass or fail, plus feedback. Transfer to the parent and to peers is turned off, so it cannot wander off into chat. The schema is the contract the checker reads.

Writer

Content builder

Turns approved research into a course. One # title, then ## sections the frontend can split into pages. It runs only after the loop has a pass.

Control

Orchestrator

EscalationChecker is a BaseAgent. It reads judge_feedback in session state and, on pass, emits an event with escalate set. That checker sits inside LoopAgent research_loop with max_iterations=3, after the researcher and the judge. The loop and then the content builder sit inside SequentialAgent course_creation_pipeline.

What A2A added

The morning workshop kept weather in-process and sent only currency out over A2A. This lab sends every specialist out. The orchestrator does not import their Python modules.

Remote agents

Researcher, judge, and content builder are RemoteA2aAgent clients. Each one points at an agent-card URL. A callback after the agent stores the reply on the orchestrator session: research_findings as text, and judge_feedback parsed when the text is JSON.

Discovery, then a call

The card is /.well-known/agent-card.json. Calls are JSON-RPC. The HTTP client attaches a Google identity token for service-to-service calls. Locally the cards came back as A2A 0.3.0, preferred transport JSON-RPC, streaming on.

Cards that were checked

Researcher skills researcher and researcher-google_search on port 8001. Judge skill judge on port 8002. Content builder skill content_builder on port 8003. The frontend was port 8000 and the orchestrator port 8004.

Why bother

Each specialist can be deployed, scaled, and replaced on its own. The orchestrator only needs the card. The same pattern works if a later agent is written in another framework, as long as it speaks A2A.

What the run verified

All 10 codelab steps passed. These are the checks that were written down. Outputs are summarised.

  1. Researcher, alone

    Prompt: "Find the population of Tokyo in 2020." The reply cited the 2020 Japan Census.

    Tokyo Metropolis
    14,047,594
    Versus 2015
    +3.9%
    Density
    6,402 persons per km²
    Daytime population
    16.752 million
    23 special wards
    9,733,276
    Greater Tokyo
    about 36,914,000
  2. Judge, alone

    Prompt: "Topic: Tokyo. Findings: Tokyo is a city." The judge failed it. The object returned:

    {"status": "fail", "feedback": "The findings are extremely insufficient for the topic 'Tokyo'. They lack critical information such as its status as the capital of Japan, population statistics, major landmarks, economic importance, and cultural significance."}
  3. Five processes on the laptop

    Ports 8000 through 8004. The three specialist cards matched the skills above, with JSON-RPC and streaming.

  4. Cloud Run, then deleted

    Five services in us-west1, labelled for the lab: researcher, judge, content-builder, orchestrator, and course-creator. They were removed in cleanup. This page does not publish their URLs or revision ids.

  5. End to end: history of coffee

    Prompt: "Create a course about the history of coffee." The stream reported research, then a judge pass, then the writer, then the course. The judge's pass:

    {"status": "pass", "feedback": "The research findings provide a comprehensive and well-structured course syllabus covering the history of coffee from its origins in Ethiopia to the modern third-wave movement, including cultural, economic, and political contexts."}

    The course title was "The Black Gold: A Global History of Coffee." The sections were:

    1. The myth and mysticism of discovery (800 CE to the 1400s).
    2. The Ottoman bloom and the birth of the coffeehouse (1500s to the 1600s).
    3. Europe's penny universities and the Enlightenment (1600s to the 1700s).
    4. Colonialism and slavery (1700s to the 1800s).
    5. Machines and the first wave (1800s to the 1960s).
    6. Modern waves, from the second wave to specialty (1960s to the present).
    7. Practical applications and further reading.

Two things that broke, and the fixes

  1. Google Search was not a local Python tool

    The error was ValueError: Tool 'google_search' not found. In this ADK version, google_search turns on server-side Gemini grounding. It is not a function registered in the local tool map. An instruction that said to use the google_search tool, with that name in backticks, made gemini-3-flash-preview emit a client-side function call of that name. The working sentence is "Use Google Search to find relevant information."

  2. Three deploys at once hit a Cloud Build limit

    The error was 429 RESOURCE_EXHAUSTED while the deploy command polled Cloud Build. Three gcloud run deploy --source commands at the same time meant three pollers, about 20 status reads a minute each, against a quota of 60 reads a minute. Deploying at most two services at a time stayed under that limit.

How I'd use this pattern

A high-variance draft should not be the thing that publishes. Put a short loop in front of the publish step.

Write, then grade

The writer drafts a pull request, a workshop page, or a research brief. The judge returns only pass or fail, plus feedback, from a Pydantic schema.

Stop on pass, cap the tries

An escalation checker ends the LoopAgent when the status is pass. max_iterations=3 is the cap. The step that opens the pull request, updates the page, or sends the brief runs only after a pass.

Cleanup confirmed

Nothing from this lab was left running. The five Cloud Run services were deleted in us-west1: researcher, judge, content-builder, orchestrator, and course-creator. The source-deploy image repository was deleted too, which freed about 755 MB.

A list filtered to the lab label, and a list of Cloud Run services across regions, both came back as:

Listed 0 items.

Stack that ran

Resolved from the lockfile on CPython 3.13.18. Every LLM agent used gemini-3-flash-preview. The A2A cards reported protocol 0.3.0. The installed library was a2a-sdk 0.3.22.

  • google-adk 1.28.0
  • google-genai 1.68.0
  • a2a-sdk 0.3.22
  • fastapi 0.124.4
  • uvicorn 0.40.0
  • pydantic 2.12.5
  • google-cloud-aiplatform 1.132.0
  • CPython 3.13.18

Code from the sample

The judge schema and the escalation checker below are the versions that ran on 4 November 2026. They derive from Google's Apache-2.0 sample prai-roadshow-lab-1-starter (GoogleCloudPlatform/devrel-demos, Copyright 2022 Google LLC). The starter leaves those two pieces as exercises. This is the completed lab code, under that licence. The notice is also in notice-lab1.txt.

Copyright 2022 Google LLC

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

Judge schema

From agents/judge/agent.py. MODEL is gemini-3-flash-preview.

class JudgeFeedback(BaseModel):
    """Structured feedback from the Judge agent."""
    status: Literal["pass", "fail"] = Field(
        description="Whether the research is sufficient ('pass') or needs more work ('fail')."
    )
    feedback: str = Field(
        description="Detailed feedback on what is missing. If 'pass', a brief confirmation."
    )


judge = Agent(
    name="judge",
    model=MODEL,
    description="Evaluates research findings for completeness and accuracy.",
    instruction="""
    You are a strict editor.
    Evaluate the 'research_findings' against the user's original request.
    If the findings are missing key info, return status='fail' and specific feedback on what is missing.
    If the findings are comprehensive and accurate, return status='pass'.
    """,
    output_schema=JudgeFeedback,
    disallow_transfer_to_parent=True,
    disallow_transfer_to_peers=True,
)

Escalation checker

From agents/orchestrator/agent.py. In that file, researcher, judge, and content builder are remote A2A agents. The checker, the loop, and the sequential agent are local.

class EscalationChecker(BaseAgent):
    """Checks the judge's feedback and escalates (breaks the loop) if it passed."""

    async def _run_async_impl(
        self, ctx: InvocationContext
    ) -> AsyncGenerator[Event, None]:
        feedback = ctx.session.state.get("judge_feedback")
        print(f"[EscalationChecker] Feedback: {feedback}")

        is_pass = False
        if isinstance(feedback, dict) and feedback.get("status") == "pass":
            is_pass = True
        elif isinstance(feedback, str) and '"status": "pass"' in feedback:
            is_pass = True

        if is_pass:
            yield Event(author=self.name, actions=EventActions(escalate=True))
        else:
            yield Event(author=self.name)


escalation_checker = EscalationChecker(name="escalation_checker")

research_loop = LoopAgent(
    name="research_loop",
    description="Iteratively researches and judges until quality standards are met.",
    sub_agents=[researcher, judge, escalation_checker],
    max_iterations=3,
)

root_agent = SequentialAgent(
    name="course_creation_pipeline",
    description="A pipeline that researches a topic and then builds a course from it.",
    sub_agents=[research_loop, content_builder],
)