Build Your First AI Agent in Python: Tools, Memory, Guardrails
Generative AI course · Chapter 45 · Estimated reading time: 14 minutes
You have seen what an agent is, built one in n8n without code, and connected AI to tools with MCP. Now you will write the agent loop yourself, in about 60 lines of plain Python. Doing it by hand is the best way to understand what every agent framework does underneath: the model decides, your code runs a tool, the result goes into memory, and the loop repeats. Your code also adds the guardrails: which tools are allowed, which need a person's approval, and when to stop. In this chapter you will build a calculator-tool agent, a shop FAQ agent that raises a support ticket only with approval, and then attack your own agent to prove the guardrails work, all with real output.
What you will learn in this chapter
- The 4 parts of an agent program: model, tools, memory, loop
- How to write safe tools, including a calculator with no
eval - How to test an agent with no API key, using a scripted model
- Example 1: a calculator-tool agent
- Example 2: an FAQ agent that raises a ticket with approval
- Example 3: guardrails under attack: allow-list, approval, errors, step limit
- How to plug in a real model, and when to use a framework
1. Suppose a shop gets the same 3 questions all day
Suppose Bluehill, a small online stationery shop, gets messages like "How long does a refund take?", "When will my order arrive?" and "My parcel arrived damaged". The first two have fixed answers in an FAQ. The third needs a person: someone must open a support ticket.
A plain chatbot might invent a refund time. An agent can do better: look up the FAQ, answer only from it, and if the FAQ has nothing, ask a person before it raises a ticket. That is exactly what we will build.
2. The 4 parts of an agent program
- The model: something that looks at the conversation and decides the next move: "call tool X with these arguments" or "give the final answer".
- Tools: normal Python functions, such as
calculator,search_faqandcreate_ticket. - Memory: a list of everything so far: the user's goal, each tool call and its result.
- The loop: ask the model → run the tool → add the result to memory → repeat, until the model answers or a limit is reached.
The guardrails live in the loop, in your code, not in the prompt. A prompt can be talked around; an if statement cannot.
The agent loop in code: decide, check, act, remember
3. The tools, written safely
import ast, json, operator
OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
ast.Div: operator.truediv, ast.USub: operator.neg}
def calculator(expression: str) -> str:
"""Safely evaluate + - * / on numbers (no eval)."""
def ev(n):
if isinstance(n, ast.Constant) and isinstance(n.value, (int, float)): return n.value
if isinstance(n, ast.BinOp) and type(n.op) in OPS: return OPS[type(n.op)](ev(n.left), ev(n.right))
if isinstance(n, ast.UnaryOp) and type(n.op) in OPS: return OPS[type(n.op)](ev(n.operand))
raise ValueError("only numbers and + - * / are allowed")
return str(round(ev(ast.parse(expression, mode="eval").body), 2))
FAQ = {"refund": "Refunds go back to the original payment method within 5 to 7 working days.",
"delivery": "Orders are delivered in 3 to 5 working days; tracking is sent by SMS.",
"cancel": "You can cancel an order until it is packed, from My Orders."}
def search_faq(topic: str) -> str:
for k, v in FAQ.items():
if k in topic.lower(): return v
return "NOT FOUND"
TICKETS = []
def create_ticket(summary: str, approved: bool = False) -> str:
if not approved: return "NEEDS_APPROVAL"
TICKETS.append(summary); return f"TICKET-{1000 + len(TICKETS)} created"
TOOLS = {"calculator": calculator, "search_faq": search_faq, "create_ticket": create_ticket}
NEEDS_APPROVAL = {"create_ticket"}
Three habits to copy:
- Never use
eval()on text from a model or a user. The calculator reads the expression as a tree and allows only numbers and+ - * /. - Tools return plain text, including "NOT FOUND", so the model can react.
- Tools that change things (here
create_ticket) refuse to act unlessapprovedis true.
4. The loop, with guardrails
def run_agent(goal, model, max_steps=5, allowed=TOOLS, approve=lambda call: False, log=print):
memory = [{"role": "user", "content": goal}]
for step in range(1, max_steps + 1):
decision = model.decide(memory) # think
if decision["type"] == "answer":
log(f" step {step}: ANSWER -> {decision['text']}"); return decision["text"]
name, args = decision["tool"], decision["args"]
if name not in allowed: # guardrail 1: allow-list
result = f"BLOCKED: tool '{name}' is not allowed"
else:
try:
if name in NEEDS_APPROVAL: # guardrail 2: human approval
args = {**args, "approved": approve({"tool": name, "args": args})}
result = allowed[name](**args) # act
except Exception as e: # guardrail 3: errors become text
result = f"ERROR: {e}"
log(f" step {step}: {name}({json.dumps(args)}) -> {result}")
memory.append({"role": "tool", "name": name, "content": result}) # observe + remember
log(f" STOP: reached the limit of {max_steps} steps"); return None # guardrail 4: step limit
Testing with no API key. To test the loop, we use a ScriptedModel that replays fixed decisions, the way a real model would send them. This is how professionals test agents: first prove the loop and guardrails work, then plug in a real model.
class ScriptedModel:
"""Stands in for an LLM: returns pre-written decisions in order (for testing the loop)."""
def __init__(self, decisions): self.decisions = list(decisions)
def decide(self, memory): return self.decisions.pop(0)
Ravindra Bagale's Tip
The mistake: writing "never delete anything" in the system prompt and believing the agent is now safe. A model can misread, or be tricked by text it reads. Put the rules in code: an allow-list of tools, an approval check before any tool that changes data, a step limit, and errors turned into text. Then test those guardrails on purpose, as in Example 3, before any real user touches the agent.
5. Worked example 1: a calculator-tool agent
run_agent("A school trip: 42 students x Rs 350 bus fare, plus Rs 1,200 for snacks. Total?",
ScriptedModel([{"type": "tool", "tool": "calculator", "args": {"expression": "42*350+1200"}},
{"type": "answer", "text": "The total is Rs 15,900."}]))
Real output (19 October 2026, Python 3):
EXAMPLE 1: calculator tool
step 1: calculator({"expression": "42*350+1200"}) -> 15900
step 2: ANSWER -> The total is Rs 15,900.
Language models are known to slip on arithmetic. A calculator tool means the number comes from Python, not from the model's guess.
6. Worked example 2: an FAQ agent that raises a ticket with approval
Case A: the FAQ has the answer.
Real output:
EXAMPLE 2a: FAQ answer, no ticket needed
step 1: search_faq({"topic": "refund"}) -> Refunds go back to the original payment method within 5 to 7 working days.
step 2: ANSWER -> Refunds reach your original payment method in 5 to 7 working days.
Case B: the FAQ has nothing, so the agent raises a ticket, and a person approves it. We pass approve=lambda call: True to act as the person who clicked "yes".
Real output:
EXAMPLE 2b: not in the FAQ -> ticket, with approval
step 1: search_faq({"topic": "damaged parcel"}) -> NOT FOUND
step 2: create_ticket({"summary": "Damaged parcel reported by customer", "approved": true}) -> TICKET-1001 created
step 3: ANSWER -> I have raised ticket TICKET-1001; our team will contact you.
Notice the order: FAQ first, ticket only after "NOT FOUND", and the approved flag is added by your code, not by the model.
7. Worked example 3: attack your own agent
Now we play the attacker. The scripted model tries 3 bad moves, then gets stuck in a loop:
- Sends Python code to the calculator.
- Calls a tool that does not exist:
delete_all_orders. - Raises a ticket with no approval.
- Keeps searching forever.
Real output:
EXAMPLE 3: guardrails
step 1: calculator({"expression": "__import__('os').system('ls')"}) -> ERROR: only numbers and + - * / are allowed
step 2: delete_all_orders({}) -> BLOCKED: tool 'delete_all_orders' is not allowed
step 3: create_ticket({"summary": "spam", "approved": false}) -> NEEDS_APPROVAL
step 4: search_faq({"topic": "x"}) -> NOT FOUND
step 5: search_faq({"topic": "y"}) -> NOT FOUND
STOP: reached the limit of 5 steps
Tickets created: ['Damaged parcel reported by customer']
Every guardrail held: the code injection failed, the unknown tool was blocked, the ticket waited for approval, and the loop stopped at 5 steps. Only the 1 approved ticket exists. To protect AI features at work against tricks hidden in emails and web pages, read our guide AI Security: Defend Prompt Injection and Data Leakage.
Add logging. In a real agent, save every step to a file so you can review it later:
import datetime
def file_log(line):
with open("agent_log.txt", "a", encoding="utf-8") as f:
f.write(f"{datetime.datetime.now():%Y-%m-%d %H:%M:%S} {line}\n")
run_agent("How long does a refund take?", model, log=file_log)
8. Plug in a real model
The loop does not change. You only write a new decide() that sends the memory and the tool descriptions to a real model. Here is the idea with the OpenAI Python SDK and its tools (function calling) feature, using the key set-up from the API chapter:
from openai import OpenAI
client = OpenAI() # reads OPENAI_API_KEY from your .env file
SCHEMAS = [{"type": "function", "function": {"name": "search_faq", "description": "Look up a shop FAQ by topic.",
"parameters": {"type": "object", "properties": {"topic": {"type": "string"}}, "required": ["topic"]}}}]
# ...plus calculator and create_ticket in the same shape
class OpenAIModel:
def decide(self, memory):
msgs = [{"role": "system", "content": "Use search_faq first. Only if it returns NOT FOUND, use create_ticket."}]
msgs += [{"role": "user", "content": m["content"] if m["role"] == "user"
else f"[tool {m['name']} returned] {m['content']}"} for m in memory]
r = client.chat.completions.create(model="gpt-6-astra", messages=msgs, tools=SCHEMAS)
msg = r.choices[0].message
if msg.tool_calls:
call = msg.tool_calls[0]
return {"type": "tool", "tool": call.function.name, "args": json.loads(call.function.arguments)}
return {"type": "answer", "text": msg.content}
The full file is longer; copy the model name from your provider's models page, as names change often.
Expected output (yours will be similar, not the same): for "My parcel arrived damaged", the model calls search_faq, gets NOT FOUND, then asks to call create_ticket. Your terminal asks "Approve create_ticket ...? (y/n)". After you type y, the ticket is created and the model writes a short, polite reply with the ticket number.
When to use a framework. Once you understand this loop, frameworks such as the OpenAI Agents SDK, LangGraph, or AWS's Strands Agents save you work on memory, streaming, retries and tracing. They do the same 4 things you just wrote, so you will know what to look for: where the tools are listed, where approvals happen, and where the step limit is set.
9. Beginner mistakes and quick fixes
| Mistake | What happens | Quick fix |
|---|---|---|
Using eval() in a calculator tool |
A model or user can run any code | Parse with ast and allow only numbers and operators |
| Rules only in the prompt | The agent can be talked around | Allow-list, approval and limits in code |
| No step limit | Endless loops and big bills | max_steps, and stop with a clear message |
| A tool error crashes the program | The agent dies mid-task | Catch errors and return them as text |
| Testing only with a live model | Slow, costly, different every run | Test the loop first with a scripted model |
| No log | You cannot see what it did | Write every step to a file |
10. Try at home
Try at home
Task 1: run the agent
- Save the tools, the loop and
ScriptedModelinagent.py. - Run the 3 examples.
- Check that your output matches the real output above.
Task 2: add a tool
- Add a
days_until(date_text)tool that counts days to a date. - Add it to
TOOLS. - Write a scripted test that uses it.
Task 3: break it on purpose
- Add
delete_ordertoNEEDS_APPROVAL. - Write a scripted model that calls it with no approval.
- Confirm the result is NEEDS_APPROVAL.
Recap
In short
- An agent program has 4 parts: a model that decides, tools, memory and a loop.
- Tools are plain Python functions that return text; never use
eval()on model output. - Guardrails belong in code: an allow-list, approval for actions that change data, error handling and a step limit.
- Test the loop with a scripted model before you spend money on a real one.
- The FAQ agent answered from the FAQ and raised a ticket only after "NOT FOUND" and approval.
- A real model plugs in through
decide(); frameworks do the same 4 jobs with more features.
Build the loop by hand once. After that, every agent framework will look familiar, and you will know exactly where to put the safety checks.
Previous chapter: What Is MCP (Model Context Protocol)? Connect AI to Your Tools
Next chapter: Amazon Bedrock for Beginners: Your First Gen AI App on AWS
Ravindra Bagale, trainer: linkedin.com/in/ravindra-bagale. Bluehill, its FAQ and the tickets on this page are made up for learning. The Python output was produced on 19 October 2026 with a scripted model standing in for a real one. The tool-calling format was checked on the OpenAI API docs on 19 October 2026. AI output changes every time, so your output with a real model will be similar, not the same.