Agentic AI 3.0 · Class 5 · Python & Agents

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.

How to read this notebook. The left rail is your map. Colored callouts flag definitions, core ideas, instructor insight, tips, and warnings. Code blocks are tagged Exact (from the supplied file), Reconstructed (rebuilt from the recording with high confidence), or Pseudocode (structure only, where exact syntax wasn't recoverable). Where the recording was genuinely unclear, it says so rather than guessing.

Section 01

Model vs Chatbot vs Agent

The single distinction the whole course rests on — taught with boxes, not jargon.

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.

◆Instructor Insight

“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.

▣Definition · AI model

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.

●Core Idea

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.”

AI MODEL stateless · one-shot 🧠 Q ──▶ 🧠 ──▶ A remembers nothing CHATBOT AI + memory 🧠 🗂️ history[] resend full transcript memory grows → cost grows AGENT AI + memory + tools + loop 🧠 🗂️ memory 🔧 tools decide → call → feed back ↺ can act on the world
The same brain flows through all three. Each stage only adds a wrapper around it — never replaces it.
PropertyAI ModelChatbotAgent
MemoryNoneConversation history (list)History + tool results
ToolsNoneNoneYes (schemas passed in)
Can act on the world?NoNoYes, via tool calls
Runs a loop?NoNoYes — the agentic loop
Mental shorthanda brainbrain + memorybrain + memory + tools + loop
◈Why teach it in Python first

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.

Section 02

How messages really work

Three roles, one array — and why the system message is paid for on every single call.

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.

▣Definition · the three message roles

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.

Turn 2 request — the WHOLE array is sent again system "You are a helpful assistant…" ← resent every call user "hi, how are you?" assistant "Nice to meet you! …" user "Great, thanks for telling me." ← the only new part
Because the model is stateless, the client resends the full transcript. The system message rides along on every call — which is why a brand-new chat can already cost thousands of tokens.
▲Rule

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.

Is the system message optional / mandatory?
Optional. “It is just a good practice, nothing else.” A typical system message is around 4–5K tokens.
During a conversation, do all messages consume tokens, or just the latest input/output?
All of them. Every message in the array is sent as input and billed. This is the direct cause of rising cost and latency in long chats.
Why are models stateless?
By design — “they cannot maintain anything.” Memory is something you add around the model, not something the model has.
✦Practical Tip

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.

chatbot_memory.py — the memory patternReconstructed
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.

Section 03

The cutoff wall

Why a plain model can't tell you who won yesterday's match — and why that motivates tools.

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.

▣Definition · knowledge cutoff

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.

▲Rule

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.

◆Instructor Insight

“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.

Section 04

Calling a real model

Removing the mock — paid (OpenAI, Anthropic) and free (Groq, OpenRouter) — and reading the response.

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:

CategoryProvidersNotes from class
PaidOpenAI, Anthropic (Claude)Need a funded key; you pay per token.
Free (to a limit)Groq, OpenRouterGroq serves open-source models, is cheaper, and a few models are free up to a usage limit.
⚠Watch Out · the Groq vs Grok trap

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.”

ask_openai.pyReconstructed
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.

response = { "id": "chatcmpl-…", "model": "gpt-4o-mini", "choices": [ { "index": 0, "message": { "role": "assistant", "content": "…your answer is HERE…" } } ], "usage": { prompt_tokens:24, completion_tokens:30 } } choices[0].message.content
“In the backend, even when ChatGPT works, this is how it works.” You extract only the field you care about; the wrapper does this for you.

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.

▲Rule · what max_tokens is for

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.

◆Instructor Insight · one interface, every provider

“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.

ask_groq.py — same call, different base_urlReconstructed
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
Is the response structure the same for all providers?
No — it can depend on the provider and the model. That's part of why frameworks (LangChain, later) exist: they normalise this so you just say “this provider, this model” and stop hand-parsing choices[0]…. The instructor notes this hand-parsing was very much “a 2024 thing.”
Can't I just change the model name to a Groq model instead of the base_url?
No — it won't work. OpenAI's endpoint doesn't host Llama models. You must point at Groq's base_url with a Groq key.
By sending a system message, aren't we biasing the model?
No. A system message is standing context/instruction, not bias in the pejorative sense.
⚠Watch Out · keys leak easily

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.

Section 05

Structured output with Pydantic

Plain text is nice to read but hard to use. Downstream code needs structure it can trust.

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:

the shape you actually wantPseudocode
# 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" }
●Core Idea

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.

▣Definition · Pydantic model

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.

python_&_agents.py — EmailReplyExact
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.

RAW STRING ```json {…} ``` + stray text TRIM / CLEAN strip fences, prefixes, spaces json.loads → dict Pydantic typed model “Structure is in JSON, but it is always a string.” The wrapper just cleans and casts.
The manual extraction pipeline. In this file the instruction was sent as a user message (not a system message) — the model still complied.
structured_output.py — instruct, clean, castReconstructed
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"
If we still put the shape in the instruction, what's the point of the Pydantic class?
Here the class mainly gives you a reusable, validated target to cast into. The cleaner path — a proper structured-output mode where you pass the Pydantic model as a 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.”
Was that instruction a system message?
No — it was sent with role: user. The instructor printed it to prove the point: structured output doesn't require a system message; the user role worked fine.
Section 06

Giving the model a tool

First a mocked tool, then the tool schema — the description that lets the model use it well.

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).

