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.

  1. 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 
  1. 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 
  1. 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.

Terminal showing Python 3.12, the active venv, and the pinned google-genai and pytest installs completing

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.

Terminal running pytest on the untouched demo project: 1 failed, 3 passed, with the quantity test named as the failure

Step 1, before: the untouched suite — exactly 1 failed, 3 passed. This is the result the agent has to change.

Editor showing cart.py with the planted add() bug and the failing assertion in test_cart.py

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.

Editor showing the head of agent.py: the client, the gemini-3.8-flash model constant, the step cap, and the plain-English task

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.)

Editor showing the confine() path check and the read_file tool in agent.py

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.

Editor showing the run_tests tool: a fixed pytest subprocess call returning the exit code and the output tail

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.

Editor showing the write_file tool: the proposal printout and the human yes-no approval prompt

Step 5, in code: the model proposes; the input() line is the gate — nothing writes before it returns "y".

Terminal at the real approval-gate moment of the run: the proposed file printed, the Apply this change prompt, and the approval being given

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.

Editor showing the main() loop in agent.py: the generate_content call, the function-call dispatch, and the step cap

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.

Terminal transcript of the live run: the agent calling run_tests, reading the test and cart.py, and naming the bug

Step 7: the run's first half — the agent finds the failure, reads both files, and diagnoses the bug itself.

Terminal transcript of the live run after approval: the suite rerunning to 4 passed and the agent's summary

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.

Terminal showing the run's closing line: the token counts and the computed cost of the fix

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:

  1. The approval gate — no write is ever ungated; a refusal is final and the model is told not to retry.
  2. Confinement — every tool is locked to one project folder, enforced by the resolved-path check in code, not requested in a prompt.
  3. The step cap — twelve turns, then it stops, whatever it believes.
  4. 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

  1. 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.
  2. 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.
  3. 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.
  4. pytest not 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.

Browser showing the GitHub issue the agent opened in the visual-studio-learn-code repo, summarising the bug and the fix

The artifact: the issue the agent filed in the repo after the fix — proposed, approved at the gate, then posted.

Sources

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.