What you’ll build
Every later lab in this series puts something in front of, around, or under an agent: a proxy, a policy, a ledger. This one builds the agent, and the loop you will use to change it.
The agent is small on purpose. timekeeper tells the time in a city. It has one local tool that maps a city to a
time zone, and one tool over MCP that reads the clock. What matters is the shape around it:
- one folder per agent, with the agent, its tools and its tests in separate files;
- typed settings read from the environment once, so nothing else reads ambient state;
- hermetic tests: a stub model, and a guard that fails any unit test that opens a network connection;
- a least-privilege service account for the deployed service.
You won’t type most of it. You’ll write those conventions down once as a project skill, and the coding agent, Antigravity, will follow them.
What you’ll do
- Install the toolchain: uv and Python 3.13, the gcloud CLI, the Antigravity CLI.
- Write a project skill that encodes the conventions.
- Have the agent plan and scaffold the ADK agent, and review the plan before it writes code.
- Run it locally, in the terminal and in the dev UI.
- Test it with a stub model, no network, no credentials.
- Add a local MCP server and give its tools to the agent.
- Deploy it to Cloud Run under its own service account and read the logs.
- Make one change and take it round the whole loop.
What you’ll need: macOS or Linux, a Google Cloud project with billing where you can create a service account and deploy to Cloud Run, and a Google account for Antigravity.
The finished code and the skill: Code on GitHub
Everything on this page was run on 2026-10-15 with google-adk 2.12.0, fastmcp 4.1.0, Python 3.13 and
gemini-3.5-flash, from an empty folder to a deployed and deleted service. The steps that need a person at the
keyboard (signing in, trusting a folder, approving a plan) were run through the CLI’s non-interactive mode instead;
each one is marked.
Set up the toolchain
You need four things. Each has an official installer; use it, then check the version.
uv and Python 3.13. uv manages the Python version, the virtual environment and the lock file, so you don’t install Python separately.
curl -LsSf https://astral.sh/uv/install.sh | sh
uv python install 3.13
The gcloud CLI. Install it from the Cloud SDK page, then sign in twice: once for the CLI, once for Application Default Credentials (ADC), which is what the ADK and the Google client libraries use on your laptop.
gcloud auth login
gcloud config set project YOUR_PROJECT_ID
gcloud auth application-default login
gcloud services enable aiplatform.googleapis.com run.googleapis.com \
cloudbuild.googleapis.com artifactregistry.googleapis.com
The Antigravity CLI (agy). Google’s
Antigravity CLI codelab covers the install, the
sign-in and the first run; follow its install and sign-in steps, then come back. In short, on macOS or Linux:
curl -fsSL https://antigravity.google/cli/install.sh | bash
agy --version
Run agy once in an empty folder to sign in. It asks whether you trust the folder: say yes only for folders you
created. /help lists the slash commands.
Permission mode: keep request-review. Open /config (or /settings) in agy and check Tool Permission. The
default, request-review, makes the agent stop and show you each command and file edit before it runs. That is the
right mode while you learn what the agent does. The settings live in ~/.gemini/antigravity-cli/settings.json.
agy --dangerously-skip-permissions approves every tool call with no prompt: the agent can run any shell command and
edit any file in the workspace without asking. Don’t use it on a machine or a folder you care about. If you want
fewer prompts later, proceed-in-sandbox is the middle ground.
Leave the model on the CLI’s default. Pinning a model name in settings or in an agent definition is the most common reason a setup that worked yesterday returns a 404 today.
Two more things you’ll use in this lab:
agy -p "..."runs one prompt non-interactively and prints the answer. No follow-up questions, so make the prompt complete.- Inside an
agysession, typing!at the start of the input switches to shell mode: the line runs as a shell command in your terminal, without the agent. Press!again orEscto switch back.
Now the project. Make the folder, pin Python, and add the dependencies:
mkdir agent-inner-loop && cd agent-inner-loop
git init
# pyproject.toml
[project]
name = "agent-inner-loop"
version = "0.1.0"
description = "An ADK agent with a local tool, an MCP tool, hermetic tests, and a Cloud Run deploy."
requires-python = ">=3.13,<3.14"
dependencies = [
"google-adk==2.12.0",
"fastmcp==4.1.0",
]
[dependency-groups]
dev = [
"pytest==9.1.1",
"pytest-asyncio==1.4.0",
]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
pythonpath = ["."]
filterwarnings = ["ignore:\\[EXPERIMENTAL\\]:UserWarning"]
[tool.uv]
package = false
echo 3.13 > .python-version
uv sync
uv sync creates .venv/, installs the exact versions, and writes uv.lock. Commit the lock: it is what the
container will install later.
Write down your conventions as a skill
A coding agent writes code the way it has seen code written. If you want it written your way, tell it, once, in a
place it reads. In Antigravity that place is a skill: a folder with a SKILL.md file. Project skills live in
.agents/skills/<name>/ at the root of the project; global ones in ~/.gemini/config/skills/.
Skills load in stages. At startup the agent reads only each skill’s name and description. It reads the body when a
request matches the description. That’s why the description says both what the skill covers and when to use
it, and why the body stays short: it costs context every time it loads.
Create .agents/skills/agent-recipe/SKILL.md:
---
name: agent-recipe
description: House conventions for building a Google ADK agent in Python - one folder per agent, agent / tools / tests split, typed settings read from the environment, hermetic tests with a stub model, an optional local MCP server over stdio, and a Cloud Run deploy under its own least-privilege service account. Use when creating, extending, testing or deploying an ADK agent in this repo, or when asked to scaffold a new agent.
---
# Agent recipe
Build every agent in this repo the same way. A reviewer should find the same files in the same places,
and `uv run pytest` must pass with no network and no credentials.
## Layout (one folder per agent)
```
<agent>/ # the ADK app: `adk run <agent>`, `adk web .`
__init__.py # exactly: from . import agent
agent.py # build_agent(settings, model=None) -> Agent; root_agent = build_agent(Settings.from_env())
config.py # @dataclass(frozen=True) Settings + Settings.from_env()
tools.py # plain functions; docstring = what the model reads
mcpserver/server.py # optional FastMCP server, stdio, NO __init__.py
tests/ # conftest.py (stub model + no-network guard), test_tools, test_agent, test_mcpserver
pyproject.toml + uv.lock # exact pins; dev group holds pytest
Dockerfile # installs from uv.lock, runs as a non-root user
```
## Rules
1. **Settings are typed and read once.** Only `Settings.from_env()` touches `os.environ`. Everything else takes a
`Settings` argument. No module-level clients, no reading env inside tools.
2. **`build_agent` takes the model as a parameter** so a test can pass a stub. `root_agent` is the only place the real
model string is used.
3. **Tools are plain functions** with typed arguments, a docstring that says what they return, and a
`{"status": "success" | "error", ...}` dict. An unknown input returns an error; it never guesses.
4. **Unit tests never call a real model.** Use the `StubLlm` in `tests/conftest.py` (scripted replies) and keep the
autouse `no_network` fixture. A test that needs the real model is an eval, not a unit test; keep it out of `tests/`.
5. **Every tool gets a direct unit test**, and the agent gets one loop test: stub calls the tool, assert the real tool
result reached the model's next request.
6. **Deploy with a dedicated service account** holding only `roles/aiplatform.user`. Never the default compute SA.
Deploy `--no-allow-unauthenticated`.
## Workflow
1. Plan first (`/plan`). The plan names the files above; reject a plan that puts tools in `agent.py` or reads env in
tools.
2. Write `tools.py` + `test_tools.py`, run `uv run pytest -q`.
3. Write `config.py`, `agent.py`, `test_agent.py`, run the tests again.
4. Only then run against the real model: `uv run adk run <agent>`.
## Gotchas
- **Gemini 3.x models answer only on the global endpoint.** With `GOOGLE_CLOUD_LOCATION=us-central1` they return
`404 ... Publisher model ... was not found`. Set `GOOGLE_CLOUD_LOCATION=global`. The Cloud Run region is separate.
- **`adk web .` / `adk api_server .` list every folder with an `__init__.py` as an app.** Keep `__init__.py` out of
`mcpserver/` and `tests/`; start the server with `python -m mcpserver.server` from the project root.
- **The stdio MCP server is a child process.** Start it with `sys.executable` (the same venv), and only print to
stderr: anything on stdout corrupts the protocol. FastMCP's banner goes away with `mcp.run(show_banner=False)`.
- **Build the image from the lock.** `uv export --frozen --no-dev` then `uv pip install --system`. Installing from
`pyproject.toml` re-resolves ranges at build time and ships versions you never tested.
- **`gcloud run deploy --source` uploads the folder.** Keep `.venv/`, `.env` and `.adk/` out with `.gcloudignore`
(`adk run` writes a local session database to `<agent>/.adk/`).
- **A new service account takes a few seconds to exist for IAM.** If the role binding right after
`gcloud iam service-accounts create` says the account does not exist, wait and retry.
A few things about how it’s written:
- The name matches the folder (
agent-recipe), lowercase with hyphens. - Rules come with the reason where the reason isn’t obvious, and the layout is shown, not described.
- The gotchas are real. Each one is something that went wrong while this lab was being built: the model 404 in a region, the extra app in the dev UI, the session database that ended up in an upload. A skill written from general knowledge says “handle errors appropriately”. A skill written from your own mistakes saves the next person the same afternoon.
Check that the agent picks it up. This runs non-interactively:
agy -p "Read .agents/skills/agent-recipe/SKILL.md and answer in at most 5 lines: which skill applies to adding a new tool to the timekeeper agent, and which files would you touch? Do not edit anything."
Applicable Skill: agent-recipe (complemented by google-agents-cli-adk-code for ADK patterns).
Files to touch:
1. timekeeper/tools.py: implement the new tool function per house conventions.
2. timekeeper/agent.py: register the new tool in build_agent() (and timekeeper/config.py if new settings are required).
3. tests/test_tools.py and tests/test_agent.py: add tool unit tests and verify the stubbed agent loop test.
(The output above is trimmed of file links. The second skill it names was installed globally on the test machine; yours may list different ones.)
Plan, then scaffold
Start agy in the project folder and ask for a plan first. /plan makes the agent write an implementation plan
artifact and wait for you before it changes anything:
/plan Using the agent-recipe skill, scaffold an ADK agent named timekeeper with one local tool,
lookup_timezone(city), that maps a few city names to IANA time zones and returns an error for an
unknown city. Write the agent package and the tests (stub model, no network). Do not add an MCP
server yet. Run uv run pytest -q and report the result.
The /plan review is interactive. For this lab the same prompt, without /plan, was run through agy -p on a fresh
folder holding only pyproject.toml, .python-version and the skill. It wrote the seven files below and reported
4 passed; running uv run pytest -q again by hand gave the same result.
Review the plan before you approve it. /artifact lists the artifacts; open the plan and check it against the
skill:
- Are the files the ones in the skill’s layout:
timekeeper/{__init__,agent,config,tools}.pyandtests/{conftest,test_tools,test_agent}.py? - Does
build_agenttake the model as a parameter? Does onlyconfig.pyread the environment? - Do the tests use a stub model, and is there a no-network guard?
If the plan puts the tool inside agent.py or reads os.environ inside a tool, say so and ask for a new plan.
Correcting a plan costs a sentence. Correcting code costs a review.
With request-review on, approve each file write and the uv run pytest call as it comes. At the end the agent
usually writes a walkthrough artifact: what changed and how to test it.
Compare with the reference. The generated code will differ in the details: names, docstrings, how the stub model is built. What should match is the shape. Here is the reference for this step; the repo has the finished version.
# timekeeper/tools.py
"""Local tools: plain functions, no I/O, easy to test."""
CITY_ZONES = {
"london": "Europe/London",
"new york": "America/New_York",
"san francisco": "America/Los_Angeles",
"singapore": "Asia/Singapore",
"sydney": "Australia/Sydney",
"tokyo": "Asia/Tokyo",
}
def lookup_timezone(city: str) -> dict:
"""Return the IANA time zone for a city.
Args:
city: A city name, for example "Tokyo".
Returns:
{"status": "success", "city": ..., "timezone": ...} or
{"status": "error", "error_message": ...} for a city we don't know.
"""
zone = CITY_ZONES.get(city.strip().lower())
if zone is None:
return {"status": "error", "error_message": f"No time zone on file for {city!r}."}
return {"status": "success", "city": city, "timezone": zone}
# timekeeper/config.py
"""Settings, read from the environment once, at the edge."""
import os
from dataclasses import dataclass
@dataclass(frozen=True)
class Settings:
model: str = "gemini-3.5-flash"
@classmethod
def from_env(cls) -> "Settings":
return cls(model=os.environ.get("TIMEKEEPER_MODEL", cls.model))
# timekeeper/agent.py
"""The agent: built from Settings, so a test can build it with a stub model."""
from google.adk.agents import Agent
from google.adk.models import BaseLlm
from .config import Settings
from .tools import lookup_timezone
INSTRUCTION = """You tell people which time zone a city is in.
Call lookup_timezone with the city. If the city is unknown, say so. Never guess."""
def build_agent(settings: Settings, model: str | BaseLlm | None = None) -> Agent:
return Agent(
name="timekeeper",
model=model or settings.model,
description="Tells the time zone of a city.",
instruction=INSTRUCTION,
tools=[lookup_timezone],
)
root_agent = build_agent(Settings.from_env())
# timekeeper/__init__.py
from . import agent
In the run above, the generated agent.py typed the model parameter as Any, and the generated stub kept its
replies as a list of full LlmResponse objects. Both are fine. Neither changes the shape, which is what the skill is
for.
Run it locally
The ADK finds the model through three environment variables. Put them in .env at the project root (the ADK reads it
when it loads the agent) and keep .env out of git:
GOOGLE_GENAI_USE_VERTEXAI=1
GOOGLE_CLOUD_PROJECT=your-project-id
# Gemini 3.x models are served from the global endpoint, not a region.
GOOGLE_CLOUD_LOCATION=global
The Gemini 3.x models are served from the global endpoint. With GOOGLE_CLOUD_LOCATION=us-central1 the first call
fails with 404 NOT_FOUND ... Publisher model ... was not found or your project does not have access to it. The model
location and the Cloud Run region are separate settings; you’ll still deploy to us-central1.
Chat with the agent in the terminal:
uv run adk run timekeeper
[user]: Which time zone is Tokyo in?
[timekeeper]: Tokyo is in the "Asia/Tokyo" time zone.
Type exit to leave. For the dev UI, which shows every event, tool call and model request:
uv run adk web .
Open http://127.0.0.1:8000, pick timekeeper, and ask about Atlantis. The events panel shows the call to
lookup_timezone and the error it returned, and the answer says the city is unknown instead of guessing.
The dev UI itself was not clicked through for this lab; what was checked is that adk web . serves it (HTTP 200) and
lists exactly one app, timekeeper. The same conversation was run through adk run and through the HTTP API.
adk run keeps a local session database in timekeeper/.adk/. Add .adk/ to .gitignore now, before it ends up in
a commit or, later, in a deploy upload.
Test it without a model
The tests the agent wrote should not need a model, credentials, or the network. Look at what makes that true:
# tests/conftest.py
"""A stub model: replays scripted replies, records what the agent sent. No network."""
import socket
from collections.abc import AsyncGenerator
import pytest
from google.adk.models import BaseLlm, LlmRequest, LlmResponse
from google.genai import types
class StubLlm(BaseLlm):
model: str = "stub"
replies: list[types.Part] = []
requests: list[LlmRequest] = []
async def generate_content_async(
self, llm_request: LlmRequest, stream: bool = False
) -> AsyncGenerator[LlmResponse, None]:
self.requests.append(llm_request)
part = self.replies.pop(0)
yield LlmResponse(content=types.Content(role="model", parts=[part]))
def call(name: str, **args) -> types.Part:
return types.Part(function_call=types.FunctionCall(name=name, args=args))
def say(text: str) -> types.Part:
return types.Part(text=text)
@pytest.fixture
def stub():
return StubLlm(replies=[], requests=[])
@pytest.fixture(autouse=True)
def no_network(monkeypatch):
"""A unit test that reaches for the network fails loudly instead of calling a real model."""
def refuse(*args, **kwargs):
raise RuntimeError("unit tests must not open network connections")
monkeypatch.setattr(socket.socket, "connect", refuse)
StubLlm replaces the model. It returns scripted replies, a function call and then text, and records every request
the agent sends. no_network makes any socket connect raise, so a unit test that reaches for a real model fails
instead of quietly spending money and time.
# tests/test_tools.py
from timekeeper.tools import lookup_timezone
def test_known_city_any_case():
assert lookup_timezone(" Tokyo ") == {"status": "success", "city": " Tokyo ", "timezone": "Asia/Tokyo"}
def test_unknown_city_is_an_error_not_a_guess():
result = lookup_timezone("Atlantis")
assert result["status"] == "error"
assert "Atlantis" in result["error_message"]
# tests/test_agent.py
"""The agent loop, end to end, against the stub model: the tool really runs, the model never does."""
from google.adk.runners import InMemoryRunner
from google.genai import types
from conftest import call, say
from timekeeper.agent import build_agent
from timekeeper.config import Settings
async def ask(agent, text: str) -> list:
runner = InMemoryRunner(agent=agent, app_name="test")
session = await runner.session_service.create_session(app_name="test", user_id="u")
message = types.Content(role="user", parts=[types.Part(text=text)])
return [e async for e in runner.run_async(user_id="u", session_id=session.id, new_message=message)]
async def test_agent_calls_the_tool_and_answers(stub):
stub.replies = [call("lookup_timezone", city="Tokyo"), say("Tokyo is on Asia/Tokyo.")]
agent = build_agent(Settings(), model=stub)
events = await ask(agent, "What time zone is Tokyo in?")
responses = [p.function_response for e in events for p in e.content.parts if p.function_response]
assert responses[0].response == {"status": "success", "city": "Tokyo", "timezone": "Asia/Tokyo"}
assert events[-1].content.parts[0].text == "Tokyo is on Asia/Tokyo."
# The second model call saw the tool's answer.
assert "Asia/Tokyo" in str(stub.requests[1].contents[-1])
def test_model_comes_from_the_environment(monkeypatch):
monkeypatch.setenv("TIMEKEEPER_MODEL", "some-other-model")
assert Settings.from_env().model == "some-other-model"
The loop test runs the real ADK runner and the real tool, with the stub where the model would be. The last assertion is the one that matters: the tool’s answer reached the model’s next request.
Run the tests from inside agy without leaving the session: type ! to switch to shell mode, then
uv run pytest -q. (Or run it in another terminal.)
uv run pytest -q
.... [100%]
4 passed in 0.35s
Now see the guard bite. Add a test that builds the agent with the real model, build_agent(Settings()) with no
model=, and run it. It fails before any request leaves the machine:
E RuntimeError: unit tests must not open network connections
Delete that test. A check against the real model is an evaluation, and it belongs in its own suite that you run on purpose.
Add a tool over MCP
The clock moves to an MCP server. MCP (Model Context Protocol) is how an agent uses tools that live in another process, or on another machine, without importing their code. Here the server is local and talks over stdio: the agent starts it as a child process and exchanges messages over its stdin and stdout.
Ask the agent for it, or write it yourself. The server, with echo and time:
# mcpserver/server.py
"""A local MCP server over stdio: echo and time."""
import datetime
import zoneinfo
from fastmcp import FastMCP
mcp = FastMCP("clock")
@mcp.tool()
def echo(message: str) -> str:
"""Echo the message back."""
return message
@mcp.tool()
def time(timezone: str = "UTC") -> str:
"""Return the current time in an IANA time zone, ISO 8601. Unknown zones fall back to UTC."""
try:
tz = zoneinfo.ZoneInfo(timezone)
except (zoneinfo.ZoneInfoNotFoundError, ValueError):
tz = datetime.timezone.utc
return datetime.datetime.now(tz).isoformat(timespec="seconds")
if __name__ == "__main__":
mcp.run(show_banner=False) # stdio: the agent starts this process and talks over stdin/stdout
There is no mcpserver/__init__.py, on purpose: adk web . would list the folder as a second app. And
show_banner=False matters: on stdio, anything the server prints to stdout breaks the protocol.
Try the server on its own with the FastMCP CLI:
uv run fastmcp list mcpserver/server.py
uv run fastmcp call mcpserver/server.py time timezone=Asia/Tokyo
Tools (2)
echo(message: str) -> dict
Echo the message back.
time(timezone: str = 'UTC') -> dict
Return the current time in an IANA time zone, ISO 8601. Unknown zones fall
back to UTC.
{
"result": "2026-10-16T02:28:35+09:00"
}
Now wire it in. Settings gain one field, the command that starts the server, on by default and off with
TIMEKEEPER_USE_MCP=0:
# timekeeper/config.py
"""Settings, read from the environment once, at the edge."""
import os
import sys
from dataclasses import dataclass
# The local MCP server, started as a child process from the same virtual environment.
MCP_SERVER = (sys.executable, "-m", "mcpserver.server")
@dataclass(frozen=True)
class Settings:
model: str = "gemini-3.5-flash"
mcp_command: tuple[str, ...] = () # empty = no MCP tools
@classmethod
def from_env(cls) -> "Settings":
use_mcp = os.environ.get("TIMEKEEPER_USE_MCP", "1") != "0"
return cls(
model=os.environ.get("TIMEKEEPER_MODEL", cls.model),
mcp_command=MCP_SERVER if use_mcp else (),
)
The agent adds an McpToolset when the command is set, and the instruction now tells it to read the clock:
# timekeeper/agent.py
"""The agent: built from Settings, so a test can build it with a stub model."""
from google.adk.agents import Agent
from google.adk.models import BaseLlm
from google.adk.tools.mcp_tool import McpToolset, StdioConnectionParams
from mcp import StdioServerParameters
from .config import Settings
from .tools import lookup_timezone
INSTRUCTION = """You tell people the time in cities.
1. Call lookup_timezone with the city.
2. If it succeeds, call the time tool with that time zone and answer with the local time.
3. If the city is unknown, say so. Never guess a time."""
def build_agent(settings: Settings, model: str | BaseLlm | None = None) -> Agent:
tools: list = [lookup_timezone]
if settings.mcp_command:
command, *args = settings.mcp_command
tools.append(
McpToolset(
connection_params=StdioConnectionParams(
server_params=StdioServerParameters(command=command, args=args),
timeout=10,
),
tool_filter=["time", "echo"],
)
)
return Agent(
name="timekeeper",
model=model or settings.model,
description="Tells the current time in a city.",
instruction=INSTRUCTION,
tools=tools,
)
root_agent = build_agent(Settings.from_env())
Settings() still defaults to no MCP, so the loop test from the last step doesn’t start a server. Add a test for the
switch to tests/test_agent.py (and import MCP_SERVER from timekeeper.config), and tests for the server, in
memory, with no subprocess:
# tests/test_agent.py
def test_mcp_is_on_by_default_and_can_be_turned_off(monkeypatch):
monkeypatch.delenv("TIMEKEEPER_USE_MCP", raising=False)
assert Settings.from_env().mcp_command == MCP_SERVER
monkeypatch.setenv("TIMEKEEPER_USE_MCP", "0")
assert Settings.from_env().mcp_command == ()
# tests/test_mcpserver.py
"""The MCP server, in memory: no subprocess, no network."""
from fastmcp import Client
from mcpserver.server import mcp
async def test_lists_both_tools():
async with Client(mcp) as client:
names = {t.name for t in await client.list_tools()}
assert names == {"echo", "time"}
async def test_time_in_a_zone():
async with Client(mcp) as client:
result = await client.call_tool("time", {"timezone": "Asia/Tokyo"})
assert result.data.endswith("+09:00")
uv run pytest -q
uv run adk run timekeeper
....... [100%]
7 passed in 1.50s
[user]: What time is it in Singapore?
[timekeeper]: The current time in Singapore is 1:28 AM on Friday, October 16, 2026.
In adk web . you’ll see two tool calls for that answer: lookup_timezone (local) and then time (MCP).
Ship it to Cloud Run
The container installs from uv.lock, not from the version ranges, so the image runs what you tested. It runs as a
non-root user, and adk api_server serves the agent over HTTP:
# Dockerfile
FROM python:3.13-slim
COPY --from=ghcr.io/astral-sh/uv:0.12.20 /uv /usr/local/bin/uv
WORKDIR /app
# Install exactly what uv.lock pins (no re-resolve at build time), without the dev group.
COPY pyproject.toml uv.lock ./
RUN uv export --frozen --no-dev --no-emit-project -o /tmp/requirements.txt \
&& uv pip install --system --no-cache -r /tmp/requirements.txt
COPY timekeeper/ timekeeper/
COPY mcpserver/ mcpserver/
RUN useradd --create-home app
USER app
ENV GOOGLE_GENAI_USE_VERTEXAI=1 PYTHONUNBUFFERED=1
CMD ["sh", "-c", "adk api_server --host 0.0.0.0 --port ${PORT:-8080} ."]
.venv/
.env
__pycache__/
.pytest_cache/
tests/
.agents/
.adk/
gcloud run deploy --source uploads the folder; .gcloudignore keeps the virtual environment, .env, the local
session database, the tests and the skill out of it.
Give the service its own identity. By default a Cloud Run service runs as the project’s default compute service account, which on many projects holds the broad Editor role. This agent needs one thing: to call Vertex AI.
PROJECT=$(gcloud config get-value project)
gcloud iam service-accounts create timekeeper-run --display-name="timekeeper on Cloud Run"
gcloud projects add-iam-policy-binding $PROJECT \
--member="serviceAccount:timekeeper-run@$PROJECT.iam.gserviceaccount.com" \
--role="roles/aiplatform.user" --condition=None
If the binding fails with Service account ... does not exist right after the create, wait a few seconds and run it
again. A new service account takes a moment to become visible to IAM.
Deploy. --no-allow-unauthenticated means only callers with roles/run.invoker (you, as project owner) can reach it:
gcloud run deploy timekeeper --source . --region us-central1 \
--service-account timekeeper-run@$PROJECT.iam.gserviceaccount.com \
--no-allow-unauthenticated \
--set-env-vars GOOGLE_CLOUD_PROJECT=$PROJECT,GOOGLE_CLOUD_LOCATION=global
Building using Dockerfile and deploying container to Cloud Run service [timekeeper] in project [...] region [us-central1]
...
Service [timekeeper] revision [timekeeper-00001-...] has been deployed and is serving 100 percent of traffic.
The build and deploy took about a minute and a half. Call it through the authenticated proxy, which adds your identity token to each request:
gcloud run services proxy timekeeper --region us-central1 --port 8080
In a second terminal, create a session and send a message:
curl -s -X POST localhost:8080/apps/timekeeper/users/u/sessions/s1 \
-H 'content-type: application/json' -d '{}'
curl -s -X POST localhost:8080/run -H 'content-type: application/json' -d '{
"app_name": "timekeeper", "user_id": "u", "session_id": "s1",
"new_message": {"role": "user", "parts": [{"text": "What time is it in Tokyo?"}]}}'
The reply is a list of events. Picked out of it:
call lookup_timezone {'city': 'Tokyo'}
call time {'timezone': 'Asia/Tokyo'}
text The current time in Tokyo is 2:37 AM on October 16, 2026.
The MCP server runs inside the container, started by the agent over stdio exactly as on your laptop. Without the
proxy, the service URL answers 403.
Read the logs:
gcloud run services logs read timekeeper --region us-central1 --limit 20
INFO - agent_loader.py:189 - Found root_agent in timekeeper.agent
INFO Starting MCP server 'clock' with transport 'stdio'
INFO - google_llm.py:263 - Sending out request, model: gemini-3.5-flash, backend: GoogleLLMVariant.VERTEX_AI, stream: False
INFO - _client.py:1740 - HTTP Request: POST https://aiplatform.googleapis.com/v1beta1/projects/.../locations/global/publishers/google/models/gemini-3.5-flash...
INFO - google_llm.py:360 - Response received from the model.
The service is protected by IAM, not by the agent. adk api_server itself has no authentication, and its own help
text says so. Keep --no-allow-unauthenticated on.
Close the loop
One change, all the way round. Users ask about Paris, and the agent says it doesn’t know the city.
Test first. Add to tests/test_tools.py:
# tests/test_tools.py
def test_paris():
assert lookup_timezone("Paris")["timezone"] == "Europe/Paris"
E KeyError: 'timezone'
1 failed, 7 passed in 1.04s
Fix. Add "paris": "Europe/Paris", to CITY_ZONES in timekeeper/tools.py, and run the tests again:
........ [100%]
8 passed in 0.68s
Redeploy with the same gcloud run deploy command. It builds a new revision (timekeeper-00002-...) and moves
traffic to it. Through the proxy:
call lookup_timezone {'city': 'Paris'}
call time {'timezone': 'Europe/Paris'}
text The current time in Paris is 7:39 PM (19:39) on Thursday, October 15, 2026.
That’s the inner loop: change, test without a model, run with one, deploy, read the logs. With agy, ask for the
change (“add Paris, test first”) and approve each step; the skill keeps the shape.
Clean up when you’re done, so nothing keeps running under that identity:
gcloud run services delete timekeeper --region us-central1
gcloud projects remove-iam-policy-binding $PROJECT \
--member="serviceAccount:timekeeper-run@$PROJECT.iam.gserviceaccount.com" \
--role="roles/aiplatform.user" --condition=None
gcloud iam service-accounts delete timekeeper-run@$PROJECT.iam.gserviceaccount.com
--source deploys also leave the image in the cloud-run-source-deploy Artifact Registry repository; delete it there
if you don’t need it.
Next: Envoy and a Stub ext_proc That Logs Every Request puts a proxy in front of a service and hands every request to a processor you write. It’s the first step toward deciding what may reach an agent like this one.
An agent you can change safely is one you can test without it.