aiml
SearchMy learning
Menu

Lesson 2 of 20Module 1

Configure an ADK Agent with YAML

Define a Google ADK 2.0 agent in root_agent.yaml, load it with from_config, and test which YAML the loader and the Visual Builder save endpoint refuse.

Prerequisites

Python and YAML, and the docs assistant from the Building Agents with Google ADK 2.0 course.

By the end, you can

  • Write an LlmAgent as root_agent.yaml with a custom Python tool, and run it with adk run or a Runner.
  • Explain what from_config validates and imports, and why a YAML change deserves a code review.
  • Predict which uploads the Visual Builder save endpoint rejects, and from which clients.
Runtime
Python 3.12+ and a Gemini API key (Agent Config supports Gemini models only)
Dependencies
google-adk 2.12.0

Lab code, tests and fixtures (ZIP, 132 KB)Quiz check your understanding

Progress is saved in this browser.

An agent’s model, instruction and tool list change more often than its tools do. ADK’s Agent Config lets you keep those settings in a YAML file, root_agent.yaml, and keep only the tools in Python. Here you rewrite the docs assistant as a config agent, run it with adk run, load it from Python with from_config, and then look at the other way such a file gets written: Visual Builder, the editor inside adk web, which saves YAML through HTTP endpoints with rules of their own. These runs were recorded on Gemini gemini-3.8-flash, and every rejection below is real output from ADK 2.12.

When you need this. Suppose the docs team, not the platform team, owns the assistant’s instruction and wants to tune its tone or word limit without changing its Python tools. The YAML still needs review and delivery to the running service. A reviewed YAML file in the repository gives them that, and Visual Builder gives a new teammate a way to sketch an agent and export the same format. You also need to know what that file can make the server import, because a YAML change that names a function is a code change.

Original recording. The saved output below uses the earlier fictional shipctl scenario. Current lab instructions and inputs use docuctl; this preserved output is not a recording of those renamed inputs.

Recorded run of the YAML agentRecorded output · textDownload
$ adk run --in_memory docs_config 'How do I roll back production?'
[docs_assistant]: To roll back production to a specific release, run:
`shipctl rollback --env production --to r41`
Without `--to`, shipctl rolls back to the previous release. It points traffic at the release you specify without rebuilding anything. 
Note that database migrations are not rolled back automatically; if the current release ran a migration, you must write a new migration that reverses it.
docs_config/root_agent.yaml · from_config · adk web builder · recorded in recorded-runs.json
data flowfailureroot_agent.yamlmodel, instruction,tools: read_pagetools.pydef read_page(page)from_configvalidate fields,block stdlib names,import each toolYAMLimportLlmAgentdocs_assistantagentadk run, Runnerrunsload failsValidationError,AttributeError, ValueErroradk webVisual Builderbrowser UIPOST /dev/apps/{app}/builder/savechecks keys and code namesmultipartwrites root_agent.yamlupload refusedHTTP 400 blocked key or code,HTTP 403 remote clientRecorded outcomes · 6 changes to the configloader: instruction misspelledValidationErrorloader: tool function missingAttributeErrorloader: tool is os.systemValueErrorbuilder: upload with an args keyHTTP 400builder: tool is subprocess.runHTTP 400builder: edited instruction, savedHTTP 200data flowfailureroot_agent.yamltools.pyfrom_configvalidate, block, importYAMLimportload fails3 error typesLlmAgentagentadk runrunsVisual Builderadk webbuilder/savechecks keys, code namesmultipartupload refusedHTTP 400 or 403writes the YAMLRecorded outcomesinstruction misspelledloader: ValidationErrortool function missingloader: AttributeErrortool is os.systemloader: ValueErrorupload with an args keybuilder: HTTP 400tool is subprocess.runbuilder: HTTP 400edited instruction, savedbuilder: HTTP 200
The YAML file is configuration that names Python code. from_config checks the field names against LlmAgent, refuses any standard-library reference and imports each tool by its dotted name; a failure stops the load before any model call. Visual Builder edits the same file through the builder endpoints, which add two rules of their own: no args key, and code only from the app's own package or ADK.

Lab setup. Download the complete lab ZIP, extract it and open a terminal in adk-agent-config/. Keep the bundled code, tests and fixtures together. Create the pinned environment before running the commands below:

Prepare the lab environmentTerminal · bash
python3.12 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements-lock.txt

1. Generate a config agent

adk create --type=config writes a minimal config agent and an .env for the key. The help text marks the config type as experimental; in 2.12 from_config carries the same marker. Pass a placeholder key, never your real one, if the folder goes into version control.

Recorded adk create runRecorded output · textDownload
$ adk create --type=config --model gemini-3.8-flash --api_key your-gemini-api-key docs_config
created docs_config/: .env, .gitignore, root_agent.yaml
# yaml-language-server: $schema=https://raw.githubusercontent.com/google/adk-python/refs/heads/main/src/google/adk/agents/config_schemas/AgentConfig.json
name: root_agent
description: A helpful assistant for user questions.
instruction: Answer user questions to the best of your knowledge
model: gemini-3.8-flash

The first line points editors that support yaml-language-server at ADK’s JSON schema for this format, so they can complete and check field names as you type.

2. Write the docs assistant in YAML

The YAML sets the same fields as the Python Agent in the first course. agent_class: LlmAgent picks the class, and generate_content_config maps onto types.GenerateContentConfig. The tool is named by its fully qualified Python name:

docs_config/root_agent.yamlSource · yamlDownload
# yaml-language-server: $schema=https://raw.githubusercontent.com/google/adk-python/refs/heads/main/src/google/adk/agents/config_schemas/AgentConfig.json
agent_class: LlmAgent
name: docs_assistant
model: gemini-3.8-flash
description: Answers developer questions about docuctl from its documentation.
instruction: |
  You answer questions from developers about docuctl, the team's deploy CLI.
  Read the relevant page with read_page before you answer, and answer only from it.
  Copy commands exactly, in backticks. Keep the answer under 100 words.
tools:
  - name: docs_config.tools.read_page
generate_content_config:
  temperature: 0.2
  max_output_tokens: 1024
  thinking_config:
    thinking_level: low
docs_config/tools.pySource · pythonDownload
"""The custom tool root_agent.yaml names by its fully qualified name: docs_config.tools.read_page."""
from pathlib import Path

DOCS = Path(__file__).resolve().parent.parent / "docs"
PAGES = sorted(p.stem for p in DOCS.glob("*.md"))


def read_page(page: str) -> dict:
    """Read one page of the docuctl documentation.

    Args:
        page: The page name: config, deploy, rollback or tokens.
    """
    if page not in PAGES:
        return {"error": f"No page {page!r}. Pages: {', '.join(PAGES)}."}
    return {"page": page, "text": (DOCS / f"{page}.md").read_text(encoding="utf-8")}

The folder has no __init__.py and no agent.py: the loader finds root_agent.yaml and builds the agent from it. The model field takes a Gemini model name; the Agent Config page lists only Gemini models as supported, so the Gemma-through-LiteLlm alternative used elsewhere in the course is not available for a config agent.

From the extracted lab directory (the parent of docs_config/), set GOOGLE_API_KEY in your shell and start the ADK CLI. Enter Which page explains CI tokens? at its prompt:

Start an interactive CLI sessionTerminal · bash
adk run docs_config

To inspect the same config in the Dev UI, stop the CLI and run adk web from the same directory. Open the printed loopback URL, select docs_config, and ask the same question. This externally created YAML agent can run in the Dev UI, but it does not receive Visual Builder’s edit button. Edit its instruction and tool reference in your text editor. To try Visual Builder separately, use + to create a new agent, review its configuration, and select Save before leaving the builder.

Start the development Web UITerminal · bash
adk web --host 127.0.0.1 --port 8766 .

A misspelled field. instructions: fails validation twice: instruction is missing, and instructions is an extra input that is not permitted. Use the schema line so the editor flags the key; run from_config in CI on every YAML change.

Recorded YAML field rejectionRecorded output (excerpt) · textFull output
from_config misspelled_field: ValidationError: 2 validation errors for LlmAgentConfig

3. Load the YAML from Python

A test or a custom server can load the same file with from_config and hand the result to a Runner:

load.pySource · pythonDownload
"""Load root_agent.yaml from Python and run one question through a Runner.

Run from this folder with GOOGLE_API_KEY set: python load.py "How do I roll back production?"
"""
import asyncio
import sys
from pathlib import Path

from google.adk import Runner
from google.adk.agents.config_agent_utils import from_config
from google.adk.sessions import InMemorySessionService
from google.genai import types

HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))  # so the YAML's docs_config.tools.read_page imports, as under adk run


def load():
    """from_config validates the YAML against LlmAgent's fields and imports each tool by name."""
    return from_config(str(HERE / "docs_config" / "root_agent.yaml"))


def describe(event) -> str:
    parts = event.content.parts if event.content and event.content.parts else []
    what = [f"call {p.function_call.name}({dict(p.function_call.args)})" if p.function_call
            else f"result {p.function_response.name}" if p.function_response
            else repr(p.text[:40]) for p in parts if p.function_call or p.function_response or (p.text and not p.thought)]
    return f"{event.author:<15} {' + '.join(what)}"


async def ask(agent, question: str) -> list:
    sessions = InMemorySessionService()
    runner = Runner(app_name="docs_config", agent=agent, session_service=sessions)
    session = await sessions.create_session(app_name="docs_config", user_id="dev-1")
    message = types.Content(role="user", parts=[types.Part(text=question)])
    try:
        return [e async for e in runner.run_async(user_id="dev-1", session_id=session.id, new_message=message)]
    finally:
        await runner.close()


def main() -> None:
    agent = load()
    print(f"{type(agent).__name__} {agent.name} model={agent.model} tools={[t.__name__ for t in agent.tools]}")
    print(f"temperature={agent.generate_content_config.temperature} "
          f"thinking_level={agent.generate_content_config.thinking_config.thinking_level.value}")
    events = asyncio.run(ask(agent, sys.argv[1]))
    for event in events:
        print(describe(event))
    print("answer:", events[-1].content.parts[-1].text)


if __name__ == "__main__":
    main()
Recorded run of load.pyRecorded output · textDownload
$ python load.py 'How do I roll back production?'
LlmAgent docs_assistant model=gemini-3.8-flash tools=['read_page']
temperature=0.2 thinking_level=LOW
docs_assistant  call read_page({'page': 'rollback'})
docs_assistant  result read_page
docs_assistant  'To roll back production, use `shipctl ro'
answer: To roll back production, use `shipctl rollback`:

- To roll back to a specific release:
  `shipctl rollback --env production --to r41`
- Without `--to`, shipctl rolls back to the previous release.

It points traffic at the specified release without rebuilding. Note that database migrations are not rolled back; if the release ran a migration, you must write a new migration to reverse it.

from_config returned an ordinary LlmAgent, so everything from the first course applies: the runner, sessions, callbacks and the event stream. The sys.path line matters. adk run puts the agents folder on the import path, which is how docs_config.tools.read_page resolves; a script has to do the same.

A missing tool function. docs_config.tools.read_pages raises AttributeError at load time, before any request. Name the function exactly; the CI load test catches it.

A standard-library reference. os.system raises ValueError: Blocked module reference. Wrap the behavior in a function in your own package and review it there.

Recorded tool-reference rejectionsRecorded output · textDownload
from_config missing_tool: AttributeError: module 'docs_config.tools' has no attribute 'read_pages'
from_config stdlib_tool: ValueError: Blocked module reference: 'os.system'. Agent configurations cannot import from 'os'. The Python standard library is blocked in full because too much of it can execute arbitrary code. Reference your own agent package, 'google.adk', or a third-party package instead.

4. Save the config from Visual Builder

adk web . serves the Dev UI, and its Visual Builder edits config agents in the browser: the docs page walks through the + button, the component panels and Save. When you select Save, the browser uploads the YAML files to /dev/apps/{app}/builder/save, and the server writes them into the agent folder. The recorded calls below exercise the builder’s HTTP endpoints with curl against a temporary copy of the folder. They test endpoint behavior; they do not show that the UI can open this externally created agent for editing:

Recorded builder endpoint callsRecorded output · textDownload
$ adk web --port 8766 .
$ curl -s http://127.0.0.1:8766/dev/apps/docs_config/builder
HTTP 200
$ curl -s -X POST http://127.0.0.1:8766/dev/apps/docs_config/builder/save -F 'files=@edit.yaml;filename=docs_config/root_agent.yaml'
true
HTTP 200
$ curl -s -X POST http://127.0.0.1:8766/dev/apps/docs_config/builder/save -F 'files=@args_key.yaml;filename=docs_config/root_agent.yaml'
{"detail":"Blocked key 'args' found in 'docs_config/root_agent.yaml'. The 'args' field is not allowed in builder uploads because it can execute arbitrary code."}
HTTP 400
$ curl -s -X POST http://127.0.0.1:8766/dev/apps/docs_config/builder/save -F 'files=@outside_code.yaml;filename=docs_config/root_agent.yaml'
{"detail":"Blocked code reference 'subprocess.run' in 'docs_config/root_agent.yaml'. The 'tools' field may only reference code under 'docs_config' or an ADK built-in."}
HTTP 400