tool_mock.py — a tool is just a functionReconstructed
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.pyReconstructed
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
        },
    },
}
① type What kind of tool — a callable function? ② name The exact function name the model should ask for. ③ description What it does / when to use it — the decisive field. ④ parameters properties (type + description) and which are required. "The better context you give your AI, the better it can use the tool."
The same schema shape that real connectors emit. It is context for the model, not logic for you.
◆Instructor Insight · description carries the meaning

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.

Is the schema structure fixed / mandatory?
Treat it as the standard — it's what OpenAI recommends and “everyone follows.” The real goal is simply to give the model all the information it needs; this shape does that well. You can even ask an assistant to generate the schema for you.
Why is city nested under properties instead of listed directly?
Because there can be more parameters later (e.g. wants_fahrenheit). properties is the bag of all input parameters; required lists the mandatory ones.
Is type: "function" the only type?
No. Providers add built-in types over time (web search, file search, and similar). The instructor's mental note: a "skill" is really just an added prompt/instruction of its own type.
Section 07

Letting the model decide

Send the question + the schema. The model replies with a tool call — a request, not a result.

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.

▲Rule · the model suggests, your code executes

“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.

question "weather in Tokyo?" + tool schema get_weather(...) 🧠 model decides tool_calls[0] name: "get_weather" arguments: {city:"Tokyo"} a request, NOT a result YOUR CODE runs get_weather("Tokyo") If the question needs no tool, the model just answers normally.
“Hi, how are you?” → plain reply, 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.

decide_with_ai.py — extract the model's tool callReconstructed
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:

python_&_agents.py — tool dispatchExact
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
✦Practical Tip

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.)

Can the model return multiple tool calls at once?
Yes. Ask for “weather in Tokyo and India” and it can return two 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.
What decides whether a tool is called at all?
The model, based on its training — “that's something the model is handling.” A capable model won't call a tool for “hi”; it will for “who won yesterday.” Small/weak models are less reliable at this (see Section 9).
What if the model names a tool that doesn't exist in my file?
You'll get an error when you try to dispatch it, or a wrong result. Guarding the dispatch (unknown-name handling) is on you.
Section 08

The agent & the loop

The one thing the model couldn't do — actually calling the tool — is what the agent adds.
●Core Idea · what makes it an agent

“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).

user question → messages[] 🧠 call the model tool_calls == None → return to user ✓ tool_calls present run each tool, get results append tool results → loop again capped at max_iterations = 4 to prevent an endless loop
The loop is the whole trick: model → (text ⇒ done) or (tool ⇒ run, feed back, repeat). The instructor set the iteration cap to 4.
agent.py (file 06) — the agentic loop skeletonReconstructed
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
⚠Deferred in session — do not over-trust the exact form

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.

