Building a Simple Tool-Using Agent in Python
Every agent shrinks to a loop: the model emits a tool_use request, your code executes it, and the result returns as a message until the model answers with no call. The article builds this by hand before frameworks, treating tool schemas as the contract and naming its four failure modes.
Shreyash Gurav
August 29, 2026
13 min read
Building a Simple Tool-Using Agent in Python
The word "agent" gets thrown around until it stops meaning anything. For the purposes of this article, an agent is a program that repeatedly calls a language model, lets the model decide whether it has enough information to answer, and hands the model real tools it can execute to gather more. No reasoning framework, no fancy abstractions. Just a loop that closes the gap between "the model knows" and "the model can do."
That loop is the entire job. Everything else, the orchestration libraries, the persistence layer, the guardrails, they all support that one idea: a model in a loop that can call tools and react to what those tools return. So before you reach for LangChain or any framework, it is worth building this by hand once. Not because hand-rolled is better in production, it mostly is not, but because when you understand the two dozen lines that make an agent work, every framework you touch afterward becomes syntax instead of magic.
The minimum viable loop#
An LLM is a text-in, text-out function. It cannot open a file, query a database, or make an HTTP request unless you let it, and by default it cannot even express a desire to do so in a structured way. Modern models solve the first half of that problem natively: given a list of tool signatures, they emit a structured call to tools: [...] in their response. They do not execute anything. They simply say "I would like to call search_products with these arguments."
Your job as the agent author is the other half. You parse that structured request, execute the actual function against your real system, and feed the result back to the model as a new message. Then you repeat until the model either produces a final answer with no tool calls or you decide it has run too long and stop it.
That is the whole architecture. Let me show you the minimal version before adding ceremony.
Read that carefully, because almost every agent you have ever used is a dressed-up version of it. The model answers. If the answer contains tool calls, you append the assistant message verbatim (critical, the tool call IDs must remain), run your functions, append the results as role: "tool" messages tied to their IDs, and go around again. The loop terminates when the model emits a message with no tool calls, or when your counter expires.
There are exactly four things that can go wrong in this skeleton, and they are the same four things that go wrong in billion-dollar agent platforms, so it is worth naming them now: the model never terminates, a tool call is malformed, a tool call succeeds but returns garbage, and the model ignores the tool results and hallucinates anyway. We will come back to each.
Let the model opt in, never force it#
A design decision hides inside that skeleton, and it matters more than any single line of code: the model gets to choose whether to call a tool. In the loop above, no tool call is ever required. The model emits tool_calls only if it judges that answering well requires external information.
This is the difference between a real agent and a glorified if/then dispatch. If you make the agent call a tool on every turn regardless of whether it needs it, you have built automation, not agency, and you will pay for every pointless round trip. Letting the model decide means your agent naturally degrades to a plain chat answer when it has everything it needs, and only spends the latency and tokens on a tool round trip when the question actually demands it.

Notice that the hard loop counter protects you from the pathological case where the model keeps calling tools forever. It is a crude but effective ceiling, and for a first version it is the right one. Later we will talk about limits that are actually calibrated to the task, but for now, a for _ in range(10) is honest engineering. An unbounded loop is a bug with a latency bill attached.
Tool schemas are the real contract#
The Python functions you write are trivial. get_weather is a two-line dict lookup. The hard part, the part that decides whether your agent works at all, is the JSON Schema you attach to it, and most people get this wrong on their first attempt by being lazy with descriptions.
The model has never seen your Python code and will never execute it directly. Everything it knows about your tool comes from three sources: the function name, the parameter descriptions, and the description field on the function itself. Treat those fields as the documentation your junior programmer reads to learn the tool, because that is effectively what is happening. The model is reasoning about your tool from its prose, so vague descriptions produce confused calls.
Two details here are load-bearing. The description says the function takes airports, not cities, and even gives examples. Without that, the model will happily pass "Paris" and "New York" to a tool that was built for "CDG" and "JFK", and you will spend a debugging session convincing yourself the model is broken when the problem is that your contract was ambiguous. The second detail is "additionalProperties": False. It sounds like a minor strictness flag, but what it really does is force the model to fit its call to your shape instead of inventing extra keys that your tool_impl does not know how to read.
Where the loop earns its keep#
A single tool call is not why you build an agent. You build an agent so the model can chain tools together, so the answer to one call feeds the arguments of the next, so the agent discovers it has the wrong data and goes back for better data.
Consider a support triage agent. A user reports a failed charge. Handled as a plain chatbot, the model must guess the user's identity, their order, and the state of the transaction, and it will guess wrong in every interesting case. Handled as a loop, the agent looks up the customer by email, finds the most recent order, checks its fulfillment status, and only then forms an opinion. Each step narrows the state space.
Now a single user message, "why did my order for the blue mixer fail?" can spawn a chain: find_customer gets the customer, find_orders returns their orders, order_details reveals the payment gateway declined because the card expired. The model did not need to be told how to do that. It read the schemas, saw the dependency implied by the foreign keys in the descriptions, and threaded the values through. That emergent chaining is the entire reason to build agents instead of canned query flows.