The recording also checks the files around those calls:

recorded-runs.json: builder file checksRecorded output · jsonDownload
{
  "get_matches_file": true,
  "saved_has_edit": true
}

The GET returned the file on disk byte for byte, and the first save rewrote it with the shorter word limit. The next two uploads were refused, and the file stayed as it was.

Note: The docs list two editing limits. Agents created outside Visual Builder cannot be opened in it for editing (the pencil icon appears only for builder-made agents), and the builder supports only the components Agent Config supports. See Visual Builder.

An upload with an args key. The builder returns HTTP 400 Blocked key 'args', and the file is unchanged. Configure tool arguments in Python code, outside the builder.

An upload naming code outside the app. subprocess.run gets HTTP 400 Blocked code reference. Reference functions under the app folder, such as docs_config.tools.

A remote client. A save from a non-loopback address gets HTTP 403 and names ADK_ALLOW_REMOTE_AGENT_BUILDER. Use the builder on your own machine; do not set the variable on a shared network.

How the loader resolves Python code

from_config reads the YAML, resolves agent_class and validates the mapping against that class’s fields with Pydantic, which forbids unknown keys. Then it resolves every code reference, in tools, callbacks and similar fields, by importing the dotted name. Before importing, it checks the top-level module against a denylist: the whole Python standard library is blocked, so the recorded os.system reference is rejected. Your own package, google.adk and third-party packages are allowed.

That makes root_agent.yaml code by reference. A YAML line can make the server import any function from any installed third-party package, so review YAML changes the way you review Python changes, and do not load config files from users.

The builder’s save endpoint is stricter than the loader, because it writes files on request. It refuses any args key, which its error message says can execute arbitrary code, and it allows code references only under the app’s own folder or ADK built-ins. A third-party function the loader would accept is still refused here. subprocess.run was refused by this app-folder rule at save time; the loader would also refuse it later, as standard library. It also accepts only clients on the loopback interface: adk web --host 0.0.0.0 does not expose the builder to the network unless you set ADK_ALLOW_REMOTE_AGENT_BUILDER=1.

Test the config offline

test_config.py loads the real YAML, runs it through a Runner with the recorded model replies, and calls the real adk web app through FastAPI’s TestClient, giving it a loopback or a remote client address. The builder tests save into the example folder and restore root_agent.yaml after each test.

Terminal commandTerminal · bash
python -m unittest -v test_config.py
Recorded test outputRecorded output · textDownload
test_a_missing_tool_function_fails_to_load (test_config.AgentConfig.test_a_missing_tool_function_fails_to_load) ... ok
test_a_misspelled_field_fails_validation (test_config.AgentConfig.test_a_misspelled_field_fails_validation) ... ok
test_the_loaded_agent_runs_through_a_runner (test_config.AgentConfig.test_the_loaded_agent_runs_through_a_runner) ... ok
test_the_loader_blocks_standard_library_references (test_config.AgentConfig.test_the_loader_blocks_standard_library_references) ... ok
test_the_yaml_loads_as_an_llm_agent_with_its_tool (test_config.AgentConfig.test_the_yaml_loads_as_an_llm_agent_with_its_tool) ... ok
test_a_builder_save_rewrites_root_agent_yaml (test_config.VisualBuilderSave.test_a_builder_save_rewrites_root_agent_yaml) ... ok
test_a_builder_upload_naming_code_outside_the_app_is_rejected (test_config.VisualBuilderSave.test_a_builder_upload_naming_code_outside_the_app_is_rejected) ... ok
test_a_builder_upload_with_an_args_key_is_rejected (test_config.VisualBuilderSave.test_a_builder_upload_with_an_args_key_is_rejected) ... ok
test_a_remote_client_cannot_reach_the_builder (test_config.VisualBuilderSave.test_a_remote_client_cannot_reach_the_builder) ... ok