Why an outer loop? After running the tools, why call the model again?
Because the tool's raw output (e.g. "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.
Isn't feeding my custom tool output back to a global model a data-privacy risk?
Yes — and that's why organisations use open-source / self-hosted models (via Foundry, AWS, Azure, etc.). Then your custom logic and results don't leave your environment. The instructor promised to build a “sensitive information” example in the agent session.
How is error handling done inside the loop?
The same way you handle any code: guard the tool (return a sentinel on 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.
◆Instructor Insight

“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.

Section 09

Scaling to many tools

Every tool schema rides in the context of every call. That's the real production problem.
▲Rule · everything is sent, every time

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.

Naïve: send ALL full schemas get_weather {…} get_capital {…} …198 more {…} huge context Optimized: names + descriptions first names + short desc + 1 lookup tool model: "I need get_weather's schema" get_tool_information → returns full schema now call the tool with args "That is what Claude/Sonnet does — it sends names, then fetches the full schema on demand."
Lazy schema loading: expose only names + short descriptions plus a “get full tool info” tool. The model asks for a schema only when it decides it needs one.
✦Practical Tip · production patterns for tool sprawl

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

Two similar tools (get_weather vs get_weather_usa) — how does the model choose, and can it get confused?
It's exactly like confusing a person: give someone “get_weather” and “get_weather_usa” with vague descriptions and they'll hesitate. The fix is the same you'd give a human — a clear instruction (“for US cities always use get_weather_usa”) baked into the description. A capable model then routes correctly at runtime; a weak model may still hallucinate. Description is the key, backed by good instructions.
Can I force a fixed sequence of tool calls?
The ordering/sequencing is decided by the AI at runtime, not pre-wired. You influence it via descriptions and instructions; you don't hard-order it in this vanilla setup.
A Groq Llama-8B skipped some tool calls — is that just the small model?
Yes. Small models miss multi-tool calls “because they don't have that much brain.” Even with a stronger system prompt and better descriptions it can still fail — the reliable fix is a bigger model (GPT-OSS-120B, larger DeepSeek, capable Anthropic/GPT).
Same question needs the same API twice (weather in Tokyo and India) — how?
The model returns two function calls for the same tool with different arguments; your code runs them (sequentially or in parallel — your choice).
Does calling the model still happen even if the answer is available locally?
Yes. Because you can't know in advance which query will come, the request always goes to the model with the tool context. You don't call tools directly bypassing the brain — that would defeat the point of letting the model decide.
Section 10

Instructor's worldview & production notes

The recurring beliefs that shaped every decision in the session.
◆Get paid to build, not to use

“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.”

◆Frameworks are convenience, understanding is leverage

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 with max_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.
◈Course logistics mentioned

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.

Section 11

Glossary

Only terms actually used in this session.
TermMeaning in this class
AI modelStateless brain; one question in, one answer out, no memory.
ChatbotModel + memory (a resent list of messages).
AgentModel + memory + tools + a loop that calls tools and feeds results back.
System / user / assistantThe three message roles; the array is resent every call.
StatelessBy design, the model retains nothing between calls.
Knowledge cutoffTraining end date; nothing after it is known to the raw model.
Stub / mockA fake response standing in for a real API call while building.
base_urlEndpoint address; swapping it (with a matching key) points OpenAI-style code at Groq/OpenRouter.
OpenAI-compatibleProvider that accepts the same request shape as OpenAI (e.g. Groq).
prompt / completion tokensInput vs output tokens, reported in usage.
max_tokensCap on output length; a cost guardrail.
Pydantic modelBaseModel class declaring field names, types, defaults, validation.
Structured outputGetting a reliable JSON/typed object back instead of free text.
ToolA function (or API) the agent can invoke.
Tool schemaJSON description of a tool: type, name, description, parameters, required.
tool_callsThe model's request to call tool(s) — a decision, not an executed result.
Agentic loopCall model → run requested tool → feed result back → repeat until final answer (capped).
Sub-agentA way to split large tool sets to manage context.
Section 12

Practice

Derived from what was taught this session — not from future topics.
✎ Generated PracticeLevel 1 · Recall

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
Because the model is stateless, the client resends the whole message array on every call — and that includes the system message (often several thousand tokens), which is transmitted every time, not just on turn one.
✎ Generated PracticeLevel 2 · Apply

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
Change the 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.)
✎ Generated PracticeLevel 2 · Apply

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.
✎ Generated PracticeLevel 3 · Reason

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
The model is called twice. Call 1: messages = [user question] + tool schema → the model returns a 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.
✎ Generated PracticeLevel 3 · Reason

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
(1) Lazy-load schemas — send only names + short descriptions plus a 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.
Section 13

Active recall

Cover the answers. Say each aloud before revealing.
What are the three things an agent has that a bare model doesn't?
Memory, tools, and a loop — the agent calls tools for you and feeds results back to the model, repeating until it can answer.
Why can't a plain model tell you yesterday's match result?
Knowledge cutoff — it only knows data up to its training end date and has no real-world/web access by default. You must give it a web-search tool.
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?
The model only tells you which tool and with what arguments (via 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?
Always a string. A JSON reply is a JSON-shaped string (sometimes fenced), which you clean, json.loads, then cast into a Pydantic model.
What is max_tokens for, and does it count input or output?
A guardrail capping the model's output length (per OpenAI) to prevent runaway cost from a malicious/huge request. Some providers count both — treat it as output.
Groq vs Grok — what's the difference, and what error signals a mixup?
Groq (gsk_ keys) = fast open-source inference, OpenAI-compatible. Grok/xAI (xai- keys) = a different chatbot. Using the wrong key gives a 401 invalid API key.
Why does sending 200 tools hurt, and how do you fix it?
Every tool's full schema is injected into every call — bloating context, cost, and confusing weaker models. Fix: send names+descriptions, lazy-load full schemas on demand, use sub-agents and stronger models.
Reconstruct the agentic loop from memory.
Put the user question in messages → call model with tools → if no tool_calls, return the text; else run each requested tool, append results, and loop again → cap iterations (4) to avoid infinite loops.
Why teach all this in vanilla Python before LangChain?
So the internals aren't magic. Frameworks hide the loop, schema, and token cost; understanding them bare is what lets you control and debug real agents — and what gets you paid.
Section 14

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 .env that you push

Code patterns to keep

Memory
history.append({role,content}) then resend the list.
Real call
client.chat.completions.create(model, messages, max_tokens)
Read reply
choices[0].message.content / .tool_calls
Provider swap
Same call + base_url + matching key.
Tool schema
type · name · description · parameters · required
Dispatch
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.