Build your own coding agent — in one Python file, with three tools and one rule that makes it safe to use: it cannot change a single line of code until a human approves the change. In this tutorial we build the agent on the Gemini API's function-calling loop, point it at a real project with a real failing test, and watch it find the bug, propose the fix, stop at the approval gate, and — only after a yes — apply it and prove the suite green. Then it prints its own bill. Every step has its own real screenshot, taken from the run you are reading about.
Versions used in this tutorial
- Python 3.12
- google-genai 3.0.0 (the Gemini SDK, pinned) and pytest 9.2.0 (pinned)
- Model: gemini-3.8-flash — rate card verified on Google's official pricing page on 2026-11-07: $0.75 per 1M input tokens, $3.75 per 1M output tokens (Standard tier, in effect through Dec 31, 2026); a free tier exists
- Companion code: topic-09-coding-agent in the Visual Studio Learn code repo
Step 0 — Setup from zero
Nothing here assumes an existing project. You need Python 3.12, an isolated environment, the two pinned packages, and a free API key.
- Create a venv — a virtual environment, an isolated folder holding this project's packages so they never collide with the rest of your machine — and activate it:
python3 -m venv .venv
source .venv/bin/activate # on Windows: .venv\Scripts\activate
- Install the pinned packages (the pins matter: this tutorial's code was verified against exactly these versions):
pip install google-genai==3.0.0 pytest==9.2.0
- In Google AI Studio, create a free API key, and put it in an environment variable — a setting your terminal session carries — named
GEMINI_API_KEY:
export GEMINI_API_KEY=your-key-here
The key goes in the environment, never in the code. Code gets committed, shared and screenshotted; keys must not travel with it.
One warning about free keys, from Google's own pricing page: on the free tier, content you send may be used to improve Google's products. Learn on demo code, like this tutorial does — not on anything private.
Step 0: Python 3.12, the venv active, and the two pinned installs in place. The key is set in the environment — its value never appears on screen or in code.
Step 1 — The patient: the buggy cart and its pytest suite
The demo project (the demo/ folder in the companion code) is a tiny shopping cart. One bug lives in it on purpose, in Cart.add():
def add(self, name, unit_price_cents, quantity=1):
# BUG (planted): this replaces the quantity instead of adding to it.
self._items[name] = [unit_price_cents, quantity]
Add the same item twice and the quantity is replaced, not accumulated. Around the cart sits a suite of four tests. Run it before the agent ever sees the project:
cd demo
python -m pytest -q
# 1 failed, 3 passed
The failing test — test_add_same_item_twice_accumulates — adds two notebooks, then three more, and expects five; the cart says three. The other three tests — a new item, a mixed-cart total, a removal — all pass, so the bug is narrow: one method, one wrong idea about what "add" means. 1 failed, 3 passed is the contract for everything below: the agent's job is to turn that line into 4 passed without touching a test.
Step 1, before: the untouched suite — exactly 1 failed, 3 passed. This is the result the agent has to change.
The patient: cart.py's add() replaces the quantity; the test beside it expects accumulation — 2 + 3 = 5.
Step 2 — The agent skeleton: client, model, task
The whole agent is one file, agent.py. Its head is the entire design in miniature:
import os
import subprocess
import sys
from pathlib import Path
from google import genai
from google.genai import types
Every import the file needs sits at the top — os for the key check, subprocess and sys for the test tool, Path for the confinement check, and the SDK itself. Then the constants:
PROJECT_DIR = Path(__file__).parent / "demo"
MODEL = "gemini-3.8-flash"
MAX_STEPS = 12
TASK = (
"Run the test suite in this project. If a test fails, read the test and "
"the code it tests, find the bug, and fix the source file so the whole "
"suite passes. Do not edit the tests. When every test passes, stop and "
"summarise what the bug was and what you changed."
)
Three ideas to notice. The model is gemini-3.8-flash, the current Flash model — fast, inexpensive, and it supports function calling: the mechanism this whole tutorial is built on. The model never runs code itself; it asks our program to run named functions — tools — and reads their results. The task is plain English with two hard edges: fix the source, never the tests. And MAX_STEPS is a hard cap of twelve model turns, so a confused model costs twelve turns — never an infinite loop.
Step 2: the skeleton — client, model, step cap, and the task written as instructions a careful junior would understand.
Step 3 — Tool 1: read_file, confined
An agent is only its tools, and ours has exactly three. The first reads a file and hands its text to the model:
def confine(path_text):
"""Resolve a model-supplied path and refuse anything outside the project."""
path = (PROJECT_DIR / path_text).resolve()
if not path.is_relative_to(PROJECT_DIR.resolve()):
return None
return path
def tool_read_file(path):
target = confine(path)
if target is None or not target.is_file():
return {"error": f"cannot read {path}"}
return {"content": target.read_text()[:8000]}
The guard is the lesson: every path the model supplies is resolved and checked, and anything landing outside the demo project folder is refused. The model can ask for anything; the code decides what it may touch. (We tested the escape: asking for a file two folders up — outside the project — comes back as an error, not a file.)
Step 3: confine() — every model-supplied path is resolved and refused if it escapes the project folder.
Step 4 — Tool 2: run_tests
def tool_run_tests():
proc = subprocess.run(
[sys.executable, "-m", "pytest", "-q"],
cwd=PROJECT_DIR, capture_output=True, text=True, timeout=120,
)
return {"exit_code": proc.returncode, "output": (proc.stdout + proc.stderr)[-3000:]}
The second tool runs the suite with a fixed command — the model cannot alter it — inside the project folder, and returns the tail of the output, where pytest puts its summary. Tools 1 and 2 are read-only: watching and reading. Safe. The third tool is neither.
Step 4: run_tests — a fixed command the model can invoke but never modify.
Step 5 — Tool 3: write_file and the approval gate
This is the dangerous tool, so it is built differently. The model never writes. It proposes.
def tool_write_file(path, content):
"""THE GATE: the model only ever PROPOSES. A human decides."""
target = confine(path)
if target is None:
return {"error": f"refused: {path} is outside the project folder"}
print(f"\n--- The agent wants to write {path} ---")
print(content)
answer = input("Apply this change? [y/N] ").strip().lower()
if answer != "y":
print("--- Denied by the human. The file was NOT changed. ---")
return {"result": "DENIED by the human approver. Do not retry this write; "
"explain in your summary what you wanted to change."}
target.write_text(content)
print("--- Approved and written. ---")
return {"result": f"{path} written"}
The full proposed content is printed in the terminal, and the program stops at a yes/no prompt. Answer anything but y and the file stays byte-for-byte untouched — and the model is told, in plain words, that a human refused and it must not retry. Only a yes writes. Every other tool in this agent can run all day; this one needs you.
Step 5, in code: the model proposes; the input() line is the gate — nothing writes before it returns "y".
Step 5, live: the gate fires during the real run. The proposed file is on screen; the file on disk has not changed yet.
Step 6 — The loop: manual function calling with the Gemini API
Around the tools sits the loop. Each tool is declared to the model as a FunctionDeclaration — a name, a plain-English description, and a schema of its arguments — and that declaration is all the model ever sees of the code:
DECLARATIONS = [
types.FunctionDeclaration(
name="read_file", description="Read a file inside the project folder.",
parameters=types.Schema(type="OBJECT", properties={
"path": types.Schema(type="STRING", description="File name, e.g. cart.py")},
required=["path"])),
# … run_tests and write_file are declared the same way …
]
One small table ties the names to the functions — the loop looks each call up by name:
TOOLS = {"read_file": tool_read_file, "run_tests": tool_run_tests,
"write_file": tool_write_file}
And here is the loop itself — the whole of main(), condensed only in its token-counting lines. Everything a working agent needs is on this screen:
def main():
if not os.environ.get("GEMINI_API_KEY"):
sys.exit("Set GEMINI_API_KEY first (free key from Google AI Studio).")
client = genai.Client()
config = types.GenerateContentConfig(
tools=[types.Tool(function_declarations=DECLARATIONS)])
contents = [types.Content(role="user", parts=[types.Part(text=TASK)])]
for step in range(1, MAX_STEPS + 1):
response = client.models.generate_content(
model=MODEL, contents=contents, config=config)
# … count response.usage_metadata tokens here (Step 8) …
model_content = response.candidates[0].content
contents.append(model_content)
calls = [p.function_call for p in model_content.parts if p.function_call]
if not calls: # the model answered in words — the task is done
print("\nAGENT:", response.text)
break
replies = []
for call in calls:
print(f"[step {step}] tool: {call.name} {dict(call.args or {})}")
result = TOOLS[call.name](**dict(call.args or {}))
replies.append(types.Part.from_function_response(
name=call.name, response=result))
contents.append(types.Content(role="user", parts=replies))
else:
print(f"\nStopped: step cap ({MAX_STEPS}) reached.")
Read it as a conversation with a bouncer. The task and the declarations go in; the model's answer comes back as either words (job done — break) or function calls. Each call is dispatched through the TOOLS dictionary to the real Python function — write_file included, gate and all — and every result goes back to the model in a Content with role "user" — the manual-loop pattern this API accepts. (Worth knowing: some write-ups send function results with a "tool" role; on the live run for this post that came straight back as a 400 error, and "user" is what worked. The code above is the code that ran.) The for…else is the step cap doing its job: exhaust twelve steps without a words-only answer and the loop stops itself. This manual pattern is deliberate: automatic function calling hides exactly the moving parts a builder needs to see — and the SDK's own documentation is changing how automatic calling is invoked in the next major version, while the manual loop this tutorial teaches stays as documented.
Step 6: the loop — send, dispatch, return the result, repeat — capped at twelve steps.
Step 7 — The run
Now the real run, on the machine this post was written on, unedited.
The agent's first move is run_tests — it sees the failure itself: 1 failed, 3 passed. Then it reads both files: the failing test, then cart.py. It names the bug in one sentence: add() replaces the quantity instead of accumulating. Then the proposal arrives — and the run stops cold at the gate. The proposed file is on screen; nothing on disk has changed. The proposed file changes only the add method. Approve — the write lands — and the agent runs the suite again on its own: 4 passed. Found, diagnosed, proposed, approved, fixed, proven.
Step 7: the run's first half — the agent finds the failure, reads both files, and diagnoses the bug itself.
Step 7, payoff: after the approval, the agent re-runs the suite on its own — 4 passed — and summarises the fix.
Step 8 — The bill
An agent you cannot price is an agent you cannot plan around, so the agent counts its own tokens: every model turn reports its usage, and the program adds them up and prices them at the rate card. This run used 7,017 tokens in and 1,202 tokens out — output counted including thinking tokens, because that is how the official rate card bills them — for a total of about $0.0098 at $0.75 / $3.75 per million. The lesson is not that it is cheap; it is that the cost is knowable, per run, from numbers the run itself prints.
Step 8: the run prints its own bill — real token counts from the run's usage metadata, priced at the verified rate card.
Step 9 — Guardrails: code, not prompts
Name the guardrails, because they are the difference between a tool and a liability:
- The approval gate — no write is ever ungated; a refusal is final and the model is told not to retry.
- Confinement — every tool is locked to one project folder, enforced by the resolved-path check in code, not requested in a prompt.
- The step cap — twelve turns, then it stops, whatever it believes.
- What the agent does not have — no shell tool, so it cannot run arbitrary commands; no network tool, so it cannot send your code anywhere.
These are the same patterns the serious tools use: Claude Code confines its writes and asks before dangerous actions, and approval-routing designs send a risky action to a human before it is allowed to execute. Prompts are suggestions. Code is enforcement. Build the enforcement.
Step 10 — The honest verdict
What it nailed: the diagnosis was exact, the patch minimal, and it stopped when the suite went green instead of gold-plating. Where it wobbled: it rewrote the whole file to change three lines in one method — whole-file writes are simple, but blunt — and the file it saved still carries its header comment claiming a bug lives here on purpose, which stopped being true the moment the fix landed. That is what the gate is for: a human reading the proposal catches the stale comment; an auto-approver ships it. And the limits, plainly: this is a teaching agent for small, well-tested code. A full tool brings a terminal, a whole-codebase index, and years of hardening this one file does not have. Build one anyway — not to replace those tools, but because the day you have written the loop, the gate, and the confinement yourself, you understand what every agent product is actually doing under the hood. That understanding is the real deliverable.
Errors you may hit
-
Bad or expired key — the program stops before anything runs (
Set GEMINI_API_KEY first). Set the variable in the same terminal you run the agent from. - The model repeats a tool call — the step cap ends the loop at twelve. Read the transcript; the fix is usually a clearer task sentence.
- A patch breaks a different test — the suite goes red again and the loop continues, and the gate gives you a second decision. Never approve a fix you have not read.
-
pytestnot found — your venv is not active. Activate it, and run the agent from the project folder.
Go further: the agent files its own report (GitHub MCP)
The companion video's Short takes one more step: after the fix, the agent opens issue #16 in this blog's code repo, summarising the bug and the fix — through GitHub's official MCP server, and it asks at the gate before posting. The server is remote, hosted by GitHub at https://api.githubcopilot.com/mcp/; for an issues-only connection you can scope it to https://api.githubcopilot.com/mcp/x/issues. Authenticate with a fine-grained personal access token — a PAT: an API password whose permissions you can limit — scoped to the one repository, with Issues set to Read and write. Least privilege, exactly like the confinement in Step 3. The google-genai SDK can also consume an MCP session directly as a tool (its docs mark the support experimental); either way, the rule from Step 5 travels with it: an outward-facing write gets a human gate first.
The artifact: the issue the agent filed in the repo after the fix — proposed, approved at the gate, then posted.
Sources
- Google, Gemini Developer API pricing — the gemini-3.8-flash rate card and the free-tier data-use note (accessed 2026-11-07).
- Google, Google Gen AI SDK documentation — the manual function-calling pattern, FunctionDeclaration, and function-response roles (accessed 2026-11-07).
- GitHub, github/github-mcp-server — the remote MCP endpoint, toolsets, and token guidance (accessed 2026-11-07).
- Google AI Studio — aistudio.google.com (free API keys).
Companion code: the complete agent, the demo project, and the tests are free in the repo folder linked at the top of this post. The companion video walks the same build on the real screens.













Top comments (0)