----------------------------------------------------------------------
Ran 9 tests in 0.356s

OK

All 9 tests pass.

Read the test and recording scripts
test_config.pySource · pythonDownload
"""Check root_agent.yaml, its load failures and the Visual Builder save endpoint, offline.

Run: python -m unittest -v test_config.py
The model is replaced by its recorded responses; the YAML loader, Runner and dev server are real.
"""
import asyncio
import json
import logging
import tempfile
import unittest
import warnings
from pathlib import Path

warnings.filterwarnings("ignore")
logging.disable(logging.CRITICAL)

from fastapi.testclient import TestClient  # noqa: E402
from google.adk.agents.config_agent_utils import from_config  # noqa: E402
from google.adk.cli.fast_api import get_fast_api_app  # noqa: E402
from google.adk.models.base_llm import BaseLlm  # noqa: E402
from google.adk.models.llm_response import LlmResponse  # noqa: E402
from google.genai import types  # noqa: E402

import load  # noqa: E402
import record  # noqa: E402

HERE = Path(__file__).resolve().parent
RECORD = json.loads((HERE / "recorded-runs.json").read_text(encoding="utf-8"))


class ReplayLlm(BaseLlm):
    turns: list

    async def generate_content_async(self, llm_request, stream=False):
        yield LlmResponse(content=types.Content.model_validate(self.turns.pop(0)["content"]))


def load_text(text: str):
    with tempfile.TemporaryDirectory() as tmp:
        path = Path(tmp) / "root_agent.yaml"
        path.write_text(text, encoding="utf-8")
        return from_config(str(path))


class AgentConfig(unittest.TestCase):
    def setUp(self):
        warnings.simplefilter("ignore")

    def test_the_yaml_loads_as_an_llm_agent_with_its_tool(self):
        agent = load.load()
        self.assertEqual((type(agent).__name__, agent.name, agent.model), ("LlmAgent", "docs_assistant", RECORD["model"]))
        self.assertEqual([t.__name__ for t in agent.tools], ["read_page"])
        self.assertEqual(agent.generate_content_config.temperature, 0.2)

    def test_the_loaded_agent_runs_through_a_runner(self):
        agent = load.load().clone(update={"model": ReplayLlm(model="replay", turns=list(RECORD["load"]["model_turns"]))})
        events = asyncio.run(load.ask(agent, RECORD["question"]))
        self.assertEqual([load.describe(e) for e in events], RECORD["load"]["lines"])
        self.assertEqual(events[-1].content.parts[-1].text, RECORD["load"]["answer"])

    def test_a_misspelled_field_fails_validation(self):
        with self.assertRaisesRegex(Exception, "instructions\n  Extra inputs are not permitted"):
            load_text(record.BROKEN["misspelled_field"])

    def test_a_missing_tool_function_fails_to_load(self):
        with self.assertRaisesRegex(AttributeError, "has no attribute 'read_pages'"):
            load_text(record.BROKEN["missing_tool"])

    def test_the_loader_blocks_standard_library_references(self):
        with self.assertRaisesRegex(ValueError, "Blocked module reference: 'os.system'"):
            load_text(record.BROKEN["stdlib_tool"])


