Ravindra BagaleCourses & study guides Track your progress

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

  1. The model: something that looks at the conversation and decides the next move: "call tool X with these arguments" or "give the final answer".
  2. Tools: normal Python functions, such as calculator, search_faq and create_ticket.
  3. Memory: a list of everything so far: the user's goal, each tool call and its result.
  4. 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 Steps from top to bottom: 1 Goal into memory, The first message in the list; 2 Model decides, A tool call or a final answer; 3 Code checks, Allow-list, approval, step limit; 4 Run the tool, Errors come back as text; 5 Remember and repeat, Result added to memory. 1Goal into memoryThe first message in the list 2Model decidesA tool call or a final answer 3Code checksAllow-list, approval, step limit 4Run the toolErrors come back as text 5Remember and repeatResult added to memory

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 unless approved is 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:

  1. Sends Python code to the calculator.
  2. Calls a tool that does not exist: delete_all_orders.
  3. Raises a ticket with no approval.
  4. 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

  1. Save the tools, the loop and ScriptedModel in agent.py.
  2. Run the 3 examples.
  3. Check that your output matches the real output above.

Task 2: add a tool

  1. Add a days_until(date_text) tool that counts days to a date.
  2. Add it to TOOLS.
  3. Write a scripted test that uses it.

Task 3: break it on purpose

  1. Add delete_order to NEEDS_APPROVAL.
  2. Write a scripted model that calls it with no approval.
  3. Confirm the result is NEEDS_APPROVAL.

Recap

In short

  1. An agent program has 4 parts: a model that decides, tools, memory and a loop.
  2. Tools are plain Python functions that return text; never use eval() on model output.
  3. Guardrails belong in code: an allow-list, approval for actions that change data, error handling and a step limit.
  4. Test the loop with a scripted model before you spend money on a real one.
  5. The FAQ agent answered from the FAQ and raised a ticket only after "NOT FOUND" and approval.
  6. 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.