But note something subtle, and this is where most hand-rolled agents quietly fail. To get that multi-step chain, the loop has to be resilient. After find_customer returns customer_id 441, the model has to decide to call find_orders(441) next, and it can only do that if the previous tool result is sitting in context as a tool message it can reference. This is why you append the assistant message with its tool_call_ids before the results, and why you keep every message in the list for the next call. If you truncate or reserialize the history carelessly, you break the chain and the agent starts hallucinating foreign keys instead of threading real ones.
The four ways it breaks#
I promised to come back to the failure modes, and this is the part most tutorials skip, which is a shame, because the failures are where the engineering happens.
The first is non-termination. The model decides every turn that it needs one more tool call, or worse, it falls into a loop where it calls the same tool with the same arguments forever because the result does not change its mind. Your hard counter catches this, but it catches it dumbly, after wasted tokens. The better fix is to teach the loop to recognize when a tool produced an identical response to the previous identical call and bail early.
The second is malformed output. JSON Schema constrains a lot, but models still occasionally emit arguments that do not parse, or call a tool you never registered. Wrap every call in a try/except that formats the error as a tool message back to the model. Telling the model "call get_weather failed: city not found" is often enough for it to self-correct, and it costs you nothing.
The third is garbage results. Your tool ran, but it returned a null, an empty list, or a database error string. If you pass that straight back, the model will do its best to sound confident about nothing. You want the loop to translate tool errors into explicit "this tool failed" signals rather than letting empty data flow through unremarked.
The fourth and most insidious is the model ignoring the evidence. You handed back a tool result that clearly contradicts what the model is about to say, and it says it anyway. This happens with surprising frequency, especially when a system prompt tells the model to be helpful at all costs. Guarding against it requires either stronger prompt control or, as we will see, treating tool results with the same skepticism you treat any model claim and validating final answers against the tool data downstream.

Concurrency and real tools#
If your agent only ever calls one tool at a time, it is fine, but slow. Models will happily emit several tool calls in a single assistant message, and you can run them in parallel. That is why the loop above iterates over msg.tool_calls instead of assuming there is exactly one. When the calls are independent, fan them out with a thread pool.
But parallel execution has a sharp edge: if two tool calls share a dependency, for example one tool needs the output of another, running them in parallel will just make one of them fail and burn a retry. The discipline is to only request parallel calls when the model can know they are independent, and to make your tool implementations idempotent so a retry after a partial failure does not double-book anything. If your tool makes a payment, a crash between "debited the card" and "returned a confirmation" is a real problem, and parallel fan-out makes that window wider, not narrower.
When the hand-rolled loop is enough#
There is a threshold past which maintaining the raw loop becomes a liability, and it is important to be honest about where that is. If you have one tool, one model, one prompt, and one user flow, the twenty-line loop is the right call. Adding a framework would be ceremony.
The moment you have branching conversation histories, multiple model calls with different roles, checkpointing so a failed request resumes where it left off, or a team of agents that hand work to each other, the hand-rolled loop stops being simple and starts being a fragile pile of if statements you have to test by hand. That is the point where you move to a graph-based orchestration layer, which is a topic of its own. The good news is that everything you just built translates directly. The tool schemas stay identical. The loop becomes an explicit graph node. The failure modes you learned to handle by hand become structured edges and retry policies.

The takeaway#
An agent is not a product. It is a loop with a contract in the middle. The contract is the tool schema, and the loop is a model calling tools, reading the results, and deciding what to do next. Get those two things right, write clear descriptions, handle the four failure modes, thread your tool results into context faithfully, and you have built the same core that fancier platforms wrap in dashboards.
Everything people pretend is mysterious about agents reduces to this. When a demo impresses you with an agent that "figured out" a multi-step task, what you are watching is a model reading good tool schemas and threading real results. The magic is not the loop. The magic is a contract clear enough that a probabilistic text generator can follow it reliably, and a loop disciplined enough to catch it when it does not.
Want to Master Spring Boot and Land Your Dream Job?
Struggling with coding interviews? Learn Data Structures & Algorithms (DSA) with our expert-led course. Build strong problem-solving skills, write optimized code, and crack top tech interviews with ease
Learn more