class VisualBuilderSave(unittest.TestCase):
    def setUp(self):
        warnings.simplefilter("ignore")
        # The builder writes into the app folder, so the test saves into this folder and restores the file.
        # (A temporary copy would not work here: docs_config also imports from this folder, and the
        # builder blocks an app whose name resolves to code outside its own folder.)
        self.yaml = HERE / "docs_config" / "root_agent.yaml"
        self.app = get_fast_api_app(agents_dir=str(HERE), web=True, session_service_uri="memory://", artifact_service_uri="memory://")
        self.local = TestClient(self.app, client=("127.0.0.1", 50000))

    def tearDown(self):
        self.yaml.write_text(record.YAML, encoding="utf-8")

    def save(self, name: str, http=None):
        files = [("files", ("docs_config/root_agent.yaml", record.UPLOADS[name].encode()))]
        return (http or self.local).post("/dev/apps/docs_config/builder/save", files=files)

    def test_a_builder_save_rewrites_root_agent_yaml(self):
        self.assertEqual(self.local.get("/dev/apps/docs_config/builder").text, record.YAML)
        self.assertEqual(self.save("edit").json(), True)
        self.assertIn("under 80 words", self.yaml.read_text(encoding="utf-8"))

    def test_a_builder_upload_with_an_args_key_is_rejected(self):
        r = self.save("args_key")
        self.assertEqual(r.status_code, 400)
        self.assertEqual(json.dumps(r.json(), separators=(",", ":")), RECORD["builder"]["args_key"].split("\nHTTP")[0])
        self.assertNotIn("args:", self.yaml.read_text(encoding="utf-8"))

    def test_a_builder_upload_naming_code_outside_the_app_is_rejected(self):
        r = self.save("outside_code")
        self.assertEqual(r.status_code, 400)
        self.assertIn("Blocked code reference 'subprocess.run'", r.json()["detail"])

    def test_a_remote_client_cannot_reach_the_builder(self):
        remote = TestClient(self.app, client=("10.0.0.7", 50000))
        r = self.save("edit", http=remote)
        self.assertEqual(r.status_code, 403)
        self.assertIn("ADK_ALLOW_REMOTE_AGENT_BUILDER", r.json()["detail"])
        self.assertNotIn("under 80 words", self.yaml.read_text(encoding="utf-8"))


if __name__ == "__main__":
    unittest.main()
record.pySource · pythonDownload
"""Record the YAML docs assistant: adk run, load.py, three load failures and the Visual Builder endpoints.

Run from this folder with GOOGLE_API_KEY set: python record.py
Writes run-output.txt and recorded-runs.json. The builder calls run against a temporary copy of
this folder, because a save rewrites root_agent.yaml.
"""
import asyncio
import json
import os
import shutil
import subprocess
import sys
import tempfile
import time
import warnings
from pathlib import Path

warnings.filterwarnings("ignore")

import httpx  # noqa: E402
from google.adk.agents.config_agent_utils import from_config  # noqa: E402

import load  # noqa: E402

HERE = Path(__file__).resolve().parent
ADK = str(Path(sys.executable).with_name("adk"))
QUESTION = "How do I roll back production?"
ENV = {**os.environ, "GOOGLE_GENAI_USE_ENTERPRISE": "0", "GOOGLE_GENAI_USE_VERTEXAI": "0", "PYTHONWARNINGS": "ignore"}
YAML = (HERE / "docs_config" / "root_agent.yaml").read_text(encoding="utf-8")
BROKEN = {
    "misspelled_field": YAML.replace("instruction:", "instructions:"),
    "missing_tool": YAML.replace("tools.read_page", "tools.read_pages"),
    "stdlib_tool": YAML.replace("docs_config.tools.read_page", "os.system"),
}
UPLOADS = {
    "edit": YAML.replace("under 100 words", "under 80 words"),
    "args_key": YAML.replace("  - name: docs_config.tools.read_page", "  - name: docs_config.tools.read_page\n    args:\n      page: tokens"),
    "outside_code": YAML.replace("docs_config.tools.read_page", "subprocess.run"),
}


def curl(*args: str) -> str:
    proc = subprocess.run(["curl", "-s", "-w", "\nHTTP %{http_code}", *args], capture_output=True, text=True, timeout=30)
    return proc.stdout


