Agents, With Just Python

Agents, With Just Python

Every piece, in one place, no framework. A from-scratch, example-driven walkthrough of what an "AI agent" actually is under the hood — a loop, a tool schema, and a model that decides what to call next — built with nothing but plain Python and an API client.

MC
Mayank Chugh
Senior Enterprise Architect · AI Engineer · RAG & Agentic AI
Foundations

The Starting Point

Strip away the branding and an "agent" is a function you call in a loop: send some text to a model, get text back, decide what to do with it, repeat. Nothing about that requires a framework — it requires a while loop and a client for whichever model API you're calling.

Think of it like this A plain language model is a brilliant guesser with no memory and no hands — it can only produce the next token. An agent is what you get when you hand that guesser a transcript to remember the conversation, and a phone to call out to the real world when it needs to.
Foundations

Why No Framework First

Agent frameworks are useful once you know what they're automating. Learned in the wrong order, they hide the one thing worth understanding: that "the agent decided to call a tool" is just an if-statement reading a field out of the model's JSON response. Build the loop by hand once, and every framework afterward reads as a shortcut instead of a black box.

Foundations

Calling a Real Model

Everything starts with a single request-response call — no tools, no loop, just a prompt in and text out.

from anthropic import Anthropic

client = Anthropic()

def ask(prompt: str) -> str:
    resp = client.messages.create(
        model="claude-sonnet-5",
        max_tokens=512,
        messages=[{"role": "user", "content": prompt}],
    )
    return resp.content[0].text

print(ask("Convert 100 USD to INR"))

Right now the model can only guess at the exchange rate — it has no way to actually look one up. That gap is exactly what tools exist to close.

Giving It Capabilities

What Is a Tool

A tool is nothing more than a Python function the model is told about, in a format it can request by name. The model never executes your code — it only ever asks for it, by returning structured JSON that names a function and its arguments. Your code is the one that actually runs it.

A menu, not the kitchen

The model reads the tool list like a menu — a name, a description, and what arguments it takes. It never sees or touches the implementation behind it.

Just a function, nothing more mysterious

Under any agent framework's "tool" abstraction is a regular function with a docstring. That's the whole trick.

Giving It Capabilities

The Tool Schema

To let the model choose a tool, you describe it in a schema: a name, a plain-language description of when to use it, and the shape of its expected input.

convert_currency_tool = {
    "name": "convert_currency",
    "description": "Use whenever an amount needs converting from one currency to another.",
    "input_schema": {
        "type": "object",
        "properties": {
            "amount": {"type": "number"},
            "from_currency": {"type": "string"},
            "to_currency": {"type": "string"},
        },
        "required": ["amount", "from_currency", "to_currency"],
    },
}

def convert_currency(amount, from_currency, to_currency):
    rates = {"USD_INR": 87.4, "INR_USD": 1 / 87.4}
    rate = rates.get(f"{from_currency}_{to_currency}")
    return {"converted": round(amount * rate, 2)} if rate else {"error": "unsupported pair"}
The bouncer that checks the form at the door Validate every tool argument the model hands back before you run it — a model producing well-formed JSON is not the same guarantee as a model producing correct JSON. A clear error beats a confident guess.
Giving It Capabilities

Manual Tool Calling

Before wiring up the loop, call the tool by hand once — turning the idea into one real function call makes the next step (letting the model trigger it) far less abstract.

result = convert_currency(100, "USD", "INR")
print(result)   # {'converted': 8740.0}
The Loop

The Agent Loop

Decide, act, observe, repeat. The model is given the tool list on every turn; if it asks for a tool call, you run it and feed the result back in as part of the conversation, then call the model again. The loop ends when the model responds with a final answer instead of a tool request.

def run_agent(user_message: str, tools: list, tool_impls: dict, max_steps: int = 5) -> str:
    messages = [{"role": "user", "content": user_message}]

    for step in range(max_steps):
        resp = client.messages.create(
            model="claude-sonnet-5",
            max_tokens=512,
            tools=tools,
            messages=messages,
        )

        if resp.stop_reason != "tool_use":
            return resp.content[0].text   # final answer

        messages.append({"role": "assistant", "content": resp.content})
        tool_results = []
        for block in resp.content:
            if block.type == "tool_use":
                output = tool_impls[block.name](**block.input)
                tool_results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,
                    "content": str(output),
                })
        messages.append({"role": "user", "content": tool_results})

    raise RuntimeError("Failed after max retries")
The Loop

Letting It Decide

The model, not your code, chooses whether a tool is needed and which one — your job is only to describe the tools honestly and execute whatever it asks for. "Convert 100 USD to INR" triggers a tool call; "what's the capital of Japan" does not, because the model already knows the answer without reaching for a phone.

The Loop

Memory

An agent's "memory" is nothing more than a growing list — the same messages array from the loop above, replayed in full on every turn. There's no hidden state; the entire conversation, including every tool call and its result, is just accumulated in that list and sent back each time.

Reliability

Structured Output

The same discipline that makes tool arguments trustworthy applies to the agent's final answer: ask for a form, not a paragraph. Constrain the response to a known JSON shape and validate it before your code acts on it — the same idea covered in a Pydantic-based reference guide, applied here to the agent's own output instead of a single API request.

Reliability

Failing Loudly

An agent stuck in a bad loop — calling the same tool with the same bad arguments — should stop and raise, not spin forever. A capped max_steps, plus a validation error surfaced back to the model as its next input, turns silent failure into a visible, debuggable one.

Interactive

Try a Mini Agent

A tiny, client-side version of the loop above — two toy tools, no live model. Ask it to convert a currency or do basic math and watch it "decide" which tool to reach for.

agent: run_agent(tools=[convert_currency, calculate])
Try: "Convert 100 USD to INR", "What is 42 * 7?", or "What is the capital of Japan?"
Wrap-up

The Complete Agent

Putting it together: a model call, a tool schema, a loop that feeds results back in, and validation at every boundary. That's the whole mechanism — everything an agent framework adds on top is convenience, not a different idea. Once you've built one by hand, "agentic" stops being a buzzword and starts being a design pattern you can reason about.

More on RAG pipelines, self-correcting agentic workflows, and MCP tooling is linked below.