Once I started running several blogs in parallel alongside my work as an indie developer, the morning routine quietly grew: checking traffic, sorting the publishing queue, triaging image assets. Each task takes minutes, but every new site shaved more time off the morning.
"Let the model make the judgment calls; let the tools do the work." Deciding to hand Gemini a set of tools under that principle was, for me, the real start of agent development.
Let me say this upfront: agents are not magic. Under the hood, an agent is a loop — the model proposes a tool call, your code executes it, the result goes back to the model, repeat. Write that loop yourself once and you gain a clear yardstick for evaluating every framework and managed service afterward. This article covers that minimal setup and the design decisions that only became visible in production.
A minimal agent with automatic function calling alone
The current Python SDK is google-genai (pip install google-genai). Samples for the old google-generativeai package still rank high in search results, but the class layout is different and mixing them breaks. Right after migrating, I burned time hunting for the old SDK's GenerativeModel myself.
The new SDK's big win is that you can pass plain Python functions and automatic function calling takes over. Function declarations are generated from docstrings and type hints; when the model decides a call is needed, the SDK executes the function and returns a final answer that incorporates the result.
from google import genai
from google.genai import types
# Reads the GEMINI_API_KEY environment variable automatically
client = genai.Client()
def get_article_stats(slug: str) -> dict:
"""Returns views and clicks for the given article slug over the last 7 days."""
# In production this queries the GA4 / Search Console data store
return {"slug": slug, "views": 1280, "clicks": 96}
def list_pending_drafts() -> list[str]:
"""Returns the list of draft slugs waiting for publication."""
return ["gemini-batch-api-notes", "image-asset-pipeline"]
response = client.models.generate_content(
model="gemini-3.5-flash",
contents="Check the pending drafts and, based on recent article performance, propose a publishing order.",
config=types.GenerateContentConfig(
tools=[get_article_stats, list_pending_drafts],
),
)
print(response.text)With just this, the model calls list_pending_drafts, calls get_article_stats as many times as it needs, and returns a synthesized recommendation. Multi-step execution works even though you never wrote a loop — the SDK is running the tool loop internally.
The part that trips people up is the docstring. With automatic function calling, the docstring is the model's only description of your tool, so a vague "what it returns" makes the call decisions wobble. State the meaning of each argument and the shape of the return value in one sentence each. That alone visibly stabilized the calls for me.
When to switch to a manual loop
Automatic execution is convenient, but in my production pipelines I switch to a manual loop. Two reasons: I want to inspect each proposed tool call before it runs, and I want to own the step limit myself.
from google import genai
from google.genai import types
client = genai.Client()
publish_tool = types.Tool(function_declarations=[
types.FunctionDeclaration(
name="publish_article",
description="Publishes the article with the given slug. Destructive: requires verification.",
parameters=types.Schema(
type=types.Type.OBJECT,
properties={"slug": types.Schema(type=types.Type.STRING)},
required=["slug"],
),
),
])
config = types.GenerateContentConfig(
tools=[publish_tool],
# Disable auto-execution; receive call proposals only
automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True),
)
contents = [
types.Content(role="user", parts=[
types.Part(text="Publish the reviewed article. Target: gemini-batch-api-notes"),
]),
]
MAX_STEPS = 6 # Runaway guard. Routine ops tasks fit within 6 steps in my experience
for step in range(MAX_STEPS):
response = client.models.generate_content(
model="gemini-3.5-flash", contents=contents, config=config,
)
part = response.candidates[0].content.parts[0]
if not part.function_call:
print(response.text) # No more tool proposals — done
break
call = part.function_call
# Inspect the proposed call here before executing it
result = execute_with_allowlist(call.name, dict(call.args))
contents.append(response.candidates[0].content)
contents.append(types.Content(role="user", parts=[
types.Part.from_function_response(name=call.name, response={"result": result}),
]))The key line is MAX_STEPS. An early version of mine lacked it; when a tool kept returning an error string, the model kept retrying the same call and the only thing that accumulated was API spend. I now treat the loop cap as a required component, not an insurance policy.
Separate read tools from destructive tools
The single most effective design decision in production was splitting tools into "read" and "destructive."
Fetching stats or listing drafts is harmless no matter how often the model calls it. Publishing, deleting, or posting externally cannot be taken back. In my implementation, destructive tools go through an allowlist, and arguments are validated before execution inside the loop.
READ_TOOLS = {"get_article_stats", "list_pending_drafts"}
WRITE_TOOLS = {"publish_article"}
def execute_with_allowlist(name: str, args: dict) -> str:
if name in READ_TOOLS:
return run_tool(name, args)
if name in WRITE_TOOLS:
# Destructive calls get an existence check first
if not draft_exists(args.get("slug", "")):
return f"Aborted: target {args.get('slug')} not found"
return run_tool(name, args)
return f"Tool {name} is not permitted""Destructive operations on nonexistent targets get swallowed by the tool itself." After adding that one layer, the path from a model misjudgment to a real incident was closed. Rather than trusting the model to be smart, tilt the tool design toward safety. A surprising share of agent quality lives in the tool-side code.
Before multi-agent, try one agent with good tools
Articles about agents love multi-agent orchestration. I tried it once too — an "analyst," a "writer," and a "reviewer." My conclusion: at solo-developer scale, it did not justify the maintenance cost.
Every handoff prompt between roles made debugging harder, and tracing where a judgment went wrong ate the time the setup was supposed to save. The same results came out more reliably from one agent with clearly scoped tools. Multi-agent pays off only when each role truly needs an isolated context — that is my current read.
If you would rather hand the entire loop and its runtime to Google, Managed Agents (public preview) is an option: an isolated sandbox that handles planning through code execution. The trade-off is exactly the setup above — you give up holding destructive-tool verification in your own code. I am taking a staged path: run my own loop first, learn where the sharp edges are, then move only the parts that transfer cleanly.
The numbers I check every month
Whether an agent keeps running is a numbers decision, not a feeling. I check three things at the start of each month.
Tool-call success rate (around 96% lately; when it dips, I suspect a docstring or argument schema first). Average steps per task (2–3 is healthy; drift upward means the goal statement in the prompt has gone fuzzy). And total token consumption summed from usage_metadata. Put that cost next to the hours the automation saves, and retire what no longer pays for itself. With that yardstick, agents stay in production because they are effective — not merely because they are running.
What to try next
Start with a minimal agent holding just two read-only tools under automatic function calling. Once you have felt how a one-sentence docstring change shifts behavior, moving to a manual loop with a step cap is the natural next step.
When you want finer control over tool definitions, Function Calling with the Gemini API is the next read. If the basics still feel shaky, Gemini API Quickstart is a good place to firm them up.
I hope this helps if you are automating your own daily operations, one small piece at a time.