From a bare model to a working agent — in plain Python
The whole session builds one idea in careful layers: a raw model is just a stateless brain, a chatbot adds memory, and an agent adds tools plus a loop that lets the model decide what to call. Everything is written by hand — no LangChain — so that when frameworks show up tomorrow, nothing feels like magic.
Model vs Chatbot vs Agent
The session opens by re-drawing the picture from the previous class: an AI system is just a few boxes. There is a box for the agent, connected to a brain, a memory, and some tools. Every framework — n8n, LangChain, anything — is ultimately arranging those same boxes. The instructor's framing: once the basics are clear, frameworks are just different languages for the same idea.
“If the basics are clear, then frameworks are just like language. If you know what OOP is, what a variable is — learning the same in Python, C++, Java, Go is very simple. If you don't know that, then it's a problem.”
The raw AI model: a stateless brain
A model on its own is “just a brain which is stateless.” You send a question, you get an answer, and it remembers nothing — not who you are, not the previous call, not the next one. Whatever you send, it replies based purely on its own capability.
A single one-shot prediction. It takes a question, returns an answer, and remembers nothing about any call that happened before or after it. This is the whole capability of a raw model with no wrapping around it. It also carries fixed properties: a context window, an output-token limit, and a specific model identity.
The chatbot: a model wrapped in memory
A chatbot wraps a model with conversation history. Each call to ask() appends both the question and the answer into self.history, and the whole history is resent alongside the new question. The brain is still there and still stateless — the chatbot is the wrapper that simulates memory by carrying the full transcript every time.
Chatbot ≈ AI + memory. The memory is nothing exotic: it's a Python list of message dictionaries (key–value pairs) that you resend on each call. As that list grows, every call carries more context — which is exactly why long conversations get slower and more expensive.
The agent: memory + tools + the ability to act
An agent keeps the brain and memory, and adds tools — web search, a calculator, a currency lookup, a weather API, anything. Crucially (and this becomes the punchline of Section 8), an agent also runs in a loop: the model can ask for a tool, the tool result is fed back to the model, and the model continues until it can answer. That loop is the only real difference between “a model that suggests a tool” and “an agent.”
| Property | AI Model | Chatbot | Agent |
|---|---|---|---|
| Memory | None | Conversation history (list) | History + tool results |
| Tools | None | None | Yes (schemas passed in) |
| Can act on the world? | No | No | Yes, via tool calls |
| Runs a loop? | No | No | Yes — the agentic loop |
| Mental shorthand | a brain | brain + memory | brain + memory + tools + loop |
The instructor is deliberate: “Today we are going to master agents, but just with pure vanilla Python, nothing else.” The reason he repeats throughout — you get paid to understand and control these internals, not to snap together a framework agent anyone can build. Frameworks come next session; the mechanics come first.
How messages really work
Every chat-style API call is built from a list of messages, and there are just three roles you need to hold in your head.
system — the standing instruction that sets behaviour (“you are a helpful assistant who explains via simple analogies”). user — what the person sent. assistant — what the model replied. A conversation is just these three roles appended in order into one array.
The instructor demonstrates this live in the Groq playground. Sending a single “hi, how are you?” produces three visible messages on the right: system, then user, then assistant. Add a second turn and now four messages are sent — the entire history travels every time.
The system message is sent every single time, not just on the first turn — because the model can't remember it. A student asked exactly this; the answer was unambiguous: “It will be sent every time.” The whole chat, system message included, is transmitted on each request.
The instructor illustrates the cost with a real example: opening a fresh chat in a hosted assistant and sending one token still registered roughly 19K tokens, because a long hidden system prompt (plus connectors) is prepended. He even pulls up a public “system prompt” dump to show how much standing instruction real assistants carry.
Maintaining memory yourself is trivial in vanilla Python: keep a list, and on every turn append {"role":"user","content":question} and then {"role":"assistant","content":answer}. That list is your chatbot's memory. In production you then decide what to trim, so cost stays controlled.
class Chatbot: def __init__(self, brain): self.brain = brain # the stateless AI model self.history = [] # memory = a list of message dicts def ask(self, question): self.history.append({"role": "user", "content": question}) answer = self.brain.get_answer(self.history) # send FULL history self.history.append({"role": "assistant", "content": answer}) return answer
In the very first file this brain call was a stub — a mock that echoes "stub prediction for {question}" instead of hitting a real API. The instructor's gloss: “stub means we are not actually calling any AI… it is just equal to a mock.” Real calls arrive in Section 4.
The cutoff wall
To motivate agents, the instructor asks the model a deliberately impossible question: “Who won the FIFA World Cup match yesterday?” The model can't answer. It replies that, as a text-based model, it has no real-time access to current events.
The date up to which a model was trained. “More or less, anything which happened after this date, this AI will not know.” He points at a model card showing a cutoff (e.g. Feb 2026 for a just-released model) — anything after it is invisible to the raw model.
By default, a model has no ability to reach the real world — no web, no live data. It answers only from what it learned before its cutoff. The hosted apps (ChatGPT, Claude) feel like they can, but that's because they wrap the model with tools like web search. The bare model can't.
“You cannot use that hosted ChatGPT inside your application everywhere.” He describes a real client who does everything in the ChatGPT app and assumes it will translate directly into a product — it won't. To build a chatbot for your own website you call the model via API and add your own tools. That gap is the entire reason agents exist.
This sets up the arc for the rest of the class: to answer live questions the agent must be given tools (web search, calculator, get-weather, get-currency…), and the model must be able to say “call this tool.” The instructor is careful about a distinction that returns later — the model can decide which tool to call, but the actual calling is done by your code.
Calling a real model
Having built the model / chatbot / agent skeleton with a stubbed brain, the class now “removes the mocking” and calls a real provider. Two categories are introduced:
| Category | Providers | Notes from class |
|---|---|---|
| Paid | OpenAI, Anthropic (Claude) | Need a funded key; you pay per token. |
| Free (to a limit) | Groq, OpenRouter | Groq serves open-source models, is cheaper, and a few models are free up to a usage limit. |
The instructor stresses it repeatedly, and a student's whole bug turned out to be this: Groq (G-R-O-Q) is the fast open-source inference provider — “not the Elon Musk one.” Grok / xAI is the separate chatbot. A key that starts with xai- is the wrong one; Groq keys look like gsk_…. Using the xAI key against Groq gave a live 401 invalid API key error in class.
The OpenAI call
The key is loaded from the environment (kept in a .env file, never hard-coded), and the call mirrors the provider's own docs exactly: client.chat.completions.create(...) with a model, a max_tokens cap, and the messages array. The instructor's point: “this is exactly how OpenAI tells you to call it — I've just copied the documented approach.”
import os from openai import OpenAI client = OpenAI(api_key=os.environ.get("OPENAI_API_KEY")) def ask_openai(question): response = client.chat.completions.create( model="gpt-4o-mini", max_tokens=200, # guardrail on OUTPUT length messages=[{"role": "user", "content": question}], ) return response.choices[0].message.content
Reading the response — where the answer actually lives
Printing the raw response reveals a rich object: an id for the call, the object type, a created timestamp, the model, a choices array, and a usage block. Your answer is buried at choices[0].message.content.
Tokens and the max_tokens guardrail
The usage block reports prompt_tokens (input) and completion_tokens (output). In the demo a short call showed ~24 prompt + ~30 completion tokens, and the reply respected the 200-token cap.
max_tokens caps the model's output length. It's a guardrail, not a feature: if someone hits your live agent and asks for a million-token essay, the cap protects you from a runaway bill. “If it's a simple customer-support agent, why should it produce more than ~1000 tokens?” Per OpenAI it counts output only; some providers count both — treat it as output.
“Every AI model in the world is just an API call now.” Groq is deliberately OpenAI-compatible: you keep the same code and only change the base_url (and key). His restaurant analogy — “you can't call McDonald's and order a North Indian thali”: you can't ask OpenAI's endpoint for a Llama model, but Groq redirects the same call to the open-source models it hosts.
from openai import OpenAI # Groq is OpenAI-compatible client = OpenAI( api_key=os.environ.get("GROQ_API_KEY"), base_url="https://api.groq.com/openai/v1", # ← the only real change ) response = client.chat.completions.create( model="llama-3.3-70b-versatile", max_tokens=200, messages=[{"role": "user", "content": question}], ) answer = response.choices[0].message.content
choices[0]…. The instructor notes this hand-parsing was very much “a 2024 thing.”Twice during the live session the instructor accidentally committed a key into .env and had to delete it. Lessons banked in real time: keep keys in .env, never paste them into code you'll push, set a low max_tokens so a leaked key can't be abused for huge outputs, and rotate immediately if exposed.
Structured output with Pydantic
The motivating example: ask for the weather and the model replies “Today the weather is 25 degrees Celsius.” Lovely for a human, painful for an application. What you actually want is something a program can consume:
# instead of: "Today the weather is 25 degree celsius" { "temperature": 25 } # email example — instead of a vague sentence, a usable object: { "subject": "leave", "body": "hi, I am going to be outside for tomorrow" }
If your model's reply always arrives in a known structure, every downstream step can rely on it. For an email-reply app, knowing you'll always get {subject, body} — regardless of whether OpenAI, Anthropic, or Groq produced it — is what makes the thing buildable at all.
Why Pydantic instead of writing it in the prompt
You could type “reply in JSON with subject and body, subject must not be empty, default is Reply, more than two words…” by hand every time. Pydantic lets you declare all of that once as a class: field types, defaults, descriptions, validation — and reuse the same model throughout your code.
A Python class subclassing BaseModel that declares the shape of your data — field names, types, defaults, and (via Field) descriptions and constraints. It doubles as documentation for the AI and as validation for your program.
from pydantic import * class EmailReply(BaseModel): Subject: str = 'Reply' # default value Body: str
Reproduced exactly from the supplied Colab file. In class this was extended live with a Field(description=…) to show how each field can carry guidance (“name of the city to get the weather for”), which becomes richer context for the model.
The reply is always a string — even when it's JSON
A subtle but load-bearing point the instructor hammers: whatever the model returns, over an API it is always a string. When you ask for JSON, you get a JSON-shaped string — sometimes wrapped in markdown fences or stray text. So the real-world flow is: instruct → get raw string → trim the junk → parse JSON → cast into your Pydantic model.
import json instruction = ( "Read the user message and reply with ONLY a JSON object, " "no other text, in this exact shape: " '{"city": "<city name>", "wants_fahrenheit": true|false}. ' "User message: " + question ) raw = ask_openai(instruction) # still a STRING clean = raw.strip().removeprefix("```json").removesuffix("```").strip() data = json.loads(clean) # → dict parsed = WeatherQuestion(**data) # → typed Pydantic object print(parsed.city) # "Tokyo"
response_format and the library guarantees a conforming object — exists but was deliberately skipped to avoid complexity. “When we learn LangChain, there is a structured-output approach… it handles all of this for you.”role: user. The instructor printed it to prove the point: structured output doesn't require a system message; the user role worked fine.Giving the model a tool
A tool starts as an ordinary function. In class it's mocked with a small in-memory table so the mechanics are visible without a real API — but the instructor stresses this could be any real API (Google, a weather service, an internal microservice).
sample_weather = {
"delhi": {"celsius": 34, "conditions": "clear"},
"tokyo": {"celsius": 22, "conditions": "partly cloudy"},
"london": {"celsius": 17, "conditions": "rain"},
}
def get_weather(city):
data = sample_weather.get(city.lower())
if data is None:
return f"No weather data for {city}"
return f"{data['celsius']}C, {data['conditions']}"
The tool schema — describing the tool to the AI
Passing the function object itself doesn't work (“object of type function is not JSON serializable” — shown live as a real error). Instead you describe the tool as a JSON schema: its type, name, description, parameters, and which parameters are required. This is the exact structure hosted assistants use internally — the instructor even opens a connector's “create event” tool to show it's the same shape.
get_weather_schema = {
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the current weather for a given city.",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "city name, e.g. Tokyo",
}
},
"required": ["city"], # can't call get_weather without a city
},
},
}
His vivid warning about naming: a badly-understood tool called drop_user could be read as “send a robot to take the user on top of a building and drop them.” The description is how you prevent that — it tells the model precisely what the tool does, what it expects, and what it returns. In your company get_weather could mean something entirely different, so the schema is what disambiguates it.
city nested under properties instead of listed directly?wants_fahrenheit). properties is the bag of all input parameters; required lists the mandatory ones.type: "function" the only type?Letting the model decide
Section 6 gave the model a tool. Now instead of you deciding which tool and which arguments, you send the question and the tool schema together, and let the model choose. Its reply may contain a tool_calls list instead of plain text.
“It can only tell you which tool to call. Tool calling is always based on you — which you call yourself afterwards. It will never call the tool itself.” The tool_calls in the response is the model's decision (name + arguments), not an executed result. This distinction is the crux of the section.
tool_calls = None. “Weather in Tokyo?” / “2 × 2?” → the model returns the matching tool call with arguments; you dispatch it.Two behaviours are demonstrated back-to-back. A greeting (“hi, how are you?”) returns a normal message and tool_calls = None. A tool-worthy question returns a tool call. With two schemas registered (get_weather and a calculator), “what is 2 × 2?” correctly picks the calculator — even though the model plainly knows the answer, it defers to the registered tool. The instructor then confirms the mirror image live in Claude: asked for latest FIFA results, Claude visibly calls web-search, gets a result, feeds it back, and only then answers.
import json response = client.chat.completions.create( model="gpt-4o-mini", max_tokens=200, messages=[{"role": "user", "content": question}], tools=[get_weather_schema, get_calculator_schema], # give it the tools ) msg = response.choices[0].message if msg.tool_calls: # model WANTS a tool call = msg.tool_calls[0] name = call.function.name # "get_weather" args = json.loads(call.function.arguments) # {"city": "Tokyo"} result = TOOLS_BY_NAME[name](**args) # YOU run it else: result = msg.content # plain answer, no tool needed
The dispatch table used to actually call the chosen function comes straight from the supplied file — this is the pattern that turns a name string into a real call:
def add_number(a, b): return a + b def subtract_number(a, b): return a - b TOOLS_BY_NAME = {"add": add_number, "subtract": subtract_number} functionToCall = TOOLS_BY_NAME["add"] # look up by the name the model returned
Pass the schema, not the raw function. Passing get_weather directly raised “object of type function is not JSON serializable.” Everything sent to the model is ultimately a string of tokens — the schema is the serializable, information-rich description the model can reason over. (Frameworks hide this behind an @tool decorator that builds the schema from your function automatically.)
get_weather calls. Whether you then run them sequentially, in parallel, or with waits is entirely up to your code — frameworks make that choice for you and hide it.The agent & the loop
“A model is a single-step process: you call it, it can tell you which tool to call, and then it's on you to call it. An agent doesn't do that — the agent calls the tool for you,” takes the result, feeds it back to the model, and repeats until the model produces a final answer. That repetition is the agentic loop.
In file 06 the agent gets a get_client_and_model() for its brain, a set of tool schemas, and — the new ingredient — a loop. On each pass the model either returns plain text (done → return it to the user) or asks for a tool (run it, append the result, loop again).
def run_agent(question, tools, tools_by_name): client, model = get_client_and_model() messages = [{"role": "user", "content": question}] for _ in range(4): # max_iterations = 4 response = client.chat.completions.create( model=model, messages=messages, tools=tools, max_tokens=200, ) msg = response.choices[0].message if not msg.tool_calls: # plain answer → done messages.append({"role": "assistant", "content": msg.content}) return msg.content for call in msg.tool_calls: # else run each requested tool name = call.function.name args = json.loads(call.function.arguments) result = tools_by_name[name](**args) messages.append(tool_result_message(call, result)) # feed back # loop repeats with tool results in context until a final answer
The instructor explicitly did not run file 06 in this class: “this is something I've not taught yet… I've not run this today, so why waste time here — I'll run it in front of you tomorrow.” The loop's control flow above is faithful to what he described (loop, branch on tool_calls, feed results back, cap at 4). The precise format of the appended tool-result message (tool_result_message(...)) was not shown and is left as a named placeholder rather than invented.
"22C, partly cloudy") isn't the final answer. You feed it back so the model can phrase the reply — “the weather in Delhi is 24°C, sunny.” On that second pass the model sees the tool result as the latest message and normally answers instead of calling more tools.None/timeout, wrap in try/except), and instruct the model — “if it timed out, tell the user we'll get back to them” — rather than surfacing a raw error. Covered properly once frameworks arrive.“No agentic class will start with Python. They never will.” The point of hand-building the loop is appreciation and control — once you've seen the loop bare, LangChain's one-line agent stops being magic and starts being a convenience you can reason about.
Scaling to many tools
When you register tools, the full schema of every tool — name, description, parameters, properties — is injected into the request on every call, whether or not a tool is needed. A student pushed on this hoping only name+description travels by default; the answer: “No, no — everything gets sent.” Trimming it is an optimization you build, not a default.
The instructor demonstrates the cost live: a hosted assistant with many connectors enabled ballooned a trivial “hi” from a few thousand tokens to ~30,000, purely from connected tool definitions. His analogy: giving someone 100 laptops and asking them to work — the sheer clutter degrades performance and cost. Weak models also start mis-selecting or skipping tools as the list grows.
When you have hundreds of tools: (1) use a stronger model; (2) send only name + description, and add a get_tool_information(name) tool that returns a full schema on demand; (3) split work across sub-agents; (4) keep descriptions crisp and non-overlapping. This mirrors how real assistants keep a fresh “hi” from dragging in every connector's full definition.
Q&A that sharpened the edges
get_weather vs get_weather_usa) — how does the model choose, and can it get confused?Instructor's worldview & production notes
“Will you get paid to use ChatGPT, or to create ChatGPT?” The entire vanilla-Python approach exists so you can control the smallest internals of an agent — that's what companies pay for, not the ability to wire up a framework agent “that everyone is creating.”
LangChain can define an agent in a few lines — and that's exactly why he delays it. “In a framework these things are hidden from you.” Learn the loop, the schema, the token cost bare; then the framework becomes a time-saver you can debug, not a black box.
Practical production themes he surfaced, all grounded in the session (nothing imported from future topics):
- Cost & tokens. The full transcript and every tool schema are billed on each call; watch
usage, cap output withmax_tokens, and trim history/tools deliberately. - Context bloat. Too many tools degrade both cost and model accuracy — lazy-load schemas and use sub-agents.
- Model capability. Tool selection, multi-tool calls, and instruction-following all scale with model strength; small models hallucinate tool choices.
- Data privacy. Feeding proprietary tool outputs back to a hosted model sends data out; self-hosted / open-source models keep it in-house.
- Reliability. Tool errors and timeouts are handled with ordinary guards (try/except, sentinels) plus a prompt that tells the model how to respond to failure.
- Freshness. For anything after the cutoff, attach a web-search tool (Tavily, Google, or a provider's built-in) — not the model alone.
All code lives in the course GitHub repo (linked from the “Agentic 3.0” master link); learners were asked to download it, open it in VS Code, and revise before the live class. Coming next: LangChain, then LangGraph and DeepAgents, taught from scratch — “nothing you need to know beforehand.” File 06's agent loop will be run and explained at the start of the next session.
Glossary
| Term | Meaning in this class |
|---|---|
| AI model | Stateless brain; one question in, one answer out, no memory. |
| Chatbot | Model + memory (a resent list of messages). |
| Agent | Model + memory + tools + a loop that calls tools and feeds results back. |
| System / user / assistant | The three message roles; the array is resent every call. |
| Stateless | By design, the model retains nothing between calls. |
| Knowledge cutoff | Training end date; nothing after it is known to the raw model. |
| Stub / mock | A fake response standing in for a real API call while building. |
| base_url | Endpoint address; swapping it (with a matching key) points OpenAI-style code at Groq/OpenRouter. |
| OpenAI-compatible | Provider that accepts the same request shape as OpenAI (e.g. Groq). |
| prompt / completion tokens | Input vs output tokens, reported in usage. |
| max_tokens | Cap on output length; a cost guardrail. |
| Pydantic model | BaseModel class declaring field names, types, defaults, validation. |
| Structured output | Getting a reliable JSON/typed object back instead of free text. |
| Tool | A function (or API) the agent can invoke. |
| Tool schema | JSON description of a tool: type, name, description, parameters, required. |
| tool_calls | The model's request to call tool(s) — a decision, not an executed result. |
| Agentic loop | Call model → run requested tool → feed result back → repeat until final answer (capped). |
| Sub-agent | A way to split large tool sets to manage context. |
Practice
Given a fresh chat where you send a single word, the API still bills thousands of tokens. In one sentence, explain why — and name the message role responsible.
Show answer
You have working OpenAI code. Turn it into a Groq call for llama-3.3-70b-versatile. Which two things change, and which stay identical?
Show answer
base_url to Groq's endpoint and the api_key to a Groq (gsk_…) key; set the model to a Groq-hosted name. The client.chat.completions.create(...) call shape, the messages array, and response.choices[0].message.content extraction stay identical — that's what "OpenAI-compatible" means. (Just changing the model name without the base_url won't work.)Write the tool schema for a get_capital(country) tool so a model can select and call it. Which field most determines whether the model picks it correctly over a similar tool?
Show answer
{ "type":"function",
"function":{
"name":"get_capital",
"description":"Return the capital city of a given country.",
"parameters":{
"type":"object",
"properties":{"country":{"type":"string","description":"country name, e.g. Japan"}},
"required":["country"] }}}The description is decisive — clear, non-overlapping descriptions are how the model disambiguates between similar tools.Trace the agentic loop for “What's the weather in Tokyo?” with only get_weather registered. How many times is the model called, and what is in the messages array on the second call?
Show answer
tool_calls for get_weather(city="Tokyo"). Your code runs the tool and appends its result (e.g. "22C, partly cloudy"). Call 2: messages now include the user question, the assistant's tool-call, and the tool result → seeing the result as the latest message, the model returns plain text ("The weather in Tokyo is 22°C, partly cloudy") with tool_calls = None, and the loop exits. The cap of 4 iterations prevents runaway looping.Your production agent has 200 tools and a small model keeps mis-selecting. Propose two changes grounded in this session and explain the trade-off of each.
Show answer
get_tool_information(name) tool; the model fetches a full schema only when needed. Trade-off: fewer tokens per call, but an extra round-trip when a schema is fetched. (2) Use a stronger model and/or split into sub-agents by domain. Trade-off: higher per-call cost or more orchestration complexity, but far more reliable tool selection. Both attack the same root cause: too much tool context degrades a weak model.Active recall
What are the three things an agent has that a bare model doesn't?
Why can't a plain model tell you yesterday's match result?
Where does the answer live in a chat completion response?
response.choices[0].message.content. Tool requests live at message.tool_calls.What does the model do when it "calls a tool"? Who actually runs it?
tool_calls). Your code executes it. The model never runs the tool itself.Whatever structured shape you request, what does the model actually return over the API?
json.loads, then cast into a Pydantic model.What is max_tokens for, and does it count input or output?
Groq vs Grok — what's the difference, and what error signals a mixup?
401 invalid API key.Why does sending 200 tools hurt, and how do you fix it?
Reconstruct the agentic loop from memory.
Why teach all this in vanilla Python before LangChain?
Session in 60 seconds
A raw model is a stateless brain: question in, answer out, no memory, frozen at its knowledge cutoff. Wrap it with a resent message list and you have a chatbot. To reach the real world you call a provider by API (chat.completions.create) — paid (OpenAI, Anthropic) or free/open-source (Groq, OpenRouter), all essentially the same call with a different base_url. Ask for structured output and shape it with Pydantic (remembering the reply is always a string). Describe functions as tool schemas and the model will tell you which tool to call with which arguments — but you run it. Put that in a loop that feeds tool results back to the model, and you have an agent. At scale, tool schemas bloat context, so lazy-load them.
Core Ideas
- Model = brain · Chatbot = +memory · Agent = +tools +loop
- System message + full history resent every call
- Model decides the tool; your code executes it
- The agentic loop is the whole difference
Common Mistakes
- Using an
xai-key against Groq (401) - Passing a function object instead of its schema
- Expecting the model to run tools itself
- Dumping 100s of tool schemas into every call
- Committing keys into
.envthat you push
Code patterns to keep
history.append({role,content}) then resend the list.client.chat.completions.create(model, messages, max_tokens)choices[0].message.content / .tool_callsbase_url + matching key.TOOLS_BY_NAME[name](**args)Instructor takeaways
- Learn to create the system, not just use it — that's the leverage.
- Every model is now just an API call.
- Better tool descriptions → better, less-confused tool selection.
- Frameworks hide the loop; the basics let you control it.