def main() -> None:
    rec, out = {"model": "gemini-3.8-flash", "question": QUESTION}, []
    with tempfile.TemporaryDirectory() as tmp:
        cmd = ["create", "--type=config", "--model", "gemini-3.8-flash", "--api_key", "your-gemini-api-key", "docs_config"]
        proc = subprocess.run([ADK, *cmd], cwd=tmp, env=ENV, capture_output=True, text=True, timeout=60)
        made = Path(tmp) / "docs_config"
        rec["adk_create"] = {"command": "adk " + " ".join(cmd), "files": sorted(p.name for p in made.iterdir()),
                             "root_agent.yaml": (made / "root_agent.yaml").read_text(encoding="utf-8")}
    out += [f"$ {rec['adk_create']['command']}", f"created docs_config/: {', '.join(rec['adk_create']['files'])}",
            rec["adk_create"]["root_agent.yaml"].rstrip(), ""]
    proc = subprocess.run([ADK, "run", "--in_memory", "docs_config", QUESTION], cwd=HERE, env=ENV, capture_output=True, text=True, timeout=300)
    rec["adk_run"] = [l for l in proc.stdout.splitlines() if l.strip() and not l.startswith(("Log setup", "To access latest log"))]
    out += [f"$ adk run --in_memory docs_config {QUESTION!r}", *rec["adk_run"], ""]

    agent = load.load()
    events = asyncio.run(load.ask(agent, QUESTION))
    rec["load"] = {"lines": [load.describe(e) for e in events], "answer": events[-1].content.parts[-1].text,
                   "model_turns": [{"content": {"role": "model", "parts": [
                       {k: v for k, v in p.model_dump(mode="json", exclude_none=True).items() if k != "thought_signature"}
                       for p in e.content.parts]}} for e in events if e.content and e.content.role == "model"]}
    cfg = agent.generate_content_config
    out += [f"$ python load.py {QUESTION!r}", f"LlmAgent {agent.name} model={agent.model} tools=['read_page']",
            f"temperature={cfg.temperature} thinking_level={cfg.thinking_config.thinking_level.value}",
            *rec["load"]["lines"], f"answer: {rec['load']['answer']}", ""]

    rec["load_failures"] = {}
    with tempfile.TemporaryDirectory() as tmp:
        for name, text in BROKEN.items():
            path = Path(tmp) / f"{name}.yaml"
            path.write_text(text, encoding="utf-8")
            try:
                from_config(str(path))
                rec["load_failures"][name] = "loaded"
            except Exception as error:  # the three failures raise three different types
                rec["load_failures"][name] = f"{type(error).__name__}: {error}"
        out += [f"from_config {name}: {msg.splitlines()[0]}" for name, msg in rec["load_failures"].items()] + [""]

        work = Path(tmp) / "agents"
        shutil.copytree(HERE / "docs_config", work / "docs_config", ignore=shutil.ignore_patterns("__pycache__", ".env"))
        shutil.copytree(HERE / "docs", work / "docs")
        for name, text in UPLOADS.items():
            (Path(tmp) / f"{name}.yaml").write_text(text, encoding="utf-8")
        server = subprocess.Popen([ADK, "web", "--port", "8766", "."], cwd=work, env=ENV, stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT)
        try:
            for _ in range(60):
                try:
                    httpx.get("http://127.0.0.1:8766/list-apps")
                    break
                except httpx.ConnectError:
                    time.sleep(0.5)
            base = "http://127.0.0.1:8766/dev/apps/docs_config/builder"
            got = curl(base)
            rec["builder"] = {"get": got.splitlines()[-1], "get_matches_file": got.rsplit("\nHTTP", 1)[0] == YAML}
            for name in UPLOADS:
                rec["builder"][name] = curl("-X", "POST", f"{base}/save", "-F", f"files=@{tmp}/{name}.yaml;filename=docs_config/root_agent.yaml")
            rec["builder"]["saved_has_edit"] = "under 80 words" in (work / "docs_config" / "root_agent.yaml").read_text(encoding="utf-8")
        finally:
            server.terminate()
            server.wait(timeout=20)
    out += ["$ adk web --port 8766 .", "$ curl -s http://127.0.0.1:8766/dev/apps/docs_config/builder", rec["builder"]["get"]]
    for name in UPLOADS:
        out += [f"$ curl -s -X POST {'http://127.0.0.1:8766/dev/apps/docs_config/builder'}/save -F 'files=@{name}.yaml;filename=docs_config/root_agent.yaml'",
                rec["builder"][name]]
    (HERE / "run-output.txt").write_text("\n".join(out).rstrip() + "\n", encoding="utf-8")
    (HERE / "recorded-runs.json").write_text(json.dumps(rec, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
    print("\n".join(out))


if __name__ == "__main__":
    main()

Check your understanding

A reviewed YAML edit changes the dotted Python tool reference. Why should the reviewer inspect more than YAML syntax?

Choose one answer, then check your reasoning.

Read the explanation

from_config resolves dotted references. A schema-valid configuration can select different imported behavior.

References

Official sources checked October 21, 2026, against google-adk 2.12.0. The loader and builder rules come from the installed google.adk.agents.config_agent_utils and google.adk.cli.dev_server modules.

Previously on the blog: Google ADK Visual Agent Builder: No-Code Agent Tutorial walks through the builder’s interface in ADK 1.x.

Written by Arjun Prabhulal.

Module 1

Run agents and manage sessions