15 Chapters • Project-Based • Python

Build AI Agents
From Nothing

A practical, hands-on guide to building AI agents with Python. From zero to a fully deployed agent with calendar, email, CRM, and web search tools.

AI Agent
More than enough
The Product

The Book

chapters. Zero to deployed AI agent. Read it in your browser after purchase.

Free Preview — Chapter 1


## 📖 Chapter 1 – Shipping Your First AI Agent (Part 1 of 4)

---

## Before You Start

- A computer with internet access
- Basic Python knowledge (variables, functions, loops)
- ~2 hours of focused time
- You do NOT need: a GPU, a server, or prior AI experience



### Get Your OpenAI API Key
> **What is an API?** An API (Application Programming Interface) is how one program talks to another. Think of it like a waiter in a restaurant — you (your code) tell the waiter (API) what you want, the waiter goes to the kitchen (the server), and brings back your food (the response). You never talk to the kitchen directly.



1. Go to **https://platform.openai.com/api-keys**
2. Log in (free account)
3. Click **"Create new secret key"**
4. Name it (e.g., "adhikk")
5. Click **"Create"**
6. **Copy the key** (starts with `sk-`) — you will not see it again
7. Paste into your `.env` file

> New accounts get **$5 free credit** — enough for this entire book.



###

...and 12 more sections in this chapter alone. 14 more chapters after this.

🔒 Full Book Locked

Unlock all 15 chapters with a one-time purchase.

1. Meet the Agent You Are About to Build2. Getting Your Python Environment Sorted3. The Python You Need for GenAI4. Making Your First API Call5. Talking to an LLM from Python6. From LLM to Agent7. How Agents Reason, Remember, and Act8. Building Your First Resume Q&A Agent9. Building Agents with the OpenAI Agents SDK10. Feeding LinkedIn Data into Your Agent11. Wiring Up Email and Notifications12. Giving Your Agent Real Tools: Calendar, Search, and CRM13. Shipping Your Agent to Production14. The Final Project: Putting It All Together15. Appendix
Unlock for ₹4,399 ₹1,999

After payment, you will receive an access code via email.

Already purchased? Enter your code:

Invalid code. Check your email.

Chapter 1

Meet the Agent You Are About to Build

📖 Chapter 1 – Shipping Your First AI Agent (Part 1 of 4)


Before You Start

  • A computer with internet access
  • Basic Python knowledge (variables, functions, loops)
  • ~2 hours of focused time
  • You do NOT need: a GPU, a server, or prior AI experience

Get Your OpenAI API Key

What is an API? An API (Application Programming Interface) is how one program talks to another. Think of it like a waiter in a restaurant — you (your code) tell the waiter (API) what you want, the waiter goes to the kitchen (the server), and brings back your food (the response). You never talk to the kitchen directly.

  1. Go to https://platform.openai.com/api-keys
  2. Log in (free account)
  3. Click "Create new secret key"
  4. Name it (e.g., "adhikk")
  5. Click "Create"
  6. Copy the key (starts with sk-) — you will not see it again
  7. Paste into your .env file

New accounts get $5 free credit — enough for this entire book.

Create & Activate Virtual Environment

What is a virtual environment (venv)? It is a separate, isolated Python world for your project. Without it, packages from different projects can conflict. With a venv, each project gets its own clean set of packages. Think of it like a separate browser profile for each website.

uv venv

Activate (Windows PowerShell):

.venv\Scripts\Activate

Activate (Mac/Linux):

source .venv/bin/activate

How you'll know it worked: Your prompt shows (.venv) at the start.

Install Python (if not already installed)

  1. Go to https://www.python.org/downloads/
  2. Click "Download Python 3.12.x"
  3. Run the installer
  4. CHECK the box: Add python.exe to PATH

    What is PATH? PATH is a list of folders your computer checks when you type a command. If you type python, your computer looks in each PATH folder for a file called python.exe. If it finds it, it runs it. If not, you get "command not found." Installing Python with "Add to PATH" checked adds Python's folder to this list.

  5. Click "Install Now"

  6. Close and reopen your terminal

Verify:

python --version

Expected output:

Python 3.12.x

Why This Matters – In today’s job market a static résumé is no longer enough. A 24 × 7 AI‑powered digital avatar can field recruiter messages, schedule interviews, and even nurture leads while you focus on building great software. This chapter shows you how to lay the foundation for that avatar.


🎯 What You’ll Build

By the end of this chapter you will have a runnable skeleton for a personal AI assistant that:

  1. Runs as a FastAPI web service (so you can embed it anywhere).
  2. Talks to OpenAI’s GPT‑4o (or any compatible LLM).
  3. Exposes a simple JSON API that accepts a user message and returns the assistant’s reply.
  4. Registers eight “tool” placeholders that we will flesh out in later chapters (e.g., calendar, email, CRM, web‑search).

You won’t see the full‑blown functionality yet, but the project layout will be ready for the next three chapters.


📚 Content

Section Description
1️⃣ Environment Setup Install Python, create a virtual environment, and pull in required packages.
2️⃣ Project Scaffold Directory layout, requirements.txt, and .env for secrets.
3️⃣ Core Engine assistant.py – a thin wrapper around the LLM that knows how to call tools.
4️⃣ API Layer main.py – FastAPI entry point that receives user messages.
5️⃣ Tool Registry tools/__init__.py – a dictionary of stub functions representing the eight future tools.
6️⃣ Run & Test Spin up the server locally and hit the /chat endpoint with curl.

📁 Project Files

Below is the exact file tree you should end up with:

ai-digital-assistant/
├─.env
├─ requirements.txt
├─ config.py
├─ assistant.py
├─ main.py
└─ tools/
 ├─ __init__.py
 ├─ calendar.py
 ├─ email.py
 ├─ crm.py
 ├─ websearch.py
 └─. (four more placeholders)

💡 Tip: Keep the repository under version control from day 1 (git init && git add. && git commit -m "Initial scaffold"). It makes later refactors painless.


1️⃣ requirements.txt

# Save as: requirements.txt
fastapi==0.110.0
uvicorn[standard]==0.27.0
python-dotenv==1.0.1
openai==1.30.0
pydantic==2.7.0

Verify: Run pip install -r requirements.txt inside a clean virtual environment. No errors should appear.


2️⃣ .env

# Save as:.env
OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

⚠️ Warning: Never commit your .env file to a public repo. Add it to .gitignore.

Verify: After installing python-dotenv, you can test loading the variable:

python -c "from dotenv import load_dotenv, find_dotenv; load_dotenv(find_dotenv()); import os; print('Key loaded' if os.getenv('OPENAI_API_KEY') else 'Missing')"

Expected output:

Key loaded

3️⃣ config.py

# Save as: config.py
import os
from dotenv import load_dotenv

load_dotenv() # Pull values from.env into os.environ

OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
if not OPENAI_API_KEY:
 raise EnvironmentError("OPENAI_API_KEY not set in environment")

Verify: Import the module in a REPL:

>>> import config
>>> config.OPENAI_API_KEY[:5]'sk-xx'

4️⃣ tools/__init__.py

# Save as: tools/__init__.py
"""
Placeholder implementations for the eight tools we will flesh out later.
Each tool receives a dictionary `payload` and returns a string result.
"""

def calendar_tool(payload: dict) -> str:
 """Stub for scheduling meetings."""
 return "🗓️ Calendar tool invoked – not yet implemented."

def email_tool(payload: dict) -> str:
 """Stub for sending emails."""
 return "✉️ Email tool invoked – not yet implemented."

def crm_tool(payload: dict) -> str:
 """Stub for personal CRM actions."""
 return "📇 CRM tool invoked – not yet implemented."

def websearch_tool(payload: dict) -> str:
 """Stub for live web searches."""
 return "🔎 Web‑search tool invoked – not yet implemented."

# Additional four placeholders
def placeholder_tool_5(payload: dict) -> str: return "🔧 Placeholder 5"
def placeholder_tool_6(payload: dict) -> str: return "🔧 Placeholder 6"
def placeholder_tool_7(payload: dict) -> str: return "🔧 Placeholder 7"
def placeholder_tool_8(payload: dict) -> str: return "🔧 Placeholder 8"

# Registry that the assistant can look up by name
TOOL_REGISTRY = {
 "calendar": calendar_tool,
 "email": email_tool,
 "crm": crm_tool,
 "websearch": websearch_tool,
 "tool5": placeholder_tool_5,
 "tool6": placeholder_tool_6,
 "tool7": placeholder_tool_7,
 "tool8": placeholder_tool_8,
}

Verify: In a Python shell:

>>> from tools import TOOL_REGISTRY
>>> TOOL_REGISTRY["calendar"]({})
'🗓️ Calendar tool invoked – not yet implemented.'

5️⃣ assistant.py

# Save as: assistant.py
import json
from typing import Any, Dict, List
import openai
from config import OPENAI_API_KEY
from tools import TOOL_REGISTRY

openai.api_key = OPENAI_API_KEY

class AIAgent:
 """
 Thin wrapper around OpenAI’s chat completion endpoint.
 It knows how to:
 • Send the user’s message to the LLM.
 • Parse tool calls embedded in the LLM’s response.
 • Dispatch to the appropriate stub function.
 """
 def __init__(self, model: str = "gpt-4o-mini"):
 self.model = model

 def _format_message(self, user_msg: str) -> List[Dict[str, str]]:
 """Create the message payload for the chat API."""
 system_prompt = (
 "You are a digital assistant representing a software engineer. "
 "You can call the following tools when needed: "
 + ", ".join(TOOL_REGISTRY.keys())
 + ". Respond in JSON when you want to invoke a tool."
 )
 return [
 {"role": "system", "content": system_prompt},
 {"role": "user", "content": user_msg},
 ]

 def _parse_tool_response(self, response: str) -> Dict[str, Any]:
 """
 The LLM is instructed to reply with a JSON object:
 {
 "tool": "<tool_name>",
 "payload": {. }
 }
 If the response is not JSON, we treat it as a plain answer.
 """
 try:
 data = json.loads(response)
 if "tool" in data and "payload" in data:
 return data
 except json.JSONDecodeError:
 pass
 return {"answer": response.strip()}

 def _run_tool(self, tool_name: str, payload: dict) -> str:
 """Dispatch to the stub implementation."""
 tool_fn = TOOL_REGISTRY.get(tool_name)
 if not tool_fn:
 return f"❗ Unknown tool: {tool_name}"
 return tool_fn(payload)

 def chat(self, user_msg: str) -> str:
 """Main entry point – returns the final string to send back to the user."""
 messages = self._format_message(user_msg)

 # Call the LLM
 completion = openai.ChatCompletion.create(
 model=self.model,
 messages=messages,
 temperature=0.2,
 )
 raw_reply = completion.choices[0].message.content

 # Determine if the LLM wants to call a tool
 parsed = self._parse_tool_response(raw_reply)

 if "answer" in parsed:
 # Simple answer – no tool needed
 return parsed["answer"]
 else:
 # Tool call path
 tool_name = parsed["tool"]
 payload = parsed["payload"]
 tool_result = self._run_tool(tool_name, payload)
 # Return a combined message for now
 return f"Tool `{tool_name}` executed. Result: {tool_result}"

Verify: Run a quick sanity check (no network call yet because we’ll use a mock later):

>>> from assistant import AIAgent
>>> agent = AIAgent()
>>> # Mock the LLM by monkey‑patching openai.ChatCompletion.create if you have no API key.
>>> # For now just ensure the object instantiates without error.
>>> isinstance(agent, AIAgent)
True

6️⃣ main.py

# Save as: main.py
import uvicorn
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from assistant import AIAgent

app = FastAPI(title="Personal AI Digital Assistant")
agent = AIAgent() # Singleton – cheap to keep in memory

class ChatRequest(BaseModel):
 message: str

class ChatResponse(BaseModel):
 reply: str

@app.post("/chat", response_model=ChatResponse)
def chat_endpoint(req: ChatRequest):
 """
 Accepts a user message, forwards it to the AIAgent,
 and returns the assistant’s reply.
 """
 if not req.message.strip():
 raise HTTPException(status_code=400, detail="Message cannot be empty")
 reply = agent.chat(req.message)
 return ChatResponse(reply=reply)

if __name__ == "__main__":
 # Run with: python main.py
 uvicorn.run(app, host="0.0.0.0", port=8000)

Verify: Start the server and hit the endpoint:

python main.py

In another terminal:

curl -X POST http://127.0.0.1:8000/chat \
 -H "Content-Type: application/json" \
 -d '{"message":"Tell me about my experience"}'

Expected output (the exact wording may vary because the LLM generates it):

{
 "reply": "I am a software engineer with 5 years of experience building scalable web applications."
}

If the LLM decides to call a tool, you’ll see something like:

{
 "reply": "Tool `calendar` executed. Result: 🗓️ Calendar tool invoked – not yet implemented."
}

🚀 What You Just Built

  • A FastAPI service listening on /chat.
  • An AI agent that can either answer directly or invoke one of eight stub tools.
  • A tool registry ready to be expanded with real implementations (calendar, email, CRM, web‑search, etc.).

You now have a clean, testable code base that any junior developer can run locally and extend.


❌ Common Mistakes

Mistake Why it Happens Fix
Missing .env config.py raises EnvironmentError. Create .env with OPENAI_API_KEY.
Wrong Python version Some libraries need ≥ 3.10. Use python -V to confirm; install a newer version if needed.
Running uvicorn without the if __name__ == "__main__" guard The script may start multiple workers unintentionally. Keep the guard as shown.
Expecting tool output without implementation Stubs return placeholder strings. Remember they are placeholders; real logic comes in later chapters.

🛠️ Troubleshooting

Symptom Likely Cause Remedy
ImportError: No module named 'openai' Dependencies not installed. Run pip install -r requirements.txt.
401 Client Error: Unauthorized from OpenAI Invalid or missing API key. Verify OPENAI_API_KEY in .env.
Server returns 500 with “JSONDecodeError” LLM replied with plain text instead of JSON. Adjust the system prompt or increase temperature to encourage JSON format.
uvicorn cannot bind to port 8000 Port already in use. Kill the existing process or change the port in main.py.

📌 Key Takeaways

  • Scaffold first, polish later. A well‑organized project layout saves countless hours when you start wiring real tools.
  • Separate concerns: assistant.py handles LLM logic, tools/ holds side‑effects, and main.py is the HTTP façade.
  • Stub everything until you’re ready to implement. This lets you test the integration pipeline early.

🧪 Try It Yourself

  1. Add a new stub – create tools/notes.py with a function that returns a static note. Register it in tools/__init__.py under the key "notes".
  2. Update the system prompt in assistant.py to mention the new "notes" tool.
  3. Restart the server and send a message like “Create a note about my latest project”.
  4. Observe the reply: it should say the notes tool was executed.

Once you’re comfortable, the next chapter will replace those stubs with real integrations (Google Calendar API, SendGrid, a tiny SQLite CRM, and a live web‑search wrapper). Happy coding!

Why This Matters

Modern AI agents are no longer just “play‑around” scripts. Companies expect them to talk to real services, store data securely, and run 24/7 in the cloud. By the end of this chapter you will have a production‑ready skeleton that:

  • Connects a large language model (LLM) to external tools (LinkedIn, email, calendar, web search, CRM).
  • Routes user intents to the correct tool automatically.
  • Exposes a FastAPI endpoint so anyone can call the agent over HTTP.

You’ll see how enterprises structure such systems, which will make the next chapters (adding advanced skills, scaling, monitoring) much easier.


What You’ll Build

A modular Python package called ai_assistant with the following components:

Module Responsibility
core/agent.py Central LLM orchestrator, decides which tool to invoke.
tools/linkedin.py Minimal LinkedIn profile fetcher (demo‑only, uses a mock).
tools/emailer.py Sends email notifications via SMTP.
tools/calendar.py Creates Google Calendar events (uses service account).
tools/websearch.py Performs live web searches via SerpAPI.
tools/crm.py Persists unknown questions in a SQLite “personal CRM”.
api/server.py FastAPI server exposing /chat endpoint.
config.py Centralised configuration & secret handling.

The whole stack can be run locally with a single command and later deployed to any cloud provider.


Content

1️⃣ Set Up the Development Environment

# Save as: setup.sh
python -m venv.venv
source.venv/bin/activate
pip install --upgrade pip
pip install fastapi uvicorn openai python-dotenv requests google-auth google-auth-oauthlib google-api-python-client sqlalchemy

Verify: After running source.venv/bin/activate && python -c "import fastapi" you should see no import errors.


2️⃣ Central Configuration (config.py)

# Save as: config.py
import os
from pathlib import Path
from dotenv import load_dotenv

BASE_DIR = Path(__file__).resolve().parent
load_dotenv(BASE_DIR / ".env")

class Settings:
 # LLM
 OPENAI_API_KEY: str = os.getenv("OPENAI_API_KEY")

 # Email
 SMTP_SERVER: str = os.getenv("SMTP_SERVER", "smtp.gmail.com")
 SMTP_PORT: int = int(os.getenv("SMTP_PORT", "587"))
 EMAIL_USER: str = os.getenv("EMAIL_USER")
 EMAIL_PASS: str = os.getenv("EMAIL_PASS")

 # Google Calendar (service account JSON path)
 GOOGLE_SERVICE_ACCOUNT_FILE: str = os.getenv("GOOGLE_SERVICE_ACCOUNT_FILE")

 # SerpAPI (web search)
 SERPAPI_KEY: str = os.getenv("SERPAPI_KEY")

 # Mock LinkedIn token (demo only)
> **What is a token?** A token is how AI models count text. Think of it as a "word piece" — roughly 3-4 characters. "Hello" = 1 token. "Hello world" = 2 tokens. Models have a limit on how many tokens they can process at once (called the "context window").


 LINKEDIN_TOKEN: str = os.getenv("LINKEDIN_TOKEN", "mock-token")

settings = Settings()

Create a .env file next to config.py (never commit real secrets!):

# Save as:.env
OPENAI_API_KEY=sk-.
SMTP_SERVER=smtp.gmail.com
SMTP_PORT=587
EMAIL_USER=you@example.com
EMAIL_PASS=your_app_password
GOOGLE_SERVICE_ACCOUNT_FILE=service_account.json
SERPAPI_KEY=your_serpapi_key
LINKEDIN_TOKEN=mock-token

Verify: Run python -c "from config import settings; print(settings.OPENAI_API_KEY[:5])" – it should print the first five characters of your key.


3️⃣ Tool Abstractions

All tools inherit from a simple BaseTool interface.

# Save as: tools/base.py
from abc import ABC, abstractmethod
from typing import Any, Dict

class BaseTool(ABC):
 """All tool classes must implement `run`."""

 @abstractmethod
 def run(self, **kwargs) -> Dict[str, Any]:
 """Execute the tool and return a JSON‑serialisable dict."""
 pass

Verify: Import the class without errors: python -c "from tools.base import BaseTool".


4️⃣ LinkedIn Mock (tools/linkedin.py)

# Save as: tools/linkedin.py
from.base import BaseTool
from config import settings

class LinkedInTool(BaseTool):
 """Fetch a public LinkedIn profile (mocked for demo)."""

 def run(self, profile_url: str) -> dict:
 # In a real implementation you would call LinkedIn's API.
 # Here we just simulate a response.
 if not profile_url.startswith("https://www.linkedin.com/in/"):
 return {"error": "Invalid LinkedIn URL"}
 username = profile_url.rstrip("/").split("/")[-1]
 return {
 "name": f"{username.title()} (Mock)",
 "headline": "Software Engineer at Example Corp",
 "location": "San Francisco, CA",
 "url": profile_url,
 }

Verify:

# Verify snippet
from tools.linkedin import LinkedInTool
print(LinkedInTool().run(profile_url="https://www.linkedin.com/in/jdoe/"))

Expected output

{
 "name": "Jdoe (Mock)",
 "headline": "Software Engineer at Example Corp",
 "location": "San Francisco, CA",
 "url": "https://www.linkedin.com/in/jdoe/"
}

5️⃣ Email Notification (tools/emailer.py)

# Save as: tools/emailer.py
import smtplib
from email.mime.text import MIMEText
from.base import BaseTool
from config import settings

class EmailTool(BaseTool):
 """Send an email via SMTP."""

 def run(self, to: str, subject: str, body: str) -> dict:
 msg = MIMEText(body, "plain")
 msg["Subject"] = subject
 msg["From"] = settings.EMAIL_USER
 msg["To"] = to

 try:
 with smtplib.SMTP(settings.SMTP_SERVER, settings.SMTP_PORT) as server:
 server.starttls()
 server.login(settings.EMAIL_USER, settings.EMAIL_PASS)
 server.send_message(msg)
 return {"status": "sent", "to": to}
 except Exception as e:
 return {"status": "failed", "error": str(e)}

Verify:

# Verify snippet (replace with your own email)
# from tools.emailer import EmailTool
# print(EmailTool().run(to="you@example.com", subject="Test", body="Hello from AI Assistant!"))

You should see {"status": "sent", "to": "you@example.com"} if credentials are correct.


6️⃣ Calendar Integration (tools/calendar.py)

# Save as: tools/calendar.py
import datetime
from google.oauth2 import service_account
from googleapiclient.discovery import build
from.base import BaseTool
from config import settings

SCOPES = ["https://www.googleapis.com/auth/calendar.events"]

class CalendarTool(BaseTool):
 """Create a Google Calendar event."""

 def __init__(self):
 credentials = service_account.Credentials.from_service_account_file(
 settings.GOOGLE_SERVICE_ACCOUNT_FILE, scopes=SCOPES
 )
 self.service = build("calendar", "v3", credentials=credentials)

 def run(self, summary: str, start: str, end: str, timezone: str = "UTC") -> dict:
 """
 `start` and `end` must be ISO‑8601 strings, e.g. "2024-10-01T10:00:00".
 """
 event = {
 "summary": summary,
 "start": {"dateTime": start, "timeZone": timezone},
 "end": {"dateTime": end, "timeZone": timezone},
 }
 try:
 created = self.service.events().insert(calendarId="primary", body=event).execute()
 return {"status": "created", "eventId": created.get("id")}
 except Exception as e:
 return {"status": "failed", "error": str(e)}

Verify:

# Verify snippet (requires a valid service account JSON)
# from tools.calendar import CalendarTool
# now = datetime.datetime.utcnow()
# start = (now + datetime.timedelta(minutes=5)).isoformat()
# end = (now + datetime.timedelta(minutes=35)).isoformat()
# print(CalendarTool().run(summary="Demo Meeting", start=start, end=end))

You should receive a JSON response with "status": "created" and an eventId.


7️⃣ Live Web Search (tools/websearch.py)

# Save as: tools/websearch.py
import requests
from.base import BaseTool
from config import settings

class WebSearchTool(BaseTool):
 """Search the web using SerpAPI (Google results)."""

 ENDPOINT = "https://serpapi.com/search"

 def run(self, query: str, num_results: int = 3) -> dict:
 params = {
 "engine": "google",
 "q": query,
 "api_key": settings.SERPAPI_KEY,
 "num": num_results,
 }
 try:
 resp = requests.get(self.ENDPOINT, params=params, timeout=10)
 resp.raise_for_status()
 data = resp.json()
 results = [
 {"title": r.get("title"), "link": r.get("link"), "snippet": r.get("snippet")}
 for r in data.get("organic_results", [])[:num_results]
 ]
 return {"query": query, "results": results}
 except Exception as e:
 return {"error": str(e)}

Verify:

# Verify snippet
# from tools.websearch import WebSearchTool
# print(WebSearchTool().run(query="Python virtual environments"))

You should see a JSON object with up to three search results.


8️⃣ Personal CRM (SQLite) (tools/crm.py)

# Save as: tools/crm.py
import datetime
import sqlite3
from pathlib import Path
from.base import BaseTool

DB_PATH = Path(__file__).parent / "crm.db"

class CRMTool(BaseTool):
 """Store unknown questions for later review."""

 def __init__(self):
 self.conn = sqlite3.connect(DB_PATH)
 self._ensure_table()

 def _ensure_table(self):
 self.conn.execute(
 """
 CREATE TABLE IF NOT EXISTS unknown_questions (
 id INTEGER PRIMARY KEY AUTOINCREMENT,
 question TEXT NOT NULL,
 received_at TEXT NOT NULL
 )
 """
 )
 self.conn.commit()

 def run(self, question: str) -> dict:
 now = datetime.datetime.utcnow().isoformat()
 self.conn.execute(
 "INSERT INTO unknown_questions (question, received_at) VALUES (?, ?)",
 (question, now),
 )
 self.conn.commit()
 return {"status": "recorded", "question": question, "timestamp": now}

 def list_all(self) -> dict:
 cur = self.conn.execute("SELECT id, question, received_at FROM unknown_questions")
 rows = [{"id": r[0], "question": r[1], "received_at": r[2]} for r in cur.fetchall()]
 return {"questions": rows}

Verify:

# Verify snippet
# from tools.crm import CRMTool
# crm = CRMTool()
# print(crm.run(question="What is the best wine for sushi?"))
# print(crm.list_all())

You should see a recorded response followed by a list containing that question.


9️⃣ Core Agent (core/agent.py)

# Save as: core/agent.py
import json
from typing import Any, Dict
import openai
from config import settings
from tools.linkedin import LinkedInTool
from tools.emailer import EmailTool
from tools.calendar import CalendarTool
from tools.websearch import WebSearchTool
from tools.crm import CRMTool

class AIAssistant:
 """Orchestrates LLM responses and routes to appropriate tools."""

 def __init__(self):
 openai.api_key = settings.OPENAI_API_KEY
 # Instantiate tools once (reuse connections)
 self.tools = {
 "linkedin": LinkedInTool(),
 "email": EmailTool(),
 "calendar": CalendarTool(),
 "websearch": WebSearchTool(),
 "crm": CRMTool(),
 }

 def _call_llm(self, user_msg: str) -> dict:
 """Ask the LLM to decide which tool (if any) to use."""
 system_prompt = (
 "You are an AI assistant that can call external tools. "
 "When you need to use a tool, respond with a JSON object: "
 '{"tool": "<tool_name>", "args": {.}}. "
 "If you cannot answer, say you don't know and record the question in the CRM. '
 "Only use the following tool names: linkedin, email, calendar, websearch, crm. "
 "If no tool is needed, respond with a plain text answer."
 )
 response = openai.ChatCompletion.create(
 model="gpt-4o-mini",
 messages=[
 {"role": "system", "content": system_prompt},
 {"role": "user", "content": user_msg},
 ],
 temperature=0,
 )
 reply = response.choices[0].message["content"].strip()
 return {"raw": reply}

 def _parse_tool_call(self, reply: str) -> Dict[str, Any]:
 """Detect JSON tool call; fallback to plain text."""
 try:
 payload = json.loads(reply)
 if isinstance(payload, dict) and "tool" in payload:
 return {"tool": payload["tool"], "args": payload.get("args", {})}
 except json.JSONDecodeError:
 pass
 return {"plain": reply}

 def handle_message(self, user_msg: str) -> dict:
 llm_output = self._call_llm(user_msg)
 parsed = self._parse_tool_call(llm_output["raw"])

 # 1️⃣ Plain answer – no tool needed
 if "plain" in parsed:
 return {"answer": parsed["plain"]}

 # 2️⃣ Tool invocation
 tool_name = parsed["tool"]
 args = parsed["args"]
 tool = self.tools.get(tool_name)

 if not tool:
 return {"error": f"Tool '{tool_name}' not recognized."}

 # Special case: unknown question → store in CRM
 if tool_name == "crm":
 result = tool.run(question=user_msg)
 return {"answer": "I don't know the answer yet. I've recorded your question for later.", "crm": result}

 # Normal tool execution
 result = tool.run(**args)
 return {"tool": tool_name, "result": result}

Verify:

# Verify snippet
# from core.agent import AIAssistant
# assistant = AIAssistant()
# print(assistant.handle_message("Find the LinkedIn profile of Linus Torvalds"))
# print(assistant.handle_message("Schedule a meeting tomorrow at 3pm called 'Project Sync'"))
# print(assistant.handle_message("What is the best wine for sushi?"))

Expected sample output (exact IDs will differ):

{
 "tool": "linkedin",
 "result": {
 "name": "Linus Torvalds (Mock)",
 "headline": "Software Engineer at Example Corp",
 "location": "San Francisco, CA",
 "url": "https://www.linkedin.com/in/linus-torvalds/"
 }
}
{
 "tool": "calendar",
 "result": {
 "status": "created",
 "eventId": "abc123def456"
 }
}
{
 "answer": "I don't know the answer yet. I've recorded your question for later.",
 "crm": {
 "status": "recorded",
 "question": "What is the best wine for sushi?",
 "timestamp": "2024-09-15T12:34:56.789012"
 }
}

🔟 FastAPI Server (api/server.py)

# Save as: api/server.py
import uvicorn
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from core.agent import AIAssistant

app = FastAPI(title="AI Assistant API")
assistant = AIAssistant()

class ChatRequest(BaseModel):
 message: str

class ChatResponse(BaseModel):
 response: dict

@app.post("/chat", response_model=ChatResponse)
def chat(req: ChatRequest):
 try:
 result = assistant.handle_message(req.message)
 return ChatResponse(response=result)
 except Exception as e:
 raise HTTPException(status_code=500, detail=str(e))

if __name__ == "__main__":
 # Run with: python -m uvicorn api.server:app --reload
 uvicorn.run("api.server:app", host="0.0.0.0", port=8000, reload=True)

Verify:

# In a terminal
python -m uvicorn api.server:app --reload

Then, in another terminal:

curl -X POST http://127.0.0.1:8000/chat \
 -H "Content-Type: application/json" \
 -d '{"message":"What is the capital of France?"}'

Expected output

{
 "response": {
 "answer": "Paris is the capital of France."
 }
}

📁 Project Files

ai_assistant/
│
├─.env # secret keys (never commit)
├─ config.py # central configuration
│
├─ tools/
│ ├─ __init__.py
│ ├─ base.py
│ ├─ linkedin.py
│ ├─ emailer.py
│ ├─ calendar.py
│ ├─ websearch.py
│ └─ crm.py
│
├─ core/
│ ├─ __init__.py
│ └─ agent.py
│
├─ api/
│ ├─ __init__.py
│ └─ server.py
│
└─ requirements.txt # optional – generated via `pip freeze > requirements.txt`

🚀 What You Just Built

Feature How It Works
LLM orchestration AIAssistant asks GPT‑4o‑mini to decide whether a tool is needed and returns either plain text or a JSON tool call.
Tool wrappers Each external service lives in tools/ and follows the BaseTool contract, making them interchangeable and testable.
Personal CRM Unknown questions are persisted in a tiny SQLite DB for later manual enrichment.
FastAPI endpoint /chat receives a user message, runs the agent, and returns a structured JSON response.
Production‑ready layout Clear separation of concerns, single source of truth for secrets, and reusable components.

You now have a complete, runnable codebase that can be deployed to any cloud platform (AWS, GCP, Azure, Railway, Render, etc.) with a single docker build later.


Common Mistakes

Mistake Why It Happens Fix
Missing environment variables The app crashes on import. Always run source.venv/bin/activate && python -c "import config" first.
Using a personal Gmail password Google blocks sign‑in from “less secure apps”. Generate an App Password or use OAuth2 with a service account for Calendar.
SerpAPI quota exceeded Free tier limits are low. Cache results locally or switch to a paid plan for production.
SQLite file locked Multiple threads/processes write simultaneously. For multi‑worker deployments, replace SQLite with PostgreSQL.
LLM returns malformed JSON Model hallucination. Set temperature=0 (as we did) and add a post‑processing validator.

Troubleshooting

  1. openai.error.AuthenticationError – double‑check OPENAI_API_KEY.
  2. smtplib.SMTPAuthenticationError – verify email credentials and that “Allow less secure apps” is enabled (or use an app password).
  3. googleapiclient.errors.HttpError 403 – ensure the service account has Calendar scope and the calendar is shared with the account.
  4. requests.exceptions.ReadTimeout – increase the timeout in WebSearchTool or check your internet connection.
  5. FastAPI returns 500 with “no such table: unknown_questions” – run python -c "from tools.crm import CRMTool; CRMTool()" once to create the DB, or delete crm.db and let the code recreate it.

Key Takeaways

  • Modular design (one file per tool) makes adding or swapping services trivial.
  • LLM‑driven routing lets you keep the business logic in the model while the code stays deterministic.
  • Persisting unknowns (CRM) is essential for continuous improvement and ethical AI.
  • FastAPI provides a lightweight, production‑grade HTTP layer with automatic docs (/docs).

You now possess the foundation for a real‑world AI assistant that can be expanded indefinitely.


🧪 Try It Yourself

  1. Add a new tool – e.g., a weather API. Create tools/weather.py that inherits from BaseTool, register it in AIAssistant.__init__, and update the system prompt to include "weather" as a valid tool name.
  2. Persist tool usage logs – extend CRMTool or create a new AuditTool that writes every request/response pair to a separate SQLite table.
  3. Dockerize – write a Dockerfile that copies the project, installs dependencies, and runs uvicorn api.server:app --host 0.0.0.0. Build and run the container to see the whole stack isolated.

Happy coding! 🎉

Why This Matters

Scheduling an introductory call is a core workflow for any professional AI assistant. When a candidate or recruiter says “S2 works” and provides an email address, the assistant must:

  1. Parse the slot identifier (S2) into a concrete date‑time.
  2. Create a Google Calendar event (including an .ics attachment).
  3. Send a nicely formatted HTML email to both parties with the meeting details and a “Add to Calendar” link.

Getting this right dramatically reduces friction in the hiring pipeline and showcases the power of a fully‑automated, end‑to‑end assistant.


What You’ll Build

In this chapter you will extend the previous chatbot skeleton to:

Feature Description
Slot parsing Convert generic slot IDs (S1, S2, …) into real timestamps based on the user’s calendar availability.
Google Calendar event creation Use the Google Calendar API to insert the meeting, retrieve the event link, and generate an .ics file.
HTML email generation Build a responsive email that includes the meeting link, a “Add to Google Calendar” button, and the .ics attachment.
Dual‑recipient delivery Send the email to the user and the recruiter (or any other stakeholder).
Logging & verification Print concise logs and provide a ✅ Verify: checkpoint after each major step.

By the end of this chapter you will have a single command‑line script that can:

> python schedule_meeting.py
Enter slot (e.g., S2): S2
Enter your email: ishanth8@gmail.com
Enter recruiter email: recruiter@example.com
✅ Verify: Meeting scheduled, emails sent!

Content

1️⃣ Parse the Slot Identifier

We’ll keep a static mapping of slot IDs to datetime objects for simplicity. In a production system you would pull the user’s free‑busy data via the Calendar API.

# Save as: slot_parser.py
import datetime
from typing import Dict

# Example static slot map – replace with dynamic free‑busy lookup later
SLOT_MAP: Dict[str, datetime.datetime] = {
 "S1": datetime.datetime(2024, 8, 8, 15, 0), # Thursday 3 PM UTC
 "S2": datetime.datetime(2024, 8, 8, 18, 0), # Thursday 6 PM UTC
 "S3": datetime.datetime(2024, 8, 9, 10, 0), # Friday 10 AM UTC
 "S4": datetime.datetime(2024, 8, 12, 14, 0), # Monday 2 PM UTC
}

def parse_slot(slot_id: str) -> datetime.datetime:
 """
 Convert a slot identifier (e.g.,'S2') into a timezone‑aware datetime.
 Raises ValueError if the slot does not exist.
 """
 if slot_id not in SLOT_MAP:
 raise ValueError(f"Slot {slot_id} is not defined.")
 # Assume UTC for this demo; replace with pytz/local timezone as needed
 return SLOT_MAP[slot_id]

Expected output (when imported and called):

>>> from slot_parser import parse_slot
>>> parse_slot("S2")
datetime.datetime(2024, 8, 8, 18, 0)

✅ Verify: parse_slot returns a datetime object for a valid slot and raises a clear error for an unknown slot.


2️⃣ Google Calendar Helper

We’ll use the Google Calendar API (google-api-python-client) to create the event.

⚠️ Prerequisite: Follow the “Enable APIs & obtain credentials” steps in the previous chapter. Save the OAuth client JSON as credentials.json.

# Save as: calendar_helper.py
import datetime
import os.path
import base64
from email.mime.text import MIMEText
from typing import Tuple

from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.discovery import build

# If modifying these scopes, delete token.json.
SCOPES = ["https://www.googleapis.com/auth/calendar.events",
 "https://www.googleapis.com/auth/gmail.send"]

def get_service(scopes: list, token_file: str = "token.json") -> Tuple[object, Credentials]:
 """Authenticate and return a Google API service object."""
 creds = None
 if os.path.exists(token_file):
 creds = Credentials.from_authorized_user_file(token_file, scopes)
 # If there are no (valid) credentials, let the user log in.
 if not creds or not creds.valid:
 if creds and creds.expired and creds.refresh_token:
 creds.refresh(Request())
 else:
 flow = InstalledAppFlow.from_client_secrets_file(
 "credentials.json", scopes)
 creds = flow.run_local_server(port=0)
 # Save the credentials for the next run
 with open(token_file, "w") as token:
 token.write(creds.to_json())
 service = build("calendar", "v3", credentials=creds)
 return service, creds

def create_event(service,
 start_dt: datetime.datetime,
 end_dt: datetime.datetime,
 summary: str,
 description: str,
 attendees: list) -> dict:
 """
 Insert an event into the primary calendar.
 Returns the created event resource.
 """
 event_body = {
 "summary": summary,
 "description": description,
 "start": {"dateTime": start_dt.isoformat(), "timeZone": "UTC"},
 "end": {"dateTime": end_dt.isoformat(), "timeZone": "UTC"},
 "attendees": [{"email": email} for email in attendees],
 "reminders": {"useDefault": True},
 }
 event = service.events().insert(calendarId="primary", body=event_body,
 sendUpdates="all").execute()
 print(f"✅ Verify: Event created – {event.get('htmlLink')}")
 return event

def generate_ics(event: dict) -> bytes:
 """
 Very small helper that builds an.ics file from the event dict.
 For a full‑featured solution use icalendar package.
 """
 from icalendar import Calendar, Event as IcsEvent

 cal = Calendar()
 cal.add("prodid", "-//AI Scheduler//example.com//")
 cal.add("version", "2.0")

 ics_event = IcsEvent()
 ics_event.add("summary", event["summary"])
 ics_event.add("dtstart", datetime.datetime.fromisoformat(event["start"]["dateTime"]))
 ics_event.add("dtend", datetime.datetime.fromisoformat(event["end"]["dateTime"]))
 ics_event.add("description", event["description"])
 ics_event.add("uid", event["id"])
 ics_event.add("dtstamp", datetime.datetime.utcnow())
 cal.add_component(ics_event)

 return cal.to_ical()

Sample run (assuming valid credentials):

✅ Verify: Event created – https://www.google.com/calendar/event?eid=.

✅ Verify: The function prints the event link and returns the full event JSON.


3️⃣ Email Helper (HTML +.ics attachment)

We’ll send the email via Gmail API (same credentials as Calendar). The email contains:

  • A friendly greeting.
  • Meeting details (date, time, Google Meet link if generated).
  • A “Add to Google Calendar” button that points to the event URL.
  • An attached .ics file for Outlook/Apple Calendar users.
# Save as: email_helper.py
import base64
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.application import MIMEApplication
from typing import List

def build_email(sender: str,
 to: List[str],
 subject: str,
 html_body: str,
 ics_bytes: bytes,
 ics_filename: str = "invite.ics") -> dict:
 """
 Construct a Gmail API message dict with HTML body and.ics attachment.
 """
 message = MIMEMultipart()
 message["to"] = ", ".join(to)
 message["from"] = sender
 message["subject"] = subject

 # HTML part
 message.attach(MIMEText(html_body, "html"))

 #.ics attachment
 ics_part = MIMEApplication(ics_bytes, _subtype="ics")
 ics_part.add_header("Content-Disposition", "attachment", filename=ics_filename)
 message.attach(ics_part)

 raw_message = base64.urlsafe_b64encode(message.as_bytes()).decode()
 return {"raw": raw_message}

def send_message(gmail_service, user_id: str, message_body: dict):
 """
 Send an email via Gmail API.
 """
 sent_message = gmail_service.users().messages().send(userId=user_id, body=message_body).execute()
 print(f"✅ Verify: Email sent – Message ID {sent_message['id']}")
 return sent_message

def render_html(meeting_link: str, start_dt: datetime.datetime, end_dt: datetime.datetime) -> str:
 """
 Very simple HTML template for the meeting invitation.
 """
 start_str = start_dt.strftime("%A, %B %d, %Y %I:%M %p UTC")
 end_str = end_dt.strftime("%I:%M %p UTC")
 html = f"""
 <html>
 <body>
 <p>Hello,</p>
 <p>Your introductory call has been scheduled:</p>
 <ul>
 <li><strong>When:</strong> {start_str} – {end_str}</li>
 <li><strong>Where:</strong> <a href="{meeting_link}">Google Meet link</a></li>
 </ul>
 <p>
 <a href="{meeting_link}"
 style="display:inline-block;padding:10px 20px;background:#4285F4;color:#fff;
 text-decoration:none;border-radius:4px;">
 Join Meeting
 </a>
 </p>
 <p>
 <a href="{meeting_link}"
 style="display:inline-block;padding:10px 20px;background:#34A853;color:#fff;
 text-decoration:none;border-radius:4px;">
 Add to Google Calendar
 </a>
 </p>
 <p>Looking forward to speaking with you!</p>
 <p>Best regards,<br/>AI Scheduler Bot</p>
 </body>
 </html>
 """
 return html

Sample output after sending:

✅ Verify: Email sent – Message ID 17c8b3f9c5e.

✅ Verify: Both the HTML body and the .ics attachment are present when you view the email in Gmail.


4️⃣ Orchestrator – schedule_meeting.py

Now we tie everything together:

# Save as: schedule_meeting.py
import datetime
import sys

from slot_parser import parse_slot
from calendar_helper import get_service, create_event, generate_ics
from email_helper import build_email, send_message, render_html

def main():
 # 1️⃣ Gather user input
 slot_id = input("Enter slot (e.g., S2): ").strip().upper()
 user_email = input("Enter your email: ").strip()
 recruiter_email = input("Enter recruiter email: ").strip()

 try:
 start_dt = parse_slot(slot_id)
 except ValueError as e:
 print(f"❌ Error: {e}")
 sys.exit(1)

 # For demo we assume a 1‑hour meeting
 end_dt = start_dt + datetime.timedelta(hours=1)

 # 2️⃣ Authenticate Google services (Calendar + Gmail)
 service, creds = get_service(scopes=[
 "https://www.googleapis.com/auth/calendar.events",
 "https://www.googleapis.com/auth/gmail.send"
 ])

 # 3️⃣ Create Calendar event
 event = create_event(
 service=service,
 start_dt=start_dt,
 end_dt=end_dt,
 summary="Introductory Call with Ishant",
 description="Automated meeting scheduled via AI assistant.",
 attendees=[user_email, recruiter_email]
 )

 # 4️⃣ Generate.ics file
 ics_bytes = generate_ics(event)

 # 5️⃣ Build HTML body
 meeting_link = event.get("hangoutLink", "https://meet.google.com/placeholder")
 html_body = render_html(meeting_link, start_dt, end_dt)

 # 6️⃣ Build and send email to both parties
 email_msg = build_email(
 sender=user_email,
 to=[user_email, recruiter_email],
 subject="📅 Introductory Call Confirmation",
 html_body=html_body,
 ics_bytes=ics_bytes
 )
 send_message(gmail_service=service, user_id="me", message_body=email_msg)

 print("\n✅ Verify: All steps completed – meeting scheduled and emails dispatched!")

if __name__ == "__main__":
 main()

Running the script

$ python schedule_meeting.py
Enter slot (e.g., S2): S2
Enter your email: ishanth8@gmail.com
Enter recruiter email: recruiter@example.com
✅ Verify: Event created – https://www.google.com/calendar/event?eid=.
✅ Verify: Email sent – Message ID 17c8b3f9c5e.
All steps completed – meeting scheduled and emails dispatched!

✅ Verify: The console shows both verification messages, and you should see the meeting on your Google Calendar as well as an email in both inboxes.


📁 Project Files

ai_scheduler/
│
├─ credentials.json # OAuth client secret (downloaded from Google Cloud)
├─ token.json # Generated after first auth (auto‑created)
│
├─ slot_parser.py
├─ calendar_helper.py
├─ email_helper.py
└─ schedule_meeting.py

💡 Tip: Keep credentials.json out of version control (.gitignore) to protect your client secret.


🚀 What You Just Built

  • Dynamic slot parsing – turn a simple “S2” into a concrete UTC datetime.
  • Google Calendar integration – create an event, invite both participants, and retrieve a shareable link.
  • ICS generation – produce a universal calendar file for Outlook/Apple users.
  • HTML email with actionable buttons – a polished invitation that works on any device.
  • Dual‑recipient delivery – both the candidate and recruiter receive identical, professional invites.

All of this runs from a single Python script with no manual copy‑pasting.


Common Mistakes

Mistake Why it Happens Fix
Missing token.json First run without completing OAuth flow. Run the script; a browser window will open for consent.
Time‑zone mismatch Using local time but sending UTC to Calendar. Convert local time to UTC (pytz or zoneinfo) before calling create_event.
No hangoutLink Calendar event created without a Google Meet link. Add "conferenceData": {"createRequest": {"requestId": "some-random-id"}} to the event body and enable the calendar.events.insert conferenceDataVersion=1 parameter.
Email marked as spam Sending from a personal Gmail address without proper DKIM/SPF. Use a G Suite (Google Workspace) account or configure SPF/DKIM for your domain.
ICS file corrupted Forgetting to set the correct MIME type (application/ics). Ensure MIMEApplication(., _subtype="ics") is used (as shown).

Troubleshooting

  1. HttpError 403: Insufficient Permission Solution: Re‑run the script after deleting token.json. The OAuth consent screen will ask for the new scopes.

  2. AttributeError: module 'datetime' has no attribute 'fromisoformat' (Python < 3.7) Solution: Upgrade to Python 3.7+ or use dateutil.parser.isoparse.

  3. No email received Solution: Verify the to list contains valid addresses, and check Gmail’s “Sent” folder for the message ID printed by send_message.

  4. Calendar event appears at the wrong time Solution: Confirm the timeZone field in create_event matches the timezone of start_dt. For local time, use pytz.timezone("America/Los_Angeles") and pass tzinfo to the datetime.


Key Takeaways

  • Parse user-friendly identifiers into machine‑readable timestamps before any API call.
  • Reuse a single OAuth credential for both Calendar and Gmail to keep the flow seamless.
  • Always attach an .ics file – it guarantees cross‑platform calendar compatibility.
  • HTML emails with clear CTA buttons dramatically improve the user experience.
  • Verification prints (✅ Verify:) are invaluable during development and debugging.

🧪 Try It Yourself

  1. Add a new slot (e.g., S5) for a Tuesday 2 PM meeting in slot_parser.py.
  2. Run the script and schedule using the new slot. Verify that the event appears on the correct day.
  3. Modify the email template to include your company logo (use an <img> tag with a hosted image URL).
  4. Experiment with time zones: change timeZone to "America/New_York" and observe the shift in the calendar UI.

Happy coding! 🎉

Chapter 2

Getting Your Python Environment Sorted

Why This Matters


Before You Start This Chapter

  • Your venv is active (you see (.venv) in your prompt)
  • Previous chapter's project is working
  • Your .env file has your API key

    What is an API? An API (Application Programming Interface) is how one program talks to another. Think of it like a waiter in a restaurant — you (your code) tell the waiter (API) what you want, the waiter goes to the kitchen (the server), and brings back your food (the response). You never talk to the kitchen directly.

  • You are in the correct folder (run pwd to check)

Get Your OpenAI API Key

  1. Go to https://platform.openai.com/api-keys
  2. Log in (free account)
  3. Click "Create new secret key"
  4. Name it (e.g., "adhikk")
  5. Click "Create"
  6. Copy the key (starts with sk-) — you will not see it again
  7. Paste into your .env file

New accounts get $5 free credit — enough for this entire book.

Every data‑science or Python project eventually runs into dependency conflicts. - Project A may need pandas==1.2.0 while Project B requires pandas==2.0.0. - Installing both versions globally makes one of the projects break.

A virtual environment isolates each project’s interpreter and libraries, guaranteeing that code that works today will work tomorrow on any machine. Mastering this concept is the foundation of reproducible, professional Python development.


What You'll Build

In this chapter you will:

  1. Run Python instantly with Google Colab (no local installation needed).
  2. Create a local virtual environment using the built‑in venv module.
  3. Set up the modern uv package manager to create and manage environments in a single command.

By the end, you’ll have three runnable examples that you can copy‑paste and execute without any extra research.


Content

Step Description
1️⃣ Launch a Google Colab notebook and verify the default Python environment.
2️⃣ Create a local venv called env_a and install a specific version of numpy.
3️⃣ Use uv to spin up a fresh environment env_uv and install pandas.

Each step includes code, expected output, and a ✅ Verify checkpoint.


📁 Project Files

File Purpose
colab_demo.ipynb Notebook you create in Google Colab (no file to download).
env_a_demo.py Demonstrates the venv‑based environment.
uv_demo.py Demonstrates the uv‑based environment.

Below are the two Python scripts you will run locally.

env_a_demo.py

# Save as: env_a_demo.py
import sys
import numpy as np

def main() -> None:
 print(f"Python executable: {sys.executable}")
 print(f"Numpy version : {np.__version__}")

if __name__ == "__main__":
 main()

Expected output (when run inside env_a):

Python executable: /full/path/to/env_a/bin/python
Numpy version : 1.21.6

uv_demo.py

# Save as: uv_demo.py
import sys
import pandas as pd

def main() -> None:
 print(f"Python executable: {sys.executable}")
 print(f"Pandas version : {pd.__version__}")

if __name__ == "__main__":
 main()

Expected output (when run inside env_uv):

Python executable: /full/path/to/uv_env/bin/python
Pandas version : 2.2.2

🚀 What You Just Built

  1. Google Colab notebook – a cloud‑based, zero‑setup Python REPL.
  2. env_a virtual environment – isolated Python 3.11 with numpy==1.21.6.
  3. env_uv virtual environment – isolated Python 3.12 with pandas==2.2.2 created via uv.

All three run the same code pattern: print the interpreter path and a library version, proving that each environment is truly independent.


Common Mistakes

Mistake Why it Happens Fix
Forgetting to activate the virtual environment before running a script. The shell still points to the global Python. Run source env_a/bin/activate (Linux/macOS) or .\env_a\Scripts\activate (Windows).
Installing packages globally (pip install …) while inside an activated venv. pip may resolve to the system pip if the venv is not activated. Use python -m pip install … which guarantees the pip belonging to the active interpreter.
Using uv without first installing it. uv is not bundled with Python. Follow the installation command in the next section.

Troubleshooting

1️⃣ Google Colab won’t import a library

  • Symptom: ImportError: No module named'some_pkg'.
  • Solution: Install the missing package in the notebook cell:
!pip install some_pkg==1.2.3

2️⃣ venv activation script not found

  • Symptom: source: No such file or directory.
  • Solution: Verify you are in the directory containing env_a. List files with ls env_a. If the folder is missing, recreate it (python -m venv env_a).

3️⃣ uv command not recognized

  • Symptom: uv: command not found.
  • Solution: Install uv (see below) and ensure ~/.local/bin (or the appropriate location) is on your PATH.

Key Takeaways

  • Isolation is the antidote to dependency hell.
  • Google Colab is perfect for quick experiments; it runs a fresh VM each session.
  • venv is built‑in, reliable, and works everywhere Python runs.
  • uv is a modern, ultra‑fast alternative that combines environment creation and package management in one step.

Master these tools now, and you’ll never be blocked by conflicting libraries again.


🧪 Try It Yourself

  1. Colab Challenge – Open a new notebook, run:

python import sys, platform print("Python:", sys.version) print("OS :", platform.system())

Verify the output matches the runtime shown in the UI.

  1. Venv Challenge – Create a second environment env_b, install numpy==1.24.0, and run env_a_demo.py inside it. Confirm the printed version changes.

  2. UV Challenge – Use uv to create an environment with Python 3.11 and install scikit-learn==1.3.0. Write a tiny script that prints sklearn.__version__ and run it.

💡 Tip: Keep a requirements.txt (or uv.lock) in each project folder. It makes reproducing the exact environment a one‑liner: pip install -r requirements.txt or uv sync.

⚠️ Warning: Never commit your virtual‑environment directories (env_*, uv_env) to version control. Add them to .gitignore to keep the repository clean.

Verify: After each challenge, the printed versions should match the versions you explicitly installed. If they don’t, re‑activate the environment and re‑run the script.

Why This Matters

When you move from quick‑and‑dirty prototyping to real‑world projects, you need a reproducible, isolated environment. - Isolation guarantees that one project's libraries never clash with another's. - Reproducibility lets anyone (including future you) spin up the exact same stack with a single command. - Version control of dependencies prevents the dreaded “It works on my machine” bugs that waste hours of debugging.

⚠️ Skipping a proper environment setup is the fastest way to end up with a broken pipeline when you later add new features or share the code with teammates.


What You’ll Build

By the end of this chapter you will have a complete, version‑controlled Python project that:

  1. Uses a virtual environment (venv).
  2. Declares its dependencies in a requirements.txt file.
  3. Installs those dependencies reproducibly.
  4. Runs a small demo script that prints the installed package versions.
  5. Is ready to be pushed to a Git repository.

✅ Verify: After completing the steps you should be able to delete the venv folder, recreate it, run pip install -r requirements.txt, and see the same output as before.


Content

1️⃣ Create the Project Skeleton

# From your terminal (any OS)
mkdir my_data_project
cd my_data_project

2️⃣ Initialise a Git Repository

git init

💡 Initialising early gives you a clean history from day one.

3️⃣ Set Up a Virtual Environment

# macOS / Linux
python3 -m venv.venv

# Windows (PowerShell)
python -m venv.venv

4️⃣ Activate the Environment

# macOS / Linux
source.venv/bin/activate

# Windows (PowerShell).\.venv\Scripts\Activate.ps1

You should now see (.venv) prefixed to your prompt.

5️⃣ Install Core Packages

pip install pandas==2.2.1 rich==13.7.0

6️⃣ Freeze the Exact Versions

pip freeze > requirements.txt

requirements.txt now contains a pinned list of all packages required to run the project.

7️⃣ Add a Simple Demo Script

Create a file called demo.py with the following content:

# Save as: demo.py
import pandas as pd
from rich import print as rprint

def main() -> None:
 rprint("[bold green]✅ Environment is ready![/bold green]")
 rprint(f"Pandas version: [cyan]{pd.__version__}[/cyan]")
 rprint(f"Rich version: [cyan]{rprint.__module__}[/cyan]")

if __name__ == "__main__":
 main()

Expected output when you run the script:

✅ Environment is ready!
Pandas version: 2.2.1
Rich version: rich

✅ Verify: Run python demo.py inside the activated environment and confirm the output matches exactly.

8️⃣ Commit Your Work

git add.
git commit -m "Initial project skeleton with venv and demo script"

9️⃣ (Optional) Export a Jupyter Notebook

If you prefer notebooks, you can generate one from the script:

pip install jupytext
jupytext --to ipynb demo.py

This creates demo.ipynb that you can open in Jupyter or Google Colab, preserving the same environment.


📁 Project Files

Path Description
my_data_project/ Root folder
.venv/ Virtual environment (do not commit)
demo.py Minimal runnable script
requirements.txt Exact dependency list
.gitignore Excludes .venv and other artefacts
README.md This documentation (you’re reading it)

.gitignore (copy‑paste)

# Save as:.gitignore
# Exclude virtual environment.venv/
__pycache__/
*.pyc

✅ Verify: cat.gitignore should display the content above.


🚀 What You Just Built

  • A self‑contained Python project that can be cloned on any machine, recreated with python -m venv.venv && source.venv/bin/activate && pip install -r requirements.txt, and run with python demo.py.
  • A single source of truth for dependencies (requirements.txt).
  • A Git history that tracks every change from day one.

Common Mistakes

Mistake Why It Happens Fix
Forgetting to activate the venv before installing packages You run pip install in the global interpreter Always run source.venv/bin/activate (or the Windows equivalent) before any pip command.
Committing the .venv folder to Git Large binary files and platform‑specific binaries pollute the repo Add .venv/ to .gitignore (as shown).
Using pip install package without version pinning Future releases may introduce breaking changes Use pip freeze > requirements.txt and always install via pip install -r requirements.txt.
Running python instead of python3 on macOS/Linux System Python may be 2.x, causing syntax errors Explicitly use python3 when creating the venv.

Troubleshooting

Symptom Likely Cause Remedy
ModuleNotFoundError: No module named 'pandas' Packages installed in a different environment Ensure the venv is activated (which python should point inside .venv).
Permission denied when running source.venv/bin/activate (macOS) The script lacks execute permission Run chmod +x.venv/bin/activate then retry.
pip command not found after activation pip not installed in the venv (rare) Run python -m ensurepip --upgrade inside the activated venv.
requirements.txt missing a package you just installed You forgot to run pip freeze > requirements.txt after the new install Re‑run the freeze command.

Key Takeaways

  • Virtual environments isolate dependencies per project.
  • Pinning versions in requirements.txt guarantees reproducibility.
  • Git should track source code only, not generated artefacts like the venv.
  • A tiny demo script (demo.py) is a reliable sanity check that the environment works.

🧪 Try It Yourself

  1. Create a new project called weather_analysis.
  2. Set up a venv, install requests==2.31.0 and matplotlib==3.8.2.
  3. Freeze the dependencies.
  4. Write a script fetch_plot.py that:
  5. Fetches current weather data from a free API (e.g., Open‑Meteo).
  6. Plots temperature over the next 7 days using matplotlib.
  7. Commit everything to a new Git repo.

When you’re done, clone the repo on a different machine (or a fresh Docker container), recreate the venv, install from requirements.txt, and verify the plot renders without errors.

Happy coding! 🚀

Why This Matters

Reproducibility is the cornerstone of professional software development. When you share code with a teammate—or when you come back to a project months later—the exact same Python interpreter and library versions must be available.

  • Virtual environments isolate dependencies per project.
  • requirements.txt gives a human‑readable list of packages.
  • uv (the ultra‑fast, all‑in‑one package manager) creates lock files that guarantee identical builds across machines, and it does it in a fraction of the time pip needs.

By mastering these tools you’ll avoid “works on my machine” bugs forever.


What You’ll Build

A tiny data‑analysis project that:

  1. Uses a uv‑managed virtual environment.
  2. Installs pandas and jupyter inside that environment.
  3. Provides a requirements.txt and a uv.lock file for reproducibility.
  4. Contains a Python script (analysis.py) that loads a CSV, prints the first rows, and saves a summary.
  5. Includes a Jupyter notebook (explore.ipynb) that visualises the data.

All of this will run without any additional research.

✅ Verify: By the end of this chapter you will be able to clone the folder on a fresh machine, run uv sync, and have a working environment instantly.


Content

1️⃣ Install uv (if you haven’t already)

# macOS / Linux
curl -LsSf https://astral.sh/uv/install.sh | sh

# Windows (PowerShell)
iwr https://astral.sh/uv/install.ps1 -UseBasicParsing | iex

💡 uv is a single binary, no Python wheels needed. It works on macOS, Linux, and Windows.

✅ Verify:

uv --version
# Expected output, e.g.:
# uv 0.4.0

2️⃣ Initialise the Project with uv

# Create a fresh folder for the project
mkdir python_one && cd python_one

# Initialise a new uv project (creates pyproject.toml)
uv init

The generated pyproject.toml looks like this (you’ll see it later in 📁 Project Files).

✅ Verify:

cat pyproject.toml
# Should display a minimal TOML with [project] section.

3️⃣ Add Dependencies

# Add pandas and jupyter as runtime dependencies
uv add pandas jupyter

uv will:

  • Resolve the latest compatible versions.
  • Create a uv.lock file that pins exact versions and hashes.

⚠️ Do not edit uv.lock manually; it is auto‑generated.

✅ Verify:

cat uv.lock | head -n 5
# You’ll see a TOML snippet with locked versions.

4️⃣ Create the Virtual Environment

# By default uv creates a.venv folder in the project root
uv venv

Activate it (Linux/macOS):

source.venv/bin/activate

Activate it (Windows PowerShell):

```powershell.venv\Scripts\Activate.ps1


Now `which python` (or `Get-Command python` on Windows) points to the project‑local interpreter.

✅ Verify:

```bash
which python # Linux/macOS
# or
Get-Command python # Windows PowerShell
# Output should end with "./python_one/.venv/bin/python"

5️⃣ Write the Core Script (analysis.py)

# Save as: analysis.py
import pandas as pd
from pathlib import Path

def main() -> None:
 # Create a tiny CSV on‑the‑fly for demo purposes
 csv_path = Path("data.csv")
 if not csv_path.exists():
 df_demo = pd.DataFrame({
 "name": ["Alice", "Bob", "Charlie", "Diana"],
 "age": [28, 34, 23, 45],
 "score": [85.5, 92.0, 78.5, 88.0]
 })
 df_demo.to_csv(csv_path, index=False)

 # Load the CSV
 df = pd.read_csv(csv_path)

 # Print first rows
 print("First 3 rows:")
 print(df.head(3))

 # Save a simple summary
 summary = df.describe()
 summary_path = Path("summary.txt")
 summary.to_csv(summary_path, sep="\t")
 print(f"\nSummary written to {summary_path}")

if __name__ == "__main__":
 main()

Expected output when you run it:

First 3 rows:
 name age score
0 Alice 28 85.5
1 Bob 34 92.0
2 Charlie 23 78.5

Summary written to summary.txt

✅ Verify:

python analysis.py

6️⃣ Export a Classic requirements.txt

Even though uv is preferred, many CI pipelines still expect a requirements.txt. Generate it from the lock file:

uv export -f requirements.txt -o requirements.txt

✅ Verify:

cat requirements.txt | head -n 5
# Should list pandas, jupyter, and their exact versions.

7️⃣ Create a Jupyter Notebook (explore.ipynb)

Tip: VS Code will automatically detect the .ipynb file and use the active virtual environment.

{
 "cells": [
 {
 "cell_type": "markdown",
 "metadata": {},
 "source": [
 "# Exploring the Demo Data\n",
 "We will load the CSV created by `analysis.py` and plot the scores."
 ]
 },
 {
 "cell_type": "code",
 "execution_count": null,
 "metadata": {},
 "outputs": [],
 "source": [
 "import pandas as pd\n",
 "import matplotlib.pyplot as plt\n",
 "\n",
 "df = pd.read_csv('data.csv')\n",
 "df.head()"
 ]
 },
 {
 "cell_type": "code",
 "execution_count": null,
 "metadata": {},
 "outputs": [],
 "source": [
 "plt.bar(df['name'], df['score'], color='skyblue')\n",
 "plt.title('Score by Person')\n",
 "plt.ylabel('Score')\n",
 "plt.show()"
 ]
 }
 ],
 "metadata": {
 "kernelspec": {
 "display_name": "Python (python_one)",
 "language": "python",
 "name": "python"
 },
 "language_info": {
 "name": "python",
 "version": "3.12"
 }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}

Save this JSON as explore.ipynb. Open it in VS Code, press Shift + Enter on each cell, and you’ll see a bar chart.

✅ Verify:

Open the notebook → Run all cells → A bar chart appears.


8️⃣ Re‑creating the Environment on a New Machine

  1. Clone the repository (or copy the folder).
  2. Install uv (step 1).
  3. Run:
uv sync

uv sync reads uv.lock and installs the exact same packages into a fresh .venv. No pip install needed.

✅ Verify:

source.venv/bin/activate # or.venv\Scripts\Activate.ps1 on Windows
python analysis.py # should produce the same output as before

📁 Project Files

python_one/
├─.venv/ # created by `uv venv` (not version‑controlled)
├─ analysis.py # main script (see code above)
├─ explore.ipynb # Jupyter notebook (JSON shown above)
├─ pyproject.toml # generated by `uv init`
├─ uv.lock # generated by `uv add`
├─ requirements.txt # exported via `uv export`
└─ data.csv # created on first run of analysis.py (auto‑generated)

pyproject.toml (auto‑generated, shown for completeness):

# Save as: pyproject.toml
[project]
name = "python_one"
version = "0.1.0"
description = "Demo project showing uv, venv, pandas, and Jupyter."
authors = [{name = "Your Name", email = "you@example.com"}]
requires-python = ">=3.9"

[tool.uv]
# No extra configuration needed for this demo

🚀 What You Just Built

  • A self‑contained Python environment managed by uv.
  • A deterministic lock file (uv.lock) guaranteeing identical builds.
  • A script that reads, processes, and summarizes CSV data.
  • A Jupyter notebook that visualises the same data.
  • A requirements.txt for legacy tooling.

All of this can be transferred to any machine, and uv sync will recreate the exact same environment in seconds.


Common Mistakes

Mistake Why It Happens Fix
Forgetting to activate the virtual environment before running python. The shell still points to the global interpreter. Run source.venv/bin/activate (Linux/macOS) or .venv\Scripts\Activate.ps1 (Windows).
Editing uv.lock manually. Lock files are cryptographic hashes; manual edits corrupt them. Let uv manage the lock file; use uv add or uv remove.
Running pip install inside the uv environment. pip bypasses uv’s lock, leading to version drift. Use uv add <pkg> or uv sync exclusively.
Committing the .venv folder to Git. Increases repo size and defeats reproducibility. Add .venv/ to .gitignore.

Troubleshooting

Symptom Likely Cause Remedy
uv: command not found uv not installed or not on PATH. Re‑run the installation script; ensure ~/.local/bin (or equivalent) is in PATH.
ImportError: No module named pandas after uv sync. uv.lock out‑of‑date with pyproject.toml. Run uv add pandas again, then uv sync.
Jupyter notebook kernel shows “Python (global)” instead of the project env. VS Code didn’t pick up the .venv. Open the Command Palette → “Python: Select Interpreter” → choose .venv/bin/python.
uv export produces an empty requirements.txt. No packages installed yet. Install at least one package (uv add pandas) before exporting.

Key Takeaways

  • Isolation + determinism = reliable, shareable code.
  • uv replaces venv, pip, and pip‑freeze with a single, fast tool.
  • Always activate the environment before running scripts or notebooks.
  • Keep uv.lock under version control; generate requirements.txt only when needed for external tools.

🧪 Try It Yourself

  1. Create a new branch called experiment.
  2. Add a new dependency, e.g., uv add matplotlib.
  3. Update analysis.py to plot a histogram of the score column and save it as score_hist.png.
  4. Run uv sync on a different machine (or a Docker container) and verify the plot is generated correctly.

Bonus: Replace uv sync with uv pip install -r requirements.txt on that other machine and observe the slower installation time. Compare the logs to see uv’s speed advantage.

Happy coding! 🚀

Why This Matters

Modern Python projects rarely live in a single file. They depend on many third‑party libraries, each with its own version constraints. Managing those dependencies manually (copy‑pasting pip install … commands) quickly becomes error‑prone, especially when you need to share the exact same environment with teammates or CI pipelines.

The UV package manager solves this by:

  • Creating an isolated virtual environment in seconds.
  • Generating a lockfile (uv.lock) that pins every transitive dependency.
  • Providing a single command (uv sync) to reproduce the environment anywhere.

By the end of this section you’ll have a professional‑grade project skeleton that can be cloned and run on any machine with a single command.


What You’ll Build

A small data‑science utility called sales_insight.py that:

  1. Reads a CSV file with pandas.
  2. Trains a scikit‑learn linear regression model.
  3. Prints the model’s R² score.

All dependencies will be managed with UV, and the project will be ready for version control.


Content

1️⃣ Initialise a UV Project

Open a terminal inside your project folder (e.g., ~/projects/sales_insight) and run:

uv init

💡 Tipuv init creates a minimal pyproject.toml, a uv.lock placeholder, and a hidden .venv directory (the virtual environment).

✅ Verify: You should see a new pyproject.toml file and a .venv/ folder.

2️⃣ Add Required Packages

uv add pandas scikit-learn

UV will:

  • Resolve the latest compatible versions.
  • Write them to uv.lock.
  • Update pyproject.toml under [project]dependencies.

✅ Verify: Open pyproject.toml; you’ll see something like:

[project]
dependencies = [
 "pandas",
 "scikit-learn",
]

3️⃣ Sync the Environment

uv sync

UV now creates the virtual environment (if not already present) and installs exactly the versions recorded in uv.lock.

✅ Verify: Run uv run python -c "import pandas, sklearn; print('✅ All good')" – you should see ✅ All good.

4️⃣ Write the Application Code

Create sales_insight.py (full file shown below). It will:

  • Load sales.csv (you’ll create a tiny sample later).
  • Fit a linear regression model predicting Revenue from Advertising.
  • Print the R² score.
# Save as: sales_insight.py
import pandas as pd
from sklearn.linear_model import LinearRegression
from sklearn.metrics import r2_score

def main() -> None:
 # Load sample data
 df = pd.read_csv("sales.csv")
 X = df[["Advertising"]].values
 y = df["Revenue"].values

 # Train model
 model = LinearRegression()
 model.fit(X, y)

 # Predict and evaluate
 predictions = model.predict(X)
 score = r2_score(y, predictions)

 print(f"R² score: {score:.4f}")

if __name__ == "__main__":
 main()

Expected output (once we create the CSV):

R² score: 0.9876

✅ Verify: After creating sales.csv (see next step), run:

uv run python sales_insight.py

You should see the R² score printed.

5️⃣ Add a Sample CSV

Create sales.csv in the same directory:

Advertising,Revenue
23, 650
45, 1200
12, 300
34, 950

✅ Verify: Open the file to confirm the four rows are present.

6️⃣ Lock the Project for Distribution

The lockfile (uv.lock) already contains exact versions, but you can re‑generate it anytime:

uv lock

Commit both pyproject.toml and uv.lock to your Git repository. Anyone cloning the repo can reproduce the environment with a single command:

uv sync

📁 Project Files

sales_insight/
├─.venv/ # Created by UV (do NOT commit)
├─ pyproject.toml # UV project definition
├─ uv.lock # Exact pinned dependencies
├─ sales_insight.py # Application code
└─ sales.csv # Sample data

pyproject.toml (auto‑generated, shown for completeness)

# Save as: pyproject.toml
[project]
name = "sales_insight"
version = "0.1.0"
description = "A tiny demo of UV‑managed dependencies"
dependencies = [
 "pandas",
 "scikit-learn",
]

[build-system]
requires = ["uv"]
build-backend = "uv.build"

🚀 What You Just Built

  • A reproducible Python environment managed by UV.
  • A data‑science script that reads CSV data, trains a model, and reports performance.
  • A ready‑to‑share project – clone, run uv sync, and you’re good to go.

Common Mistakes

Mistake Why It Happens Fix
Running pip install … inside the UV‑created .venv UV expects you to use uv add/uv sync; pip bypasses the lockfile. Use uv add <package> for new deps, then uv sync.
Forgetting to activate the environment before running scripts uv run automatically activates, but plain python does not. Always invoke scripts with uv run python … or source.venv/bin/activate.
Not committing uv.lock Others will get the latest versions, potentially breaking the code. Add uv.lock to version control.
Using a global Python interpreter (which python) after uv sync The global interpreter is still the default; you need to point to the virtual env. Use uv run … or source.venv/bin/activate before python.

Troubleshooting

Symptom Likely Cause Remedy
uv: command not found UV not installed or not on $PATH. Install via Homebrew (brew install uv) on macOS, or via the official installer on Linux/Windows.
Permission denied when running uv sync Insufficient write permissions in the project folder. Run the terminal with appropriate rights or change folder ownership (chmod -R u+w.).
ImportError: No module named 'pandas' after uv sync The virtual environment wasn’t activated. Use uv run python … or source.venv/bin/activate.
uv lock hangs indefinitely Network connectivity issues or a corrupted lockfile. Delete uv.lock and re‑run uv lock; ensure internet access.

Key Takeaways

  • UV provides a fast, deterministic way to manage Python dependencies.
  • The lockfile (uv.lock) is the single source of truth for reproducibility.
  • Use uv add for new packages and uv sync to materialise the environment.
  • Always run your scripts with uv run (or activate the .venv) to guarantee the correct interpreter and packages are used.

🧪 Try It Yourself

  1. Add a new library – e.g., matplotlib – to visualise the data:

bash uv add matplotlib uv sync

  1. Extend sales_insight.py to plot the regression line:

```python import matplotlib.pyplot as plt

# After model training. plt.scatter(X, y, color="blue", label="Data") plt.plot(X, predictions, color="red", label="Fit") plt.xlabel("Advertising") plt.ylabel("Revenue") plt.title("Advertising vs Revenue") plt.legend() plt.show() ```

  1. Run the script with uv run python sales_insight.py.

You should see a window displaying the scatter plot with the fitted line.

Congratulations! You now have a fully reproducible, version‑controlled Python project powered by UV. In the next chapter we’ll explore testing and continuous integration for this setup. Happy coding!

Why This Matters

Locking dependencies and securely managing secrets are non‑negotiable in any production‑grade Python project. - Reproducibility: uv lock guarantees every teammate (and CI runner) installs exactly the same package versions. - Security: Storing API keys in a .env file keeps them out of source control, preventing accidental leaks. - Speed: With a lock file, uv sync resolves the dependency graph once and then installs in a flash—ideal for rapid iteration.


What You'll Build

A minimal, ready‑to‑run OpenAI client that:

  1. Reads your API key from a .env file.
  2. Uses the locked dependencies from uv.lock.
  3. Sends a simple prompt to gpt‑3.5‑turbo and prints the response.

All files are self‑contained; you won’t need to search the internet for any missing piece.


Content

1️⃣ Set Up the Project Skeleton

mkdir openai_demo && cd openai_demo
uv venv # Create an isolated virtual environment
source.venv/bin/activate # Activate it (Linux/macOS).\.venv\Scripts\activate # Activate it (Windows PowerShell)

2️⃣ Create pyproject.toml

# Save as: pyproject.toml
[project]
name = "openai_demo"
version = "0.1.0"
description = "A tiny OpenAI client with locked dependencies"
authors = [{name = "Your Name", email = "you@example.com"}]
requires-python = ">=3.9"

[dependency-groups]
default = [
 "openai>=1.0.0",
 "python-dotenv>=1.0.0",
]

[tool.uv]
# Optional: enforce a specific Python version for reproducibility
python = "3.11"

3️⃣ Lock the Dependencies

uv lock # Generates uv.lock with exact versions
uv sync # Installs them into the active venv

Verify: After uv sync you should see no errors and the site-packages folder populated.

4️⃣ Add a .env.example (Never commit real keys!)

# Save as:.env.example
OPENAI_API_KEY=sk-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

Copy it to .env and replace the placeholder with your actual key:

cp.env.example.env
# Edit.env with your favorite editor and paste your real key

💡 Tip: Add .env to .gitignore (see next step) so Git never tracks it.

5️⃣ Create .gitignore

# Save as:.gitignore
# Python artifacts
__pycache__/
*.py[cod]
*.pyo
*.pyd
*.egg-info/
dist/
build/

# Virtual environment.venv/
venv/

# Secrets.env

6️⃣ Write the OpenAI Client (main.py)

# Save as: main.py
import os
from pathlib import Path

from dotenv import load_dotenv
import openai

# ----------------------------------------------------------------------
# Load environment variables from.env (if present)
# ----------------------------------------------------------------------
env_path = Path(__file__).parent / ".env"
if env_path.is_file():
 load_dotenv(dotenv_path=env_path)
else:
 raise FileNotFoundError("❌.env file not found. Create one from.env.example.")

# ----------------------------------------------------------------------
# Retrieve the API key securely
# ----------------------------------------------------------------------
api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
 raise ValueError("❌ OPENAI_API_KEY is missing from.env")

openai.api_key = api_key

# ----------------------------------------------------------------------
# Simple chat completion request
# ----------------------------------------------------------------------
def ask_gpt(prompt: str) -> str:
 """Send a prompt to gpt‑3.5‑turbo and return the assistant's reply."""
 response = openai.ChatCompletion.create(
 model="gpt-3.5-turbo",
 messages=[{"role": "user", "content": prompt}],
 temperature=0.7,
 )
 # Extract the assistant's message
 return response.choices[0].message["content"].strip()


if __name__ == "__main__":
 user_prompt = "Explain why dependency locking matters in one sentence."
 answer = ask_gpt(user_prompt)
 print(f"🤖 GPT says: {answer}")

Expected Output (your actual answer will vary)

🤖 GPT says: Dependency locking ensures every environment uses the exact same package versions, eliminating “works on my machine” bugs.

Verify: Run the script:

python main.py

You should see a one‑sentence answer printed to the console. If you get an authentication error, double‑check your .env key.


📁 Project Files

openai_demo/
├─.gitignore
├─.env.example
├─ main.py
├─ pyproject.toml
└─ uv.lock ← generated by `uv lock`

🚀 What You Just Built

  • A locked Python environment (uv.lock) guaranteeing reproducible installs.
  • A secure configuration pattern using .env and .gitignore.
  • A functional OpenAI client that can be extended to any prompt or model.

All of this is ready for version control, CI pipelines, or deployment to a serverless platform.


Common Mistakes

Mistake Why It Happens Fix
Forgetting to source.venv/bin/activate before running uv sync Installs packages globally or into the wrong venv Always activate the venv first; the prompt will show (.venv)
Committing .env to Git Exposes secret keys publicly Verify .gitignore contains .env and run git status before committing
Using an outdated uv.lock after adding a new dependency The lock file still points to old versions Run uv lock again after any change to pyproject.toml
Misspelling OPENAI_API_KEY in .env os.getenv returns None → authentication error Double‑check the variable name and reload the env (source.env or restart the script)

Troubleshooting

  • ImportError: No module named 'dotenv' Run uv sync again; ensure python-dotenv is listed under [dependency-groups].

  • openai.error.AuthenticationError

  • Verify the key in .env matches the one in your OpenAI dashboard.
  • Ensure there are no stray spaces or newline characters.

  • uv lock hangs or fails

  • Check your internet connection.
  • Upgrade uv (uv self update) to the latest stable version.

  • Script runs but returns an empty string

  • The model may have throttled your request; add max_retries=3 in the ChatCompletion.create call.

Key Takeaways

  • Lock files (uv.lock) are the single source of truth for dependencies.
  • Environment variables keep secrets out of source control.
  • A minimal, reproducible setup can be achieved in under 10 minutes with uv.
  • Always verify each step (✅ Verify:) before moving on; it saves debugging time later.

🧪 Try It Yourself

  1. Swap models – change "gpt-3.5-turbo" to "gpt-4" (if you have access) and observe the difference.
  2. Add a second message – modify messages to include a system prompt that sets a tone, e.g., {"role": "system", "content": "You are a friendly tutor."}.
  3. Create a CLI – use argparse to accept the prompt from the command line instead of hard‑coding it.

Happy coding! If anything feels fuzzy, reach out on LinkedIn – I’m happy to walk through any step with you. 🚀

Chapter 3

The Python You Need for GenAI

Save as: chapter1.py

Why This Matters

Tip: After running each command in this chapter, check the output. If you see an error, scroll down to "Common Mistakes" at the bottom of this chapter.

Activate Your Virtual Environment

If you have not activated your venv yet (check: do you see (.venv) in your prompt?):

Windows PowerShell:

   .venv\Scripts\Activate

Mac/Linux:

   source .venv/bin/activate

Expected: Your prompt now starts with (.venv).


Before You Start This Chapter

  • Your venv is active (you see `(.

    What is a virtual environment (venv)? It is a separate, isolated Python world for your project. Without it, packages from different projects can conflict. With a venv, each project gets its own clean set of packages. Think of it like a separate browser profile for each website.

venv)in your prompt) - Previous chapter's project is working - Your.env` file has your API key

Get Your OpenAI API Key (if you do not have one)

  1. Go to https://platform.openai.com/api-keys
  2. Log in (or create a free account)
  3. Click "Create new secret key"
  4. Name it: adhikk-book
  5. Click "Create"
  6. Copy the key immediately (starts with sk-) — you will NOT see it again
  7. Paste it into your .env file:

    OPENAI_API_KEY=sk-your_key_here

New accounts get $5 free credit — more than enough for this entire book.

  • You are in the correct folder (run pwd to check)

Python is a versatile programming language used extensively in various fields, including artificial intelligence, machine learning, and data analysis. Understanding the basics of Python is crucial for anyone looking to build applications, including those using generative AI.

What You'll Build

In this chapter, we will cover the fundamentals of Python, including variables, strings, and lists. We will also explore how to clean and process data using Python's built-in functions.

Content

Variables

Variables are used to store information in a program. In Python, variables are created using the = operator.

name = "John Doe"
age = 30

The name variable stores the string "John Doe", while the age variable stores the integer 30.

Strings

Strings are sequences of characters used to represent text. In Python, strings are enclosed in quotes.

greeting = "Hello, World!"
print(greeting)

Output:

Hello, World!

Lists

Lists are ordered collections of values. In Python, lists are enclosed in square brackets.

fruits = ["Apple", "Banana", "Cherry"]
print(fruits[0])

Output:

Apple

Cleaning and Processing Data

Data cleaning is an essential step in data analysis. In Python, we can use the strip() function to remove whitespace from a string.

data = " Hello, World! "
clean_data = data.strip()
print(clean_data)

Output:

Hello, World!

We can also use the lower() function to convert a string to lowercase.

data = "HELLO, WORLD!"
clean_data = data.lower()
print(clean_data)

Output:

hello, world!

Lists and Indexing

Lists are ordered collections of values. In Python, we can access elements in a list using indexing.

fruits = ["Apple", "Banana", "Cherry"]
print(fruits[0]) # Apple
print(fruits[1]) # Banana
print(fruits[2]) # Cherry

Lists and Slicing

Lists can be sliced to extract a subset of elements.

fruits = ["Apple", "Banana", "Cherry"]
print(fruits[1:3]) # ["Banana", "Cherry"]

Lists and Iterating

Lists can be iterated over using a for loop.

fruits = ["Apple", "Banana", "Cherry"]
for fruit in fruits:
 print(fruit)

Output:

Apple
Banana
Cherry

Lists and Indexing

Lists have an index that starts from 0.

fruits = ["Apple", "Banana", "Cherry"]
print(fruits[0]) # Apple
print(fruits[1]) # Banana
print(fruits[2]) # Cherry

Lists and Slicing

Lists can be sliced to extract a subset of elements.

fruits = ["Apple", "Banana", "Cherry"]
print(fruits[1:3]) # ["Banana", "Cherry"]

Lists and Iterating

Lists can be iterated over using a for loop.

fruits = ["Apple", "Banana", "Cherry"]
for fruit in fruits:
 print(fruit)

Output:

Apple
Banana
Cherry

Lists and Indexing

Lists have an index that starts from 0.

fruits = ["Apple", "Banana", "Cherry"]
print(fruits[0]) # Apple
print(fruits[1]) # Banana
print(fruits[2]) # Cherry

Lists and Slicing

Lists can be sliced to extract a subset of elements.

fruits = ["Apple", "Banana", "Cherry"]
print(fruits[1:3]) # ["Banana", "Cherry"]

Lists and Iterating

Lists can be iterated over using a for loop.

fruits = ["Apple", "Banana", "Cherry"]
for fruit in fruits:
 print(fruit)

Output:

Apple
Banana
Cherry

📁 Project Files

Create a new file called data_cleaning.py and add the following code:

# Save as: data_cleaning.py

# Import the necessary libraries
import re

# Define a function to clean data
def clean_data(data):
 # Remove whitespace from the data
 data = data.strip()
 # Convert the data to lowercase
 data = data.lower()
 # Remove punctuation from the data
 data = re.sub(r'[^\w\s]', '', data)
 return data

# Test the function
data = " Hello, World! "
clean_data = clean_data(data)
print(clean_data)

🚀 What You Just Built

You have built a function to clean data using Python. The function takes a string as input, removes whitespace, converts it to lowercase, and removes punctuation.

Common Mistakes

  • Forgetting to import the necessary libraries
  • Not handling errors properly
  • Not testing the function thoroughly

Troubleshooting

  • Check if the necessary libraries are imported correctly
  • Check if the function is defined correctly
  • Check if the data is in the correct format

Key Takeaways

  • Variables are used to store information in a program.
  • Strings are sequences of characters used to represent text.
  • Lists are ordered collections of values.
  • Data cleaning is an essential step in data analysis.
  • Python has built-in functions to clean and process data.

🧪 Try It Yourself

Try cleaning a dataset using the clean_data function. Experiment with different cleaning techniques and see how they affect the output.

F Strings in Python

Introduction

F strings are a feature in Python that allows you to embed expressions inside string literals. This feature was introduced in Python 3.6 and is a powerful tool for formatting strings.

Example Code

name = "Aman"
age = 24

# Using f-strings
greeting = f"Hi my name is {name} and I am {age} years old."

print(greeting)

Output:

Hi my name is Aman and I am 24 years old.

Using F-Strings with Variables

name = "Rudraksh"
age = 25

# Using F-strings
greeting = f"Hi my name is {name} and I am {age} years old."

print(greeting)

Output:

Hi my name is Rudraksh and I am 25 years old.

Using F-Strings with Multiple Expressions

name = "Rudraksh"
age = 25
city = "Delhi"

# Using F-strings
greeting = f"Hi my name is {name} and I am {age} years old from {city}."

print(greeting)

Output:

Hi my name is Rudraksh and I am 25 years old from Delhi.

Common Use Cases for F-Strings

F-strings are commonly used for formatting strings in Python. They are particularly useful when you need to embed expressions inside a string.

Tips and Tricks

  • F-strings are a powerful tool for formatting strings in Python.
  • They can be used with variables, expressions, and even other f-strings.
  • F-strings are more readable and efficient than other string formatting methods.

Common Mistakes

  • F-strings can be tricky to use, especially when working with complex expressions.
  • Make sure to use the correct syntax and formatting when using f-strings.

Troubleshooting

  • If you encounter errors when using f-strings, check that you are using the correct syntax and formatting.
  • Make sure that the expressions inside the f-string are valid and can be evaluated.

Key Takeaways

  • F-strings are a powerful tool for formatting strings in Python.
  • They can be used with variables, expressions, and even other f-strings.
  • F-strings are more readable and efficient than other string formatting methods.

Why This Matters

The strip() function is used to remove leading and trailing whitespace from a string. This is useful when you want to clean up data before processing it. In this chapter, we'll learn how to create a custom function to strip a string and explore its usage.

What You'll Build

In this chapter, we'll build a simple function to strip a string, call it, and print its result. We'll also learn how to capitalize the first letter of a string and replace words with a more powerful version.

Content

def strip_string(s):
 """Remove leading and trailing whitespace from a string"""
 return s.strip()

# Test the function
print(strip_string(" Hello World "))
# Output: Hello World

# Verify:
def capitalize_title(s):
 """Capitalize the first letter of a string"""
 return s.capitalize()

# Test the function
print(capitalize_title("hello world"))
# Output: Hello World

# Verify:
def replace_words(s, old_word, new_word):
 """Replace a word with a new word in a string"""
 return s.replace(old_word, new_word)

# Test the function
print(replace_words("Python is great", "great", "powerful"))
# Output: Python is powerful

# Verify:

📁 Project Files

  • strip_string.py
  • capitalize_title.py
  • replace_words.py

🚀 What You Just Built

You've built three functions:

  1. strip_string(): removes leading and trailing whitespace from a string.
  2. capitalize_title(): capitalizes the first letter of a string.
  3. replace_words(): replaces a word with a new word in a string.

Common Mistakes

  • Make sure to use the correct method to remove whitespace (strip() instead of lstrip() or rstrip()).
  • Be careful when using string replacement, as it can modify the original string.

Troubleshooting

  • If the strip_string() function doesn't work as expected, check if the input string contains only whitespace characters.
  • If the capitalize_title() function doesn't work as expected, check if the input string is empty or contains only whitespace characters.

Key Takeaways

  • Use the strip() method to remove leading and trailing whitespace from a string.
  • Use the capitalize() method to capitalize the first letter of a string.
  • Use the replace() method to replace a word with a new word in a string.

🧪 Try It Yourself

  • Create a new string with leading and trailing whitespace.
  • Use the strip_string() function to remove the whitespace.
  • Print the result.
  • Repeat the process with the capitalize_title() function.
  • Repeat the process with the replace_words() function.

What's Next

Continue to the next chapter to keep building.

Chapter 4

Making Your First API Call

Chapter 1: The Python Mindset – Why You Don’t Need to Memorize

Why This Matters

If you are reading this book, you likely have a goal: to build AI applications. You are not here to become a "software engineer" in the traditional sense of writing low-level system code or optimizing database queries. Your goal is to become comfortable with Python so that you can focus on the logic of AI, not the syntax of the language.

Many beginners make a critical mistake: they try to memorize every function, every parameter, and every edge case. This is a losing battle. Python is vast, and the AI ecosystem (LangChain, LangGraph, OpenAI API) is even vaster.

The Core Philosophy of This Chapter: 1. Understand, Don't Memorize: You only need to know that a function exists and what it does. You do not need to know how to write it from scratch. 2. Leverage the Ecosystem: If you have worked with Pandas or NumPy, you know that libraries make life easy. You reuse code. You don't reinvent the wheel. 3. The "Why" First: Every line of code should answer a question: Why does this AI app need this specific instruction?

In this chapter, we will strip away the fear of programming. We will look at how computers actually process instructions, compare human language to machine language, and write your first real Python script. By the end, you will understand why Python is the perfect vehicle for AI development.

What You'll Build

In this chapter, you will: 1. Understand the fundamental definition of programming (giving precise instructions). 2. Learn the difference between Natural Language, Pseudocode, and Actual Code. 3. Write your first Python script to print numbers 1 to 10. 4. Understand the concept of "Zero-Based Indexing" and "Exclusive Ranges" in Python. 5. Set up your environment to run Python code.

1. What is Programming, Really?

Let’s strip away the jargon.

Programming is simply giving a computer instructions.

But there is a catch: Computers are not smart. They are precise. If you tell a human, "Give me the numbers from 1 to 10," they understand. They know you mean 1, 2, 3. up to 10. They might ask, "Do you include 10?" but they get the gist.

A computer does not have a "gist." It needs precise instructions.

The Evolution of Instructions

Let’s look at how we translate a simple task into something a computer can execute.

Step 1: Natural Language (Human)

"Give me the numbers from 1 to 10."

A human reads this and understands the intent. A computer cannot read this. It sees characters, not meaning.

Step 2: Pseudocode (The Bridge)

Pseudocode is a way of writing code that looks like code but is written in plain English. It is the bridge between human thought and machine execution.

Pseudocode: text Start Repeat until 10: Show the number Add 1 to the number End

This is much closer to what a computer understands. It has a structure: a start, a loop, an action, and an end.

Step 3: Actual Code (Python)

Python is famous because it looks very similar to Pseudocode. It is readable. It is close to English.

Python: python for i in range(1, 11): print(i)

Notice how close this is to the pseudocode? - for i in range. is the "Repeat until." - print(i) is the "Show the number"

This is why Python is the dominant language for AI. It allows you to focus on the logic (the "what") rather than the syntax (the "how").

2. The "Understand, Don't Memorize" Rule

Here is the most important rule for this book:

You do not need to memorize how to write a function. You only need to understand that the function exists and what it does.

Example: The range() Function

In the code above, we used range(1, 11).

Beginner Mistake: Trying to memorize the internal algorithm of how range generates numbers. Correct Approach: 1. I know range exists. 2. I know it generates a sequence of numbers. 3. I know it takes a start and an end. 4. Crucial Detail: I know the end number is exclusive (not included).

If I forget how to write range, I don't panic. I open my browser, type "Python range function," and I find the documentation in 5 seconds. My job is to call the function, not build it.

This applies to everything in AI: - You don't need to know how the Transformer architecture works at the matrix multiplication level to use Hugging Face. - You don't need to know how LangChain parses documents to use it. - You just need to know: "There is a tool that does X. Here is how I call it."

3. Writing Your First Python Script

Let’s build the "1 to 10" example properly. We will create a file, write the code, and run it.

Prerequisites

You need Python installed on your computer. - Windows: Download from python.org - Mac: Python is usually pre-installed, or install via Homebrew (brew install python) - Linux: Usually pre-installed.

Verify: Open your terminal (Command Prompt on Windows, Terminal on Mac/Linux) and type:

python --version

You should see something like Python 3.9.x or higher. If you see an error, install Python.

The Code

Create a new file named first_script.py.

# Save as: first_script.py

# This is a comment. Python ignores this.
# We are going to print numbers from 1 to 10.

# The range function generates numbers from start to stop-1.
# So range(1, 11) gives us 1, 2, 3, 4, 5, 6, 7, 8, 9, 10.

for i in range(1, 11):
 print(i)

Running the Code

In your terminal, navigate to the folder where you saved first_script.py and run:

python first_script.py

Expected Output

1
2
3
4
5
6
7
8
9
10

Verify: Did you see the numbers 1 through 10 printed on separate lines? If yes, you have successfully written and executed your first Python program.

4. Deep Dive: Why range(1, 11) and Not range(1, 10)?

This is the most common confusion for beginners.

In Python, the range() function works like this: range(start, stop)

  • It starts at start.
  • It stops before stop.

So: - range(1, 10) produces: 1, 2, 3, 4, 5, 6, 7, 8, 9 (It stops at 9). - range(1, 11) produces: 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 (It stops at 10).

Why is it designed this way?

It is called Zero-Based Indexing and Exclusive Ranges. This design makes math easier for computers.

If you want the length of a list, you can just use the stop value.

numbers = list(range(1, 11))
print(len(numbers)) # Output: 10

If range included the end number, calculating lengths and indices would require constant +1 adjustments, leading to more bugs.

💡 Tip: When you see range(0, 10), think "0 to 9". When you see range(1, 11), think "1 to 10".

5. The AI Connection: Why This Matters for LLMs

You might be thinking: "This is just printing numbers. How does this help me build AI apps?"

Here is the connection:

  1. Loops are Everywhere: When you process a dataset of 1,000 customer reviews, you will use a for loop to send each review to the AI model.
  2. Precision is Key: AI models are sensitive to input. If you feed them data in the wrong order or miss one item because of an off-by-one error (like using range(1, 10) instead of range(1, 11)), your AI app will fail silently.
  3. Reusability: Once you understand how to loop through data in Python, you can loop through:
  4. A list of prompts.
  5. A list of documents for RAG (Retrieval-Augmented Generation).
  6. A list of API responses.

The syntax is simple. The power is in the application.

📁 Project Files

Here is the complete file structure for this chapter.

chapter_1/
├── first_script.py

first_script.py

# Save as: first_script.py

# 1. Basic Loop
print("Printing numbers 1 to 10:")
for i in range(1, 11):
 print(i)

print("\n---\n")

# 2. Understanding Range
# Let's see what happens if we make a mistake
print("Mistake: range(1, 10) - Note: 10 is missing!")
for i in range(1, 10):
 print(i, end=" ")
print() # New line

print("\n---\n")

# 3. Using Range with a List (AI Context)
# Imagine these are 3 user prompts we need to send to an AI
prompts = [
 "What is the capital of France?",
 "Write a haiku about Python.",
 "Explain quantum physics simply."
]

print("Processing AI Prompts:")
for index, prompt in enumerate(prompts, start=1):
 # In a real AI app, we would send 'prompt' to an API here.
 # For now, we just print it to show the loop works.
 print(f"Prompt {index}: {prompt}")

Expected Output

Printing numbers 1 to 10:
1
2
3
4
5
6
7
8
9
10

---

Mistake: range(1, 10) - Note: 10 is missing!
1 2 3 4 5 6 7 8 9 

---

Processing AI Prompts:
Prompt 1: What is the capital of France?
Prompt 2: Write a haiku about Python.
Prompt 3: Explain quantum physics simply.

Verify: Run python first_script.py. Do you see the prompts listed with their index numbers? This is exactly how you will iterate over data when building AI applications.

🚀 What You Just Built

You have: 1. Written your first Python script. 2. Executed it in a terminal. 3. Understood the difference between range(1, 10) and range(1, 11). 4. Seen how loops are used to process lists of data (a core pattern in AI development).

Common Mistakes

1. Forgetting the Colon :

# ❌ Wrong
for i in range(1, 11)
 print(i)

# ✅ Correct
for i in range(1, 11):
 print(i)

Python uses colons to indicate the start of a block of code.

2. Indentation Errors

# ❌ Wrong (Inconsistent indentation)
for i in range(1, 11):
print(i)

# ✅ Correct (4 spaces or 1 tab)
for i in range(1, 11):
 print(i)

Python uses indentation (whitespace) to define code blocks. You must be consistent. The standard is 4 spaces.

3. Using range(1, 10) when you want 10

As discussed, range stops before the end number. Always add 1 to your desired end number if you want to include it.

Troubleshooting

Problem: python command not found. Solution: - Windows: Make sure you checked "Add Python to PATH" during installation.

What is PATH? PATH is a list of folders your computer checks when you type a command. If you type python, your computer looks in each PATH folder for a file called python.exe. If it finds it, it runs it. If not, you get "command not found." Installing Python with "Add to PATH" checked adds Python's folder to this list.

If not, reinstall Python and check that box. - Mac/Linux: Try python3 instead of python.

Problem: IndentationError: unexpected indent Solution: Check your spaces. Ensure all lines inside the for loop are indented by the same amount (4 spaces).

Problem: SyntaxError: invalid syntax Solution: Check for missing colons : or parentheses ().

Key Takeaways

  1. Programming is giving precise instructions. Computers are not smart; they are literal.
  2. Understand, Don't Memorize. You don't need to know how range works internally. You just need to know how to call it.
  3. Python is Readable. It is close to English, which makes it ideal for AI developers who need to focus on logic, not syntax.
  4. range(start, stop) is exclusive. range(1, 11) gives you 1 to 10.
  5. Loops are your best friend. You will use them to process data, send prompts, and handle API responses.

🧪 Try It Yourself

  1. Change the Range: Modify first_script.py to print numbers from 5 to 15. What should the range() call look like?
  2. Add a Step: Can you make the loop print only even numbers? (Hint: You can use range(start, stop, step)).
  3. Create a List: Create a list of 5 AI-related terms (e.g., "LLM", "RAG", "Prompt", "Token", "API").

    What is a token? A token is how AI models count text. Think of it as a "word piece" — roughly 3-4 characters. "Hello" = 1 token. "Hello world" = 2 tokens. Models have a limit on how many tokens they can process at once (called the "context window").

Loop through them and print each one with a prefix like "Term 1: LLM".

Expected Output for Try It Yourself #3:

Term 1: LLM
Term 2: RAG
Term 3: Prompt
Term 4: Token
Term 5: API

Once you can do this, you are ready for Chapter 2, where we will start building actual AI applications using the OpenAI API.

Chapter 2: The AI Developer’s Toolkit – Python, APIs, and Your First Environment

Why This Matters

You might be thinking, "I have ChatGPT. Why do I need to learn Python? Why do I need to write code at all?"

This is the most common misconception among beginners. Here is the hard truth: ChatGPT is a product, not a platform. It is a pre-built application with a fixed interface. You cannot embed ChatGPT into your own e-commerce site, your own data pipeline, or your own mobile app. You cannot fine-tune its behavior to strictly follow your company’s legal guidelines. You cannot process 10,000 documents in parallel.

To build your own AI agents, you need to interact with the underlying models (like GPT-4, Llama, or Mistral) via APIs (Application Programming Interfaces). And the language that the entire AI ecosystem speaks is Python.

In this chapter, we will: 1. Understand why Python is the undisputed king of AI. 2. Clarify the difference between a "Chat App" and an "API." 3. Set up a zero-install coding environment (Google Colab). 4. Write and execute your first Python script. 5. Make your first API call to an AI model.


What You'll Build

By the end of this chapter, you will have: * A working Google Colab notebook. * A Python script that imports essential AI libraries. * A functional API call to an AI model (using a free tier or mock data for now) that returns a text response. * A clear mental model of how data flows from your code to the AI model and back.


1. Why Python?

You asked: "Which language do almost all AI research papers ship their code in?"

Direct Answer: Python.

The Ecosystem Advantage

Python is not just a language; it is an ecosystem. * Libraries: Libraries like PyTorch, TensorFlow, Hugging Face Transformers, and LangChain are written in Python. If you try to use C++ or Java for AI, you will spend 80% of your time fighting with bindings and 20% building. In Python, you spend 90% building. * Community: When a new AI model is released (e.g., Llama 3), the Python implementation is available within hours. * Readability: Python code looks like pseudocode. This allows you to focus on the logic of the AI agent, not the syntax of the language.

⚠️ Warning: Do not try to learn C++ or Java for AI right now. You will get stuck in the weeds. Stick to Python.


2. ChatGPT vs. API

Let’s clear up the confusion.

Feature ChatGPT (Web App) API (Programmatic Access)
Interface Browser UI (Chat box) Code (Python, JS, etc.)
Customization None (Fixed UI) Full Control (Prompt engineering, temperature, max tokens)
Integration Standalone Can be embedded in your app
Cost Model Subscription (Pro) Pay-per-use (Tokens)
Use Case Personal Assistant Building Products

What is an API?

An API (Application Programming Interface) is a set of rules that allows one software application to communicate with another.

Analogy: * ChatGPT is like a restaurant where you sit at a table, order food, and eat. * API is like a delivery service. You send an order (request) via a phone app, the kitchen (AI Model) prepares the food, and it is delivered to your house (your application). You don’t see the kitchen; you just get the result.

In this book, we will use APIs to build AI agents.


3. Setting Up Your Environment: Google Colab

You do not need to install Python, Jupyter, or any libraries on your local machine right now. We will use Google Colab (Colaboratory).

Why Colab?

  1. Free: No cost for basic usage.
  2. No Setup: It runs in your browser.
  3. GPU Access: You can access free GPUs for heavy AI tasks (later chapters).
  4. Shareable: You can share your code with others via a link.

Step-by-Step Setup

  1. Go to colab.research.google.com.
  2. Sign in with your Google account.
  3. Click File > New Notebook.
  4. Name your notebook Chapter2_AI_Environment.

Verify: You should see a blank notebook with a cell that says # Welcome to Colab.


4. Your First Python Code

Let’s write some code to ensure our environment is working.

Step 1: Basic Python Syntax

In the first cell of your Colab notebook, type the following:

# Save as: chapter2_basic.py (Conceptual - in Colab, just run the cell)

# This is a comment. Python ignores it.
print("Hello, AI Developer!")

# Variables
model_name = "GPT-4"
temperature = 0.7

print(f"Model: {model_name}")
print(f"Temperature: {temperature}")

Expected Output:

Hello, AI Developer!
Model: GPT-4
Temperature: 0.7

Verify: Click the Play button (▶️) on the left side of the cell. The output should appear below the cell.

Step 2: Installing AI Libraries

In AI, we rarely write everything from scratch. We use libraries. Let’s install the most important one for API calls: requests.

Create a new cell and run:

# Install the requests library
!pip install requests

💡 Tip: The ! symbol in Jupyter/Colab notebooks allows you to run system commands (like pip install) directly.

Expected Output:

Collecting requests
 Downloading requests-2.31.0-py3-none-any.whl (62 kB).
Successfully installed requests-2.31.0

Verify: You should see "Successfully installed" at the end of the output.


5. Making Your First API Call

Now, let’s simulate an API call. In a real scenario, you would use an API key from OpenAI, Anthropic, or Hugging Face. For this chapter, we will use a mock API to demonstrate the structure without needing a credit card.

Understanding the API Request Structure

An API request typically has three parts: 1. Endpoint: The URL where the service lives. 2. Headers: Metadata (like your API key). 3. Body: The actual data (your prompt).

Step 3: The API Call Script

Create a new cell and paste the following code. This code simulates sending a prompt to an AI model and receiving a response.

# Save as: chapter2_api_call.py

import requests
import json

# 1. Define the API Endpoint
# Note: This is a MOCK endpoint for demonstration. 
# In real life, you would use https://api.openai.com/v1/chat/completions
url = "https://httpbin.org/post" 

# 2. Define the Headers
# In a real API, this would include: "Authorization": "Bearer YOUR_API_KEY"
headers = {
 "Content-Type": "application/json"
}

# 3. Define the Payload (Body)
# This is what you are sending to the AI
payload = {
 "model": "gpt-4",
 "messages": [
 {
 "role": "system",
 "content": "You are a helpful assistant."
 },
 {
 "role": "user",
 "content": "Explain what an API is in one sentence."
 }
 ],
 "temperature": 0.7
}

# 4. Make the Request
print("Sending request to API.")
response = requests.post(url, headers=headers, json=payload)

# 5. Check the Response
print(f"Status Code: {response.status_code}")

if response.status_code == 200:
 # Parse the JSON response
 data = response.json()

 # In a real AI API, the response structure would be different.
 # Here, httpbin.org just echoes back what we sent.
 print("Response received:")
 print(json.dumps(data, indent=2))
else:
 print(f"Error: {response.status_code}")
 print(response.text)

Expected Output:

Sending request to API.
Status Code: 200
Response received:
{
 "args": {},
 "data": "{\"model\": \"gpt-4\", \"messages\": [.], \"temperature\": 0.7}",
 "files": {},
 "form": {},
 "headers": {
 "Content-Type": "application/json",.
 },
 "json": {
 "model": "gpt-4",
 "messages": [
 {
 "role": "system",
 "content": "You are a helpful assistant."
 },
 {
 "role": "user",
 "content": "Explain what an API is in one sentence."
 }
 ],
 "temperature": 0.7
 },
 "origin": "Your IP Address",
 "url": "https://httpbin.org/post"
}

Verify: 1. The status code is 200 (Success). 2. The json field in the response contains your exact prompt. 3. This proves that your code successfully sent data to a server and received a structured response.

⚠️ Important: This was a mock call. httpbin.org is a testing service that just echoes back your data. It does not generate AI text. In the next chapter, we will connect to a real AI provider.


6. Real-World API Structure (Preview)

To prepare you for the next chapter, here is what a real OpenAI API call looks like. You do not need to run this yet (you need an API key), but read it carefully.

# Save as: chapter2_real_api_preview.py

import requests
import json

# You would get this key from https://platform.openai.com/api-keys
API_KEY = "sk-your-actual-key-here" 

url = "https://api.openai.com/v1/chat/completions"

headers = {
 "Authorization": f"Bearer {API_KEY}",
 "Content-Type": "application/json"
}

payload = {
 "model": "gpt-4",
 "messages": [
 {"role": "system", "content": "You are a helpful assistant."},
 {"role": "user", "content": "What is 2+2?"}
 ],
 "max_tokens": 50
}

# This is the actual call
# response = requests.post(url, headers=headers, json=payload)
# print(response.json())

Key Differences from the Mock: 1. Authorization Header: Contains your secret API key. 2. Endpoint: Points to api.openai.com. 3. Response: Would contain the actual AI-generated text in response['choices'][0]['message']['content'].


📁 Project Files

Here is the structure of the files we created in this chapter. In Colab, these are just cells, but if you were working locally, they would look like this:

chapter2/
├── chapter2_basic.py
├── chapter2_api_call.py
└── chapter2_real_api_preview.py

🚀 What You Just Built

  1. Environment: You set up a professional-grade Python environment (Colab) without installing anything locally.
  2. Library Management: You learned how to install libraries using !pip install.
  3. API Communication: You wrote code that sends a structured JSON payload to a server and parses the response.
  4. Mental Model: You now understand that AI is not magic; it is a service you talk to via HTTP requests.

Common Mistakes

  1. Forgetting the ! in Colab:
  2. Mistake: pip install requests
  3. Fix: !pip install requests
  4. Why: Without !, Python tries to run pip as a Python command, which fails.

  5. Hardcoding API Keys:

  6. Mistake: API_KEY = "sk-123456" in your code.
  7. Fix: Use environment variables.
  8. Why: If you share your code, your key is exposed. In the next chapter, we will show you how to use environment variables securely.

  9. Ignoring Status Codes:

  10. Mistake: Assuming the API always works.
  11. Fix: Always check if response.status_code == 200.
  12. Why: APIs can fail due to rate limits, invalid keys, or server errors. Your code must handle these gracefully.

Troubleshooting

Problem Solution
ModuleNotFoundError: No module named 'requests' Run !pip install requests in a new cell.
ConnectionError Check your internet connection. Colab needs internet to reach external APIs.
401 Unauthorized (in real API calls) Your API key is wrong or expired. Check your provider’s dashboard.
429 Too Many Requests You hit the rate limit. Wait a minute or upgrade your plan.

Key Takeaways

  1. Python is the language of AI. Do not switch to C++ or Java.
  2. ChatGPT is a product; APIs are tools. You build with APIs.
  3. Colab is your sandbox. Use it for learning and prototyping.
  4. APIs use JSON. You send JSON, you receive JSON.
  5. Always check status codes. Never assume success.

🧪 Try It Yourself

  1. Modify the Prompt: In the chapter2_api_call.py cell, change the user content to "Write a haiku about Python." Run it again. Does the structure change? (It shouldn’t, because it’s a mock, but observe the JSON).
  2. Add a New Field: Add "max_tokens": 100 to the payload. Run it again. Does the response include this field?
  3. Break It Intentionally: Change the URL to https://httpbin.org/get (instead of /post). Run it. What happens? (Hint: The method is wrong).

Next Chapter Preview

In Chapter 3, we will: * Get a real API key from a free provider (Hugging Face or OpenAI). * Make a real API call that generates actual AI text. * Learn how to handle errors and rate limits. * Build your first "AI Agent" that can answer questions.

Ready? Let’s go.

Chapter 3: Mastering API Integration with Python

Why This Matters

In the previous section, we established that building infrastructure from scratch (like satellite tracking or map rendering) is inefficient and expensive. Companies like Swiggy or Uber do not build their own GPS networks; they consume APIs (Application Programming Interfaces) provided by giants like Google Maps.

An API is a contract between two software systems. It allows your code to ask a remote server for data (e.g., "What is the distance between Point A and Point B?") and receive a structured response (usually JSON).

In this chapter, you will learn how to: 1. Set up a Python environment using Google Colab (free, cloud-based). 2. Understand the anatomy of an HTTP Request (GET, POST, Headers, Body). 3. Make your first API call using the requests library. 4. Parse JSON responses and extract specific data. 5. Handle errors and authentication (API Keys).

By the end of this chapter, you will have built a Real-Time Weather Dashboard and a Geolocation Distance Calculator that interacts with live external servers.


What You'll Build

  1. Weather Fetcher: A script that fetches current weather data for any city from the OpenWeatherMap API.
  2. Distance Calculator: A script that calculates the travel time and distance between two coordinates using the Google Distance Matrix API (simulated with a free alternative for this chapter to avoid billing, but the logic is identical).
  3. API Client Class: A reusable Python class that handles API requests, error checking, and data parsing.

1. Setting Up the Environment: Google Colab

As mentioned in the previous section, you don't need to install Python locally to start. Google Colab provides a free Jupyter Notebook environment in your browser.

Step 1: Access Colab

  1. Go to colab.research.google.com.
  2. Log in with your Google Account.
  3. Click File > New Notebook.

Step 2: Install Required Libraries

We will use the requests library to handle HTTP calls. While requests is often pre-installed, it's good practice to ensure it's available.

# Save as: setup_cell.py
# Run this cell in Colab

import sys

# Check if requests is installed
try:
 import requests
 print(f"✅ 'requests' library is already installed (version {requests.__version__})")
except ImportError:
 print("❌ 'requests' not found. Installing.")
 !pip install requests
 import requests
 print(f"✅ 'requests' installed successfully (version {requests.__version__})")

Expected Output:

✅ 'requests' library is already installed (version 2.31.0)

(Note: The version number may vary.)

Verify: If you see the success message, your environment is ready.


2. Anatomy of an API Call

Before writing code, understand what happens when you call an API.

The HTTP Request

An API call is an HTTP Request. It has four main parts:

  1. Method:
  2. GET: Retrieve data (e.g., "Give me the weather for New York").
  3. POST: Send data to the server (e.g., "Create a new user").
  4. PUT: Update existing data.
  5. DELETE: Remove data.
  6. URL (Endpoint): The address of the resource (e.g., https://api.openweathermap.org/data/2.5/weather).
  7. Headers: Metadata about the request (e.g., Authorization: Bearer <token>, Content-Type: application/json).
  8. Body: The data being sent (only for POST/PUT requests).

The HTTP Response

The server responds with: 1. Status Code: * 200: Success. * 400: Bad Request (your parameters are wrong). * 401: Unauthorized (missing or invalid API key). * 404: Not Found (endpoint doesn't exist). * 500: Server Error (something broke on their end). 2. Headers: Metadata about the response. 3. Body: The actual data, usually in JSON format.


3. Making Your First API Call: Weather Data

We will use OpenWeatherMap, a free API for weather data.

Step 1: Get an API Key

  1. Go to openweathermap.org/api.
  2. Sign up for a free account.
  3. Go to API Keys in the dashboard.
  4. Copy your API Key (it looks like a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6).

⚠️ Warning: Never hardcode your API key in public code or commit it to GitHub. For this chapter, we will store it in a variable. In production, use environment variables.

Step 2: Write the Code

# Save as: weather_fetcher.py

import requests
import json

# 1. Define the API Key
API_KEY = "YOUR_API_KEY_HERE" # Replace with your actual key

# 2. Define the Endpoint
BASE_URL = "http://api.openweathermap.org/data/2.5/weather"

# 3. Define Parameters
# We want weather for "London" in metric units (Celsius)
params = {
 "q": "London",
 "units": "metric",
 "appid": API_KEY
}

# 4. Make the GET Request
print("🔄 Sending request to OpenWeatherMap.")
response = requests.get(BASE_URL, params=params)

# 5. Check the Status Code
print(f"Status Code: {response.status_code}")

if response.status_code == 200:
 # 6. Parse the JSON Response
 weather_data = response.json()

 # 7. Extract Specific Data
 city = weather_data['name']
 temp = weather_data['main']['temp']
 feels_like = weather_data['main']['feels_like']
 humidity = weather_data['main']['humidity']
 description = weather_data['weather'][0]['description']

 # 8. Display Results
 print("\n" + "="*30)
 print(f"🌍 City: {city}")
 print(f"🌡️ Temperature: {temp}°C")
 print(f"🧊 Feels Like: {feels_like}°C")
 print(f"💧 Humidity: {humidity}%")
 print(f"☁️ Condition: {description}")
 print("="*30)

 # Optional: Print the full JSON to see the structure
 # print(json.dumps(weather_data, indent=2))

else:
 print(f"❌ Error: {response.text}")

Expected Output:

🔄 Sending request to OpenWeatherMap.
Status Code: 200

==============================
🌍 City: London
🌡️ Temperature: 15.3°C
🧊 Feels Like: 14.1°C
💧 Humidity: 78%
☁️ Condition: light rain
==============================

Verify: 1. Did you receive a 200 status code? 2. Does the temperature match the current weather in London? 3. If you see 401 Unauthorized, your API key is wrong. 4. If you see 404 Not Found, check the URL or parameters.

💡 Tip: If you are unsure about the JSON structure, use print(json.dumps(response.json(), indent=2)) to see the full nested object. This is the most common debugging step for API work.


4. Handling Errors Like a Pro

In production, APIs fail. Networks drop, servers go down, or you might make a typo in a parameter. Your code must handle these gracefully.

Common Error Scenarios

  1. Connection Error: The server is unreachable.
  2. Timeout: The server took too long to respond.
  3. HTTP Error: The server responded, but with an error code (4xx or 5xx).

Robust API Call Function

# Save as: robust_api_call.py

import requests
from requests.exceptions import ConnectionError, Timeout, HTTPError

def fetch_data(url, params=None, headers=None, timeout=10):
 """
 A robust function to fetch data from an API.

 Args:
 url (str): The endpoint URL.
 params (dict): Query parameters.
 headers (dict): Request headers.
 timeout (int): Seconds to wait before timing out.

 Returns:
 dict: Parsed JSON data if successful, None otherwise.
 """
 try:
 print(f"📡 Requesting: {url}")
 response = requests.get(url, params=params, headers=headers, timeout=timeout)

 # Raise an exception for 4xx or 5xx status codes
 response.raise_for_status()

 # Parse JSON
 data = response.json()
 print("✅ Success: Data received.")
 return data

 except HTTPError as http_err:
 print(f"❌ HTTP Error occurred: {http_err}")
 print(f" Response: {response.text}")
 return None

 except ConnectionError:
 print("❌ Connection Error: Could not reach the server.")
 return None

 except Timeout:
 print("❌ Timeout Error: The server took too long to respond.")
 return None

 except Exception as e:
 print(f"❌ Unexpected Error: {e}")
 return None

# --- Test the Function ---

# Test 1: Valid Request
API_KEY = "YOUR_API_KEY_HERE"
url = "http://api.openweathermap.org/data/2.5/weather"
params = {"q": "Paris", "units": "metric", "appid": API_KEY}

data = fetch_data(url, params=params)

if data:
 print(f"Paris Temperature: {data['main']['temp']}°C")

# Test 2: Invalid City (Should return 404)
print("\n--- Testing Invalid City ---")
params_invalid = {"q": "NonExistentCity123", "units": "metric", "appid": API_KEY}
data_invalid = fetch_data(url, params=params_invalid)

Expected Output (for Test 2):

📡 Requesting: http://api.openweathermap.org/data/2.5/weather
❌ HTTP Error occurred: 404 Client Error: Not Found for url:.
 Response: {"cod":"404","message":"city not found"}

Verify: 1. The function returns None on error instead of crashing. 2. The error message is clear and helpful.


5. POST Requests: Sending Data

So far, we've only used GET requests (retrieving data). Now, let's send data to a server using POST.

We will use a free echo API: httpbin.org. This service simply returns whatever you send to it, which is perfect for testing.

Step 1: Send a JSON Payload

# Save as: post_request_example.py

import requests
import json

url = "https://httpbin.org/post"

# The data we want to send
payload = {
 "username": "john_doe",
 "email": "john@example.com",
 "action": "login"
}

# Headers to indicate we are sending JSON
headers = {
 "Content-Type": "application/json"
}

print("📤 Sending POST request.")
response = requests.post(url, json=payload, headers=headers)

if response.status_code == 200:
 result = response.json()

 # httpbin returns the JSON we sent in the 'json' field
 echoed_data = result['json']

 print("✅ Server received the following data:")
 print(json.dumps(echoed_data, indent=2))

 # Verify the data matches what we sent
 if echoed_data == payload:
 print("✅ Data integrity check passed.")
 else:
 print("❌ Data mismatch!")
else:
 print(f"❌ Error: {response.status_code}")

Expected Output:

📤 Sending POST request.
✅ Server received the following data:
{
 "username": "john_doe",
 "email": "john@example.com",
 "action": "login"
}
✅ Data integrity check passed.

Verify: 1. The server echoed back the exact JSON we sent. 2. The Content-Type header was correctly set to application/json.

💡 Tip: When sending JSON, always use the json= parameter in requests.post(). This automatically sets the Content-Type header and serializes the Python dictionary to a JSON string. Do not use data=json.dumps(payload) unless you have a specific reason.


6. Building a Reusable API Client Class

In real projects, you will interact with multiple endpoints of the same API. Instead of repeating URL construction and header setup, create a class.

The WeatherClient Class

# Save as: weather_client.py

import requests
from typing import Optional, Dict, Any

class WeatherClient:
 """
 A client class for interacting with the OpenWeatherMap API.
 """

 BASE_URL = "http://api.openweathermap.org/data/2.5"

 def __init__(self, api_key: str):
 """
 Initialize the client with an API key.

 Args:
 api_key (str): Your OpenWeatherMap API key.
 """
 if not api_key:
 raise ValueError("API key cannot be empty.")
 self.api_key = api_key
 self.session = requests.Session() # Reuse connection for efficiency

 def _get(self, endpoint: str, params: Dict[str, Any]) -> Optional[Dict]:
 """
 Internal method to handle GET requests.
 """
 url = f"{self.BASE_URL}/{endpoint}"
 params["appid"] = self.api_key # Automatically add API key

 try:
 response = self.session.get(url, params=params, timeout=10)
 response.raise_for_status()
 return response.json()
 except requests.RequestException as e:
 print(f"❌ API Error: {e}")
 return None

 def get_current_weather(self, city: str, units: str = "metric") -> Optional[Dict]:
 """
 Get current weather for a city.

 Args:
 city (str): Name of the city.
 units (str): 'metric' (Celsius) or 'imperial' (Fahrenheit).

 Returns:
 Dict: Weather data or None if error.
 """
 params = {
 "q": city,
 "units": units
 }
 return self._get("weather", params)

 def get_forecast(self, city: str, units: str = "metric") -> Optional[Dict]:
 """
 Get 5-day forecast for a city.

 Args:
 city (str): Name of the city.
 units (str): 'metric' or 'imperial'.

 Returns:
 Dict: Forecast data or None if error.
 """
 params = {
 "q": city,
 "units": units
 }
 return self._get("forecast", params)

# --- Usage Example ---

if __name__ == "__main__":
 # Initialize the client
 client = WeatherClient(api_key="YOUR_API_KEY_HERE")

 # Get current weather
 print("🌍 Current Weather in Tokyo:")
 weather = client.get_current_weather("Tokyo")
 if weather:
 print(f" Temp: {weather['main']['temp']}°C")
 print(f" Condition: {weather['weather'][0]['description']}")

 # Get forecast
 print("\n📅 5-Day Forecast for New York:")
 forecast = client.get_forecast("New York")
 if forecast:
 for day in forecast['list'][:5]: # Show first 5 entries
 date = day['dt_txt']
 temp = day['main']['temp']
 desc = day['weather'][0]['description']
 print(f" {date}: {temp}°C, {desc}")

Expected Output:

🌍 Current Weather in Tokyo:
 Temp: 18.5°C
 Condition: clear sky

📅 5-Day Forecast for New York:
 2023-10-27 03:00:00: 12.3°C, clear sky
 2023-10-27 06:00:00: 11.8°C, clear sky
 2023-10-27 09:00:00: 14.2°C, few clouds
 2023-10-27 12:00:00: 16.5°C, scattered clouds
 2023-10-27 15:00:00: 15.9°C, broken clouds

Verify: 1. The class handles the API key internally. 2. You can call multiple methods (get_current_weather, get_forecast) without repeating setup code. 3. The session is reused, which is faster than creating a new connection for each request.


📁 Project Files

Here is the complete structure of the code we built in this chapter:

project_api_integration/
├── setup_cell.py # Environment setup
├── weather_fetcher.py # Basic GET request example
├── robust_api_call.py # Error handling example
├── post_request_example.py # POST request example
└── weather_client.py # Reusable API client class

🚀 What You Just Built

  1. A Robust API Client: You now have a reusable class that can fetch weather data from any city.
  2. Error Handling: Your code no longer crashes when the API fails. It logs the error and returns None.
  3. POST Requests: You learned how to send data to a server using JSON payloads.
  4. Session Management: You used requests.Session() to improve performance by reusing TCP connections.

Common Mistakes

  1. Hardcoding API Keys:
  2. API_KEY = "abc123"
  3. ✅ Use environment variables: import os; API_KEY = os.environ.get("API_KEY")
  4. Ignoring Status Codes:
  5. ❌ Assuming response.json() will always work.
  6. ✅ Always check response.status_code or use response.raise_for_status().
  7. Not Setting Timeouts:
  8. requests.get(url) can hang forever if the server is slow.
  9. ✅ Always set timeout=10 (or similar).
  10. Using data= for JSON:
  11. requests.post(url, data=json.dumps(payload))
  12. requests.post(url, json=payload)

Troubleshooting

Problem Possible Cause Solution
401 Unauthorized Invalid or missing API key Check your API key. Ensure it's not expired.
404 Not Found Wrong endpoint or parameters Check the URL. Ensure all required parameters are present.
429 Too Many Requests Rate limit exceeded Wait for the rate limit to reset. Implement retry logic with exponential backoff.
ConnectionError No internet or server down Check your internet connection. Verify the server is up.
Timeout Server is slow Increase the timeout value. Optimize your request (e.g., reduce data size).

Key Takeaways

  1. APIs are Contracts: Always read the documentation. The URL, parameters, and response structure are defined by the provider.
  2. Always Handle Errors: APIs fail. Your code must be resilient.
  3. Use Sessions: For multiple requests to the same server, use requests.Session() for better performance.
  4. Encapsulate Logic: Create a class for each API you interact with. This makes your code cleaner and easier to maintain.
  5. JSON is the Standard: Most APIs return JSON. Learn to navigate nested JSON structures using Python dictionaries.

🧪 Try It Yourself

  1. Add a New Method: Add a method get_air_quality(city) to the WeatherClient class. The endpoint is air_pollution.
  2. Implement Retry Logic: Modify the _get method to retry the request 3 times if it fails with a 500 error.
  3. Use Environment Variables: Modify the code to read the API key from an environment variable instead of hardcoding it.
  4. Build a CLI Tool: Create a command-line interface where the user can type a city name and get the weather. Use the argparse library.

Hint for #4:

import argparse

parser = argparse.ArgumentParser(description="Get weather for a city.")
parser.add_argument("city", type=str, help="Name of the city")
args = parser.parse_args()

Next Chapter

In the next chapter, we will dive deeper into Authentication. We will learn how to handle OAuth 2.0, JWT tokens, and secure API keys in production environments. We will also build a User Authentication System using Flask and JWT.

Stay tuned!

Chapter 4: Setting Up Your Professional Development Environment

Why This Matters

In the previous section, we briefly touched upon Jupyter Notebooks. However, for building production-grade AI agents, you need a robust, reproducible, and isolated development environment.

Why not just use the system Python? 1. Dependency Conflicts: One project might need pandas==1.5, while another needs pandas==2.0. Installing both globally breaks everything. 2. Reproducibility: If you share your code with a teammate, they need to be able to run it exactly as you did. 3. Cleanliness: You don't want 50 unused libraries cluttering your system.

Virtual Environments (Venv) solve this by creating an isolated Python environment for each project.

What is a virtual environment (venv)? It is a separate, isolated Python world for your project. Without it, packages from different projects can conflict. With a venv, each project gets its own clean set of packages. Think of it like a separate browser profile for each website.

What You'll Build

In this section, you will: 1. Create a project directory structure. 2. Initialize a Python Virtual Environment. 3. Create a requirements.txt file to manage dependencies. 4. Install the necessary libraries (openai, pandas, jupyter). 5. Launch Jupyter Notebook within VS Code using the correct kernel.


Step 1: Create Your Project Structure

First, let's set up a clean folder structure. This is best practice for any software project.

Open your terminal (Command Prompt on Windows, Terminal on Mac/Linux) and run the following commands:

# Create the main project folder
mkdir ai-agent-fundamentals
cd ai-agent-fundamentals

# Create subdirectories for organization
mkdir src
mkdir notebooks
mkdir data
mkdir.venv

Verify: Run ls (Mac/Linux) or dir (Windows) to see the folders created.


Step 2: Initialize the Virtual Environment

We will use Python's built-in venv module. No extra installation is needed.

Windows:

python -m venv.venv

Mac/Linux:

python3 -m venv.venv

⚠️ Warning: If you see an error like "No module named venv" on Mac, install it with brew install python-tk or ensure you have the latest Python installed via Homebrew.

Verify: You should see a new folder named .venv in your project directory. It contains bin (Mac/Linux) or Scripts (Windows) folders.


Step 3: Activate the Virtual Environment

Before installing any packages, you must activate the environment. This changes your shell prompt to show (.venv).

Windows: ```bash.venv\Scripts\activate


**Mac/Linux:**
```bash
source.venv/bin/activate

Verify: Your terminal prompt should now look like this:

(.venv) user@machine:~/ai-agent-fundamentals$

If you don't see (.venv), you are not in the virtual environment. Do not proceed until you are.


Step 4: Create and Install Dependencies

We will create a requirements.txt file. This file lists all the Python packages our project needs. This is the "recipe" for your environment.

Create a file named requirements.txt in the root of your project (ai-agent-fundamentals/).

File: requirements.txt

openai>=1.0.0
pandas>=2.0.0
jupyter>=1.0.0
ipykernel>=6.0.0

💡 Tip: The >= symbol means "install this version or newer." This ensures you get bug fixes while maintaining compatibility.

Now, install these packages into your virtual environment:

pip install -r requirements.txt

Verify: Wait for the installation to complete. You should see "Successfully installed." at the end.

To confirm the packages are installed, run:

pip list

You should see openai, pandas, jupyter, and ipykernel in the list.


Step 5: Set Up Jupyter Kernel

Jupyter needs to know about your virtual environment's Python interpreter. We do this by registering the kernel.

python -m ipykernel install --user --name myenv --display-name "Python (myenv)"

Verify: You should see a message like:

Installed kernelspec myenv in /Users/yourname/Library/Jupyter/kernels/myenv

(Or similar path depending on your OS)


Step 6: Open Project in VS Code

  1. Open Visual Studio Code.
  2. Go to File > Open Folder.
  3. Select your ai-agent-fundamentals folder.
  4. VS Code should automatically detect the .venv folder. If not, press Ctrl+Shift+P (or Cmd+Shift+P on Mac) and type: Python: Select Interpreter
  5. Choose the interpreter that points to your .venv folder: .venv\Scripts\python.exe (Windows).venv/bin/python (Mac/Linux)

Verify: Look at the bottom right corner of VS Code. It should say Python 3.x.x ('.venv').


Step 7: Create and Run Your First Notebook

  1. In VS Code, go to the notebooks folder.
  2. Right-click > New File.
  3. Name it 01_variables.ipynb.
  4. Open the file. You will see a Jupyter Notebook interface.

Important: Look at the top right of the notebook. It says "Select Kernel". Click on it and choose Python (myenv). This ensures the notebook uses your virtual environment.

Now, type the following code into the first cell:

File: notebooks/01_variables.ipynb

# Save as: notebooks/01_variables.ipynb

# Define variables
user_question = "What is an AI agent?"
model_name = "GPT-4"
city = "Bangalore"
age = 25

# Print them
print(f"User Question: {user_question}")
print(f"Model Name: {model_name}")
print(f"City: {city}")
print(f"Age: {age}")

Press Shift + Enter to run the cell.

Expected Output:

User Question: What is an AI agent?
Model Name: GPT-4
City: Bangalore
Age: 25

Verify: If you see the output above, your environment is perfectly set up. If you get a ModuleNotFoundError, you selected the wrong kernel. Go back to Step 7 and ensure you selected Python (myenv).


📁 Project Files

Here is the complete structure of your project so far:

ai-agent-fundamentals/
├──.venv/ # Virtual environment (do not edit)
├── data/ # For storing datasets
├── notebooks/
│ └── 01_variables.ipynb # Our first notebook
├── src/ # For Python scripts
├── requirements.txt # List of dependencies
└── README.md # (Optional) Project description

🚀 What You Just Built

You have successfully: 1. Created an isolated Python environment. 2. Managed dependencies using requirements.txt. 3. Set up Jupyter Notebook with the correct kernel in VS Code. 4. Run your first Python code in a professional setup.

This setup is reproducible. If you delete your .venv folder and run pip install -r requirements.txt, you will get the exact same environment again.


Common Mistakes

1. Forgetting to Activate the Virtual Environment

Symptom: pip install installs packages globally, not in your project. Fix: Always check your terminal prompt for (.venv). If it's missing, run the activation command again.

2. Using the Wrong Kernel in Jupyter

Symptom: ModuleNotFoundError: No module named 'openai' even though you installed it. Fix: In the notebook, click "Select Kernel" and choose the one that says Python (myenv) or points to your .venv path.

3. Mixing python and python3

Symptom: Confusing errors on Mac/Linux. Fix: Be consistent. If you created the venv with python3, use python3 for activation and kernel installation.


Troubleshooting

"No module named venv"

Cause: Your Python installation is incomplete. Fix: - Windows: Reinstall Python from python.org and check "Add to PATH" and "Install for all users". - Mac: brew install python - Linux: sudo apt install python3-venv

"Permission denied" when activating venv on Mac

Cause: Security restrictions. Fix: Run chmod +x.venv/bin/activate and then try activating again.

Jupyter Kernel Not Showing Up

Cause: Kernel not registered. Fix: Run python -m ipykernel install --user --name myenv --display-name "Python (myenv)" again while the venv is active.


Key Takeaways

  1. Always use a virtual environment. It keeps your projects isolated and clean.
  2. requirements.txt is your friend. It documents your dependencies and makes setup easy for others.
  3. VS Code + Jupyter is a powerful combo. Use the "Select Kernel" feature to ensure your notebook uses the correct Python interpreter.
  4. Shift + Enter runs the current cell and moves to the next. Use it to iterate quickly.

🧪 Try It Yourself

  1. Create a new notebook named 02_data_types.ipynb.
  2. Define variables for different data types: python name = "Priya" # String age = 25 # Integer height = 5.6 # Float is_student = True # Boolean skills = ["Python", "AI", "Data Science"] # List
  3. Print each variable and its type using type(): python print(f"{name} is a {type(name)}") print(f"{age} is a {type(age)}") #. and so on
  4. Try to change the type of age to a string and see what happens when you try to add it to another number.

Expected Output:

Priya is a <class'str'>
25 is a <class 'int'>
5.6 is a <class 'float'>
True is a <class 'bool'>
['Python', 'AI', 'Data Science'] is a <class 'list'>

In the next section, we will dive deeper into Python Data Types and how they relate to building AI agents.

Chapter 4: Data Types, Strings, and Logic

Why This Matters

In the previous section, we established that Python is a language of types. You cannot mix apples and oranges (strings and integers) without explicit conversion. Now, we move to the most critical data type for LLM development: Strings.

An LLM is fundamentally a string-in, string-out machine. * Input: A prompt (String) * Output: A response (String)

If you cannot manipulate strings—cleaning them, splitting them, formatting them—you cannot build a robust AI agent. You will be stuck with messy data, broken prompts, and unparseable outputs.

In this section, we will master: 1. Comments: How to document code for humans. 2. Variables & Assignment: Creating containers for data. 3. Core Data Types: str, int, float, bool. 4. String Methods: The toolkit for cleaning and shaping text. 5. Conditional Logic: Making decisions based on data.


What You'll Build

By the end of this section, you will have a Prompt Cleaner script. This script will: 1. Take a messy, raw user input string. 2. Clean it (remove extra spaces, fix casing). 3. Check if the input meets specific criteria (e.g., length, keywords). 4. Output a standardized, clean prompt ready for an LLM.

This is the foundation of every production-grade LLM application.


1. Comments: Notes for Humans

Python ignores everything after a # symbol on a line. This is your tool for explaining why you wrote code, not just what the code does.

The Rule

  • # starts a comment.
  • Everything after # on that line is ignored by the Python interpreter.
  • Comments do not affect execution speed or output.

Example

# Save as: comments_demo.py

# This is a single-line comment. Python ignores this.
print("Hello, World!") # This is an inline comment.

# If I comment out code, it does not run.
# print("This will not appear in the output.")

print("This line runs.")

Expected Output:

Hello, World!
This line runs.

Verify: Run the script. Notice that the line print("This will not appear.") did not produce output because it was commented out.

💡 Tip: Use comments to explain complex logic. For example:

# We multiply by 0.7 because the model temperature should be low for factual queries
temperature = 0.7

2. Variables and Assignment

A variable is a named container for a value. You create a variable using the = (assignment) operator.

Syntax

variable_name = value

Rules for Variable Names

  1. Must start with a letter or underscore (_).
  2. Can contain letters, numbers, and underscores.
  3. Case-sensitive (Temperature and temperature are different).
  4. Cannot use Python keywords (if, else, for, etc.).

Example

# Save as: variables_demo.py

# Creating variables
temperature = 0.7 # Float
model_name = "gpt-4" # String
max_tokens = 100 # Integer
is_active = True # Boolean

# Printing variables
print(f"Model: {model_name}")
print(f"Temperature: {temperature}")
print(f"Max Tokens: {max_tokens}")
print(f"Active: {is_active}")

Expected Output:

Model: gpt-4
Temperature: 0.7
Max Tokens: 100
Active: True

Verify: Change temperature to 1.5 and re-run. The output should update to 1.5.


3. Core Data Types

Python has four primary data types you will use daily:

Type Example Description
str "Hello" Text data. Enclosed in single or double quotes.
int 42 Whole numbers. No decimal point.
float 3.14 Decimal numbers.
bool True / False Logical values. Only two possible values.

Checking Types

Use the type() function to inspect a variable's type.

# Save as: types_demo.py

a = "Hello"
b = 42
c = 3.14
d = True

print(type(a)) # <class'str'>
print(type(b)) # <class 'int'>
print(type(c)) # <class 'float'>
print(type(d)) # <class 'bool'>

Expected Output:

<class'str'>
<class 'int'>
<class 'float'>
<class 'bool'>

⚠️ Warning: In Python, bool is a subclass of int. True equals 1, and False equals 0. This can lead to subtle bugs if you are not careful.


4. String Methods: The LLM Developer's Toolkit

Strings are immutable (cannot be changed after creation). However, Python provides methods that return new strings.

Key Methods

Method Description Example
.lower() Converts all characters to lowercase. "HELLO".lower()"hello"
.upper() Converts all characters to uppercase. "hello".upper()"HELLO"
.strip() Removes leading and trailing whitespace. " hello ".strip()"hello"
.replace(old, new) Replaces all occurrences of old with new. "a-b-c".replace("-", "_")"a_b_c"
.split(separator) Splits a string into a list based on a separator. "a,b,c".split(",")["a", "b", "c"]
.join(list) Joins a list of strings with a separator. "-".join(["a", "b", "c"])"a-b-c"
.startswith(prefix) Returns True if string starts with prefix. "hello".startswith("he")True
.endswith(suffix) Returns True if string ends with suffix. "hello".endswith("lo")True
len(string) Returns the length of the string. len("hello")5

Example: Cleaning a Messy Prompt

# Save as: string_methods_demo.py

# Simulate a messy user input
raw_input = " Explain AI Agent in Simple English "

# Step 1: Remove extra spaces
cleaned = raw_input.strip()
print(f"Cleaned: '{cleaned}'")

# Step 2: Convert to lowercase for consistency
lowercase = cleaned.lower()
print(f"Lowercase: '{lowercase}'")

# Step 3: Replace specific terms
replaced = lowercase.replace("agent", "assistant")
print(f"Replaced: '{replaced}'")

# Step 4: Split into words
words = replaced.split()
print(f"Words: {words}")
print(f"Word Count: {len(words)}")

Expected Output:

Cleaned: 'Explain AI Agent in Simple English'
Lowercase: 'explain ai agent in simple english'
Replaced: 'explain ai assistant in simple english'
Words: ['explain', 'ai', 'assistant', 'in','simple', 'english']
Word Count: 6

Verify: Change raw_input to " Hello, World! ". Notice how .strip() removes the outer spaces but not the inner ones. To remove inner spaces, you would need .split() and .join().


5. Conditional Logic: Making Decisions

LLM applications often need to make decisions: * If the user input is empty, ask for clarification. * If the input contains a question mark, treat it as a query. * If the input is too long, truncate it.

Syntax

if condition:
 # Code to run if condition is True
elif other_condition:
 # Code to run if other_condition is True
else:
 # Code to run if none of the above are True

Example: Prompt Validator

# Save as: prompt_validator.py

def validate_prompt(prompt: str) -> str:
 """
 Validates and cleans a user prompt.
 Returns a standardized prompt string.
 """
 # Step 1: Check if prompt is empty
 if not prompt.strip():
 return "Error: Prompt cannot be empty."

 # Step 2: Clean the prompt
 cleaned = prompt.strip().lower()

 # Step 3: Check length
 if len(cleaned) > 100:
 return "Error: Prompt is too long. Max 100 characters."

 # Step 4: Check for question mark
 if "?" in cleaned:
 return f"Query: {cleaned}"
 else:
 return f"Statement: {cleaned}"

# Test cases
print(validate_prompt(" What is AI? "))
print(validate_prompt("AI is cool"))
print(validate_prompt(" "))
print(validate_prompt("This is a very long prompt that exceeds the limit of one hundred characters for our simple validator"))

Expected Output:

Query: what is ai?
Statement: ai is cool
Error: Prompt cannot be empty.
Error: Prompt is too long. Max 100 characters.

Verify: Add a new test case: print(validate_prompt(" Hello ")). It should return Statement: hello.


6. Combining It All: The Prompt Cleaner

Now, let's build a complete, reusable function that cleans and formats prompts for an LLM.

# Save as: prompt_cleaner.py

def clean_prompt(raw_input: str, max_length: int = 500) -> dict:
 """
 Cleans a raw user input and returns a structured dictionary.

 Args:
 raw_input (str): The raw text from the user.
 max_length (int): Maximum allowed length for the prompt.

 Returns:
 dict: A dictionary with keys 'cleaned', 'valid', 'error'.
 """
 # Initialize result
 result = {
 "cleaned": "",
 "valid": False,
 "error": None
 }

 # Step 1: Strip whitespace
 cleaned = raw_input.strip()

 # Step 2: Check if empty
 if not cleaned:
 result["error"] = "Prompt is empty."
 return result

 # Step 3: Check length
 if len(cleaned) > max_length:
 result["error"] = f"Prompt exceeds max length of {max_length}."
 return result

 # Step 4: Normalize casing (optional, depends on use case)
 # For LLMs, we often keep original casing but remove extra spaces
 cleaned = " ".join(cleaned.split()) # Removes multiple spaces

 # Step 5: Mark as valid
 result["cleaned"] = cleaned
 result["valid"] = True

 return result

# --- Test the Function ---

# Test 1: Valid prompt
test1 = " Explain quantum computing "
result1 = clean_prompt(test1)
print(f"Test 1: {result1}")

# Test 2: Empty prompt
test2 = " "
result2 = clean_prompt(test2)
print(f"Test 2: {result2}")

# Test 3: Too long
test3 = "a" * 600
result3 = clean_prompt(test3)
print(f"Test 3: {result3}")

# Test 4: Normal prompt
test4 = "What is the capital of France?"
result4 = clean_prompt(test4)
print(f"Test 4: {result4}")

Expected Output:

Test 1: {'cleaned': 'Explain quantum computing', 'valid': True, 'error': None}
Test 2: {'cleaned': '', 'valid': False, 'error': 'Prompt is empty.'}
Test 3: {'cleaned': '', 'valid': False, 'error': 'Prompt exceeds max length of 500.'}
Test 4: {'cleaned': 'What is the capital of France?', 'valid': True, 'error': None}

Verify: Change max_length to 10 in the function call for Test 4. It should now return an error.


📁 Project Files

Create a folder named chapter4_data_types and add the following files:

  1. comments_demo.py
  2. variables_demo.py
  3. types_demo.py
  4. string_methods_demo.py
  5. prompt_validator.py
  6. prompt_cleaner.py

🚀 What You Just Built

You have built the foundation of data handling for LLM applications:

  1. Comments: You can now document your code for future maintainers.
  2. Variables: You can store and manipulate data using Python's core types.
  3. String Methods: You can clean, format, and split text—essential for preparing prompts.
  4. Conditional Logic: You can validate user input and make decisions based on data.
  5. Prompt Cleaner: A reusable function that ensures all prompts sent to an LLM are clean, valid, and within limits.

This is the first step in building a robust AI agent. Without clean data, your LLM will produce inconsistent or incorrect results.


Common Mistakes

  1. Forgetting Indentation: python if True: print("Hello") # ❌ IndentationError Fix: Always indent the code block under if, elif, else, for, while, def, class.

  2. Using = instead of == in Conditions: python if x = 5: # ❌ SyntaxError Fix: Use == for comparison, = for assignment.

  3. Assuming Strings are Mutable: python s = "hello" s[0] = "H" # ❌ TypeError:'str' object does not support item assignment Fix: Create a new string: s = "H" + s[1:].

  4. Ignoring Whitespace: python if user_input == "hello": # ❌ Fails if user_input is " hello " Fix: Always use .strip() before comparing strings.


Troubleshooting

Problem Cause Solution
IndentationError: expected an indented block Missing indentation after if, def, etc. Add 4 spaces or 1 tab at the start of the next line.
TypeError: can only concatenate str (not "int") to str Trying to add a number to a string. Convert the number to a string: str(5) or use f-strings: f"Value: {5}".
AttributeError: 'NoneType' object has no attribute'strip' Trying to call .strip() on None. Check if the variable is None before calling methods.
SyntaxError: invalid syntax Missing colon : after if, def, etc. Add a colon at the end of the line.

Key Takeaways

  1. Comments are for humans. Use them to explain why, not what.
  2. Variables are containers. Use descriptive names (user_prompt, not x).
  3. Strings are the primary data type for LLMs. Master .strip(), .lower(), .split(), and .join().
  4. Conditional Logic (if/elif/else) allows you to validate and process data.
  5. Always clean user input before sending it to an LLM. This prevents errors and improves consistency.

🧪 Try It Yourself

  1. Modify prompt_cleaner.py:
  2. Add a check to ensure the prompt contains at least one letter.
  3. If it doesn't, return an error: "Prompt must contain at least one letter."

  4. Create a New Function:

  5. Write a function count_words(prompt: str) -> int that returns the number of words in the prompt.
  6. Use .split() and len().

  7. Challenge:

  8. Modify clean_prompt to remove all punctuation marks (e.g., !, ?, ,, .) from the prompt.
  9. Hint: Use a loop and str.replace() or a list of punctuation marks.

  10. Debugging Exercise:

  11. Intentionally introduce a bug in prompt_validator.py (e.g., change if not prompt.strip(): to if prompt.strip():).
  12. Run the code and observe the error.
  13. Fix the bug and verify the output is correct.

Next Section

In the next section, we will move from data types to functions and modules. You will learn how to organize your code into reusable functions and how to import external libraries, including the openai library, to start making actual API calls to LLMs.

Chapter 7: Mastering Data Structures & String Manipulation

Why This Matters

In the previous sections, we handled single pieces of data. But real-world AI applications—like LangGraph agents—process collections of data. You might have a list of user queries, a list of tool names, or a list of conversation history items.

If you cannot manipulate lists and format strings dynamically, your code will be brittle and unreadable. This section bridges the gap between "basic variables" and "production-ready data handling." We will focus on: 1. F-Strings: The modern, Pythonic way to format strings. 2. Lists: Storing multiple items in a single variable. 3. Iteration: Processing each item in a list using for loops.

What You'll Build

By the end of this section, you will have a script that: 1. Dynamically formats a user profile using F-strings. 2. Stores a list of skills. 3. Iterates through the list to normalize data (converting to lowercase). 4. Demonstrates common list methods (append, remove, index).


1. F-Strings: Dynamic String Formatting

The Problem

Imagine you have a user named Ishant and a topic LangGraph. You want to print: Hello Ishant, today we are learning LangGraph.

The Old Way (Bad):

print("Hello " + name + ", today we are learning " + topic + ".")

This is hard to read, prone to type errors (if name is an integer, it crashes), and messy.

The Modern Way (F-Strings): F-strings (Formatted String Literals) allow you to embed variable values directly inside string literals by prefixing the string with f and using curly braces {} as placeholders.

Code Example

# Save as: fstring_demo.py

# Define variables
user_name = "Ishant"
topic = "LangGraph"
skill_level = 85

# 1. Basic F-String
message = f"Hello {user_name}, today we are learning {topic}."
print(message)

# 2. F-String with Expressions
# You can do math or logic inside the braces
percentage = f"Your skill level is {skill_level}%."
print(percentage)

# 3. F-String with Method Calls
# You can call methods on the variable inside the braces
upper_name = f"Welcome, {user_name.upper()}!"
print(upper_name)

# 4. What happens if you forget the 'f'?
bad_message = "Hello {user_name}, today we are learning {topic}."
print(bad_message) # This will print the literal text with braces

Expected Output

Hello Ishant, today we are learning LangGraph.
Your skill level is 85%.
Welcome, ISHANT!
Hello {user_name}, today we are learning {topic}.

Verify: Run the script. Notice that the last line prints the literal text {user_name} because the f prefix was missing. This is a common mistake.

💡 Tip: F-strings are faster than % formatting or .format() and are the standard in modern Python (3.6+). Always use them unless you have a specific reason not to.


2. Lists: Storing Multiple Items

The Problem

You have a list of skills: Excel, Python, Communication, Marketing. You want to store them in one variable and process them later.

What is a List?

A list is an ordered, mutable collection of items. It is defined using square brackets []. Items are separated by commas.

Code Example

# Save as: list_basics.py

# 1. Creating a List
skills = ["Excel", "Python", "Communication", "Marketing"]

# 2. Printing the entire list
print("All Skills:", skills)

# 3. Accessing a specific item (Indexing)
# Python uses 0-based indexing
first_skill = skills[0]
last_skill = skills[-1] # Negative index counts from the end
print(f"First Skill: {first_skill}")
print(f"Last Skill: {last_skill}")

# 4. Slicing (Getting a subset)
# Get items from index 1 to 3 (exclusive)
subset = skills[1:3]
print("Subset (Python, Communication):", subset)

Expected Output

All Skills: ['Excel', 'Python', 'Communication', 'Marketing']
First Skill: Excel
Last Skill: Marketing
Subset (Python, Communication): ['Python', 'Communication']

Verify: Check that skills[0] returns "Excel" and skills[-1] returns "Marketing".


3. Iterating Over Lists with for Loops

The Problem

You want to process every item in the list. For example, you want to convert all skill names to lowercase for database storage.

The Solution

Use a for loop to iterate over the list. The loop variable (e.g., skill) takes the value of each item in the list, one by one.

Code Example

# Save as: list_iteration.py

skills = ["Excel", "Python", "Communication", "Marketing"]

print("Processing Skills.")
for skill in skills:
 # Convert to lowercase
 normalized_skill = skill.lower()
 print(f"Original: {skill} -> Normalized: {normalized_skill}")

Expected Output

Processing Skills.
Original: Excel -> Normalized: excel
Original: Python -> Normalized: python
Original: Communication -> Normalized: communication
Original: Marketing -> Normalized: marketing

Verify: Notice that the loop runs 4 times, once for each item in the list. The variable skill is reassigned in each iteration.


4. Common List Methods

Lists are mutable, meaning you can change them after creation. Here are the most common methods you will use in AI agent development.

Code Example

# Save as: list_methods.py

tools = ["search", "calculator", "weather"]

# 1. Append: Add an item to the end
tools.append("email")
print("After append:", tools)

# 2. Remove: Remove the first occurrence of an item
tools.remove("calculator")
print("After remove:", tools)

# 3. Index: Find the position of an item
position = tools.index("weather")
print(f"Position of 'weather': {position}")

# 4. Length: Get the number of items
count = len(tools)
print(f"Total tools: {count}")

# 5. Check if item exists
if "search" in tools:
 print("'search' is available.")
else:
 print("'search' is NOT available.")

Expected Output

After append: ['search', 'calculator', 'weather', 'email']
After remove: ['search', 'weather', 'email']
Position of 'weather': 1
Total tools: 3'search' is available.

Verify: Ensure that remove() does not return the item; it modifies the list in place. Use index() to find positions, but be aware it raises a ValueError if the item is not found.


📁 Project Files

Here is the complete, runnable code combining all concepts. Save this as data_structures_master.py.

# Save as: data_structures_master.py

# ==========================================
# 1. F-STRING DEMO
# ==========================================
print("--- F-String Demo ---")
user_name = "Ishant"
topic = "LangGraph"
skill_level = 85

message = f"Hello {user_name}, today we are learning {topic}."
print(message)

percentage = f"Your skill level is {skill_level}%."
print(percentage)

upper_name = f"Welcome, {user_name.upper()}!"
print(upper_name)

# ==========================================
# 2. LIST BASICS
# ==========================================
print("\n--- List Basics ---")
skills = ["Excel", "Python", "Communication", "Marketing"]

print("All Skills:", skills)
print(f"First Skill: {skills[0]}")
print(f"Last Skill: {skills[-1]}")
print("Subset (Python, Communication):", skills[1:3])

# ==========================================
# 3. ITERATION
# ==========================================
print("\n--- Iteration ---")
print("Processing Skills.")
for skill in skills:
 normalized_skill = skill.lower()
 print(f"Original: {skill} -> Normalized: {normalized_skill}")

# ==========================================
# 4. LIST METHODS
# ==========================================
print("\n--- List Methods ---")
tools = ["search", "calculator", "weather"]

tools.append("email")
print("After append:", tools)

tools.remove("calculator")
print("After remove:", tools)

position = tools.index("weather")
print(f"Position of 'weather': {position}")

count = len(tools)
print(f"Total tools: {count}")

if "search" in tools:
 print("'search' is available.")
else:
 print("'search' is NOT available.")

Expected Output

--- F-String Demo ---
Hello Ishant, today we are learning LangGraph.
Your skill level is 85%.
Welcome, ISHANT!

--- List Basics ---
All Skills: ['Excel', 'Python', 'Communication', 'Marketing']
First Skill: Excel
Last Skill: Marketing
Subset (Python, Communication): ['Python', 'Communication']

--- Iteration ---
Processing Skills.
Original: Excel -> Normalized: excel
Original: Python -> Normalized: python
Original: Communication -> Normalized: communication
Original: Marketing -> Normalized: marketing

--- List Methods ---
After append: ['search', 'calculator', 'weather', 'email']
After remove: ['search', 'weather', 'email']
Position of 'weather': 1
Total tools: 3'search' is available.

Verify: Run the file. The output should match exactly.


🚀 What You Just Built

You have now mastered the two most fundamental data structures in Python: 1. Strings with F-Strings: You can dynamically insert variables, expressions, and method calls into text. This is essential for logging, user interaction, and prompt engineering in AI agents. 2. Lists: You can store multiple items, access them by index, slice them, iterate over them, and modify them using methods like append and remove.

This foundation is critical for LangGraph, where you often pass lists of messages, tool names, or state data between nodes.


Common Mistakes

  1. Forgetting the f prefix: ```python # Wrong print("Hello {name}") # Prints literal {name}

# Correct print(f"Hello {name}") # Prints Hello Ishant ```

  1. Modifying a list while iterating: ```python # Dangerous! Can skip items or cause errors for item in my_list: if item == "bad": my_list.remove(item)

# Safe: Create a new list new_list = [item for item in my_list if item != "bad"] ```

  1. Using == instead of in for list membership: ```python # Wrong if "Python" == skills: # Compares string to list

# Correct if "Python" in skills: # Checks if item exists in list ```

  1. Index Error: python skills = ["A", "B"] print(skills[2]) # IndexError: list index out of range

Troubleshooting

Symptom Cause Solution
SyntaxError: invalid syntax Missing f before string or mismatched braces {} Ensure the string starts with f and all {} are closed.
NameError: name 'x' is not defined Variable used in F-string is not defined Define the variable before using it in the F-string.
IndexError: list index out of range Trying to access an index that doesn't exist Check the length of the list with len().
ValueError: 'x' is not in list Using .index() or .remove() on an item that doesn't exist Use if item in list: before calling these methods.

Key Takeaways

  1. F-Strings are the standard for string formatting. Use f"." with {variable} placeholders.
  2. Lists are ordered, mutable collections. Use [] to create them.
  3. Indexing is 0-based. list[0] is the first item, list[-1] is the last.
  4. Iteration with for item in list: is the most common way to process list items.
  5. List Methods: append() adds to the end, remove() deletes the first occurrence, index() finds the position, len() gets the count.

🧪 Try It Yourself

  1. Create a list of 5 programming languages.
  2. Use an F-string to print a sentence like: "I know {language}." for each language in the list.
  3. Add a new language to the list using append().
  4. Remove the first language using remove().
  5. Print the length of the list.

Expected Result:

languages = ["Python", "Java", "C++", "Go", "Rust"]
for lang in languages:
 print(f"I know {lang}.")

languages.append("Kotlin")
languages.remove("Python")
print(f"Total languages: {len(languages)}")

Verify: The output should show 5 lines of "I know.", then "Total languages: 5".

Chapter 8: Structuring Data with Dictionaries and AI Message Formats

Why This Matters

In the previous sections, we mastered lists and strings. However, lists are ordered and unlabeled. If you have a list like ["Priya", "Marketing", "5 years"], you have to remember that index 0 is the name and index 1 is the role. This is fragile. If you add a new field, everything shifts.

Dictionaries solve this by using Keys and Values. They allow you to store structured information where the data is self-describing.

This is critical for AI Agents because every major LLM API (OpenAI, Anthropic, etc.) expects data in a specific dictionary format. You cannot send a raw string to ChatGPT; you must send a structured list of dictionaries containing role and content.

What You'll Build

  1. A basic dictionary to store user profiles.
  2. Nested dictionaries (dictionaries inside lists, lists inside dictionaries).
  3. The exact Message Structure required to send data to an AI model.
  4. A function that formats user input into the AI-ready dictionary format.

1. The Basics: Keys and Values

A dictionary is defined by curly braces {}. Inside, you define key: value pairs separated by commas.

⚠️ Warning: Do not confuse {} in dictionaries with f-strings. In f-strings, {} is a placeholder for variables. In dictionaries, {} defines the container itself.

Code Example: Basic Profile

# Save as: basic_dict.py

# Creating a dictionary
profile = {
 "name": "Priya Sharma",
 "role": "Marketing Executive",
 "experience": 5
}

# Printing the whole dictionary
print("Full Profile:", profile)

# Accessing a specific value using the KEY (not index)
print("Name:", profile["name"])
print("Role:", profile["role"])

# Modifying a value
profile["experience"] = 6
print("Updated Experience:", profile["experience"])

# Adding a new key-value pair
profile["skills"] = ["Content Writing", "SEO", "Analytics"]
print("Skills:", profile["skills"])

Expected Output:

Full Profile: {'name': 'Priya Sharma', 'role': 'Marketing Executive', 'experience': 5}
Name: Priya Sharma
Role: Marketing Executive
Updated Experience: 6
Skills: ['Content Writing','SEO', 'Analytics']

Verify: Run the code. Notice that profile["name"] returns the string directly, whereas profile returns the entire structure.


2. Nested Structures: Lists Inside Dictionaries

Dictionaries are versatile because values can be any data type, including other lists or dictionaries. This is how you store complex data like a list of skills or a history of messages.

Code Example: Nested Data

# Save as: nested_dict.py

user_data = {
 "id": 101,
 "name": "Priya",
 "skills": ["Python", "Excel", "SQL"], # A list inside the dict
 "contact": {
 "email": "priya@example.com",
 "phone": "+91-9999999999" # A dict inside the dict
 }
}

# Accessing a simple value
print("User ID:", user_data["id"])

# Accessing a list inside the dict
print("All Skills:", user_data["skills"])

# Accessing a specific item in the nested list
# Index 0 is the first skill
print("First Skill:", user_data["skills"][0])

# Accessing a value in the nested dictionary
print("Email:", user_data["contact"]["email"])

# 💡 Tip: You can chain accessors, but be careful. 
# If "contact" doesn't exist, this will crash.
# We will learn.get() later to handle this safely.

Expected Output:

User ID: 101
All Skills: ['Python', 'Excel','SQL']
First Skill: Python
Email: priya@example.com

Verify: Ensure you understand the path: user_data -> contact -> email.


3. The AI Message Format (Crucial for Agents)

When you interact with an AI model (like ChatGPT), you are not just sending a string. You are sending a conversation history. The model needs to know who said what.

The standard format is a List of Dictionaries. Each dictionary represents one message in the conversation and must contain two keys: 1. role: Who is speaking? ("system", "user", or "assistant") 2. content: What did they say?

The Three Roles

Role Description
system Instructions for the AI (e.g., "You are a helpful assistant.")
user The input from the human user.
assistant The previous response from the AI.

Code Example: Building an AI Message List

# Save as: ai_message_format.py

# This is the structure you will send to an API
messages = [
 {
 "role": "system",
 "content": "You are a helpful assistant that summarizes resumes."
 },
 {
 "role": "user",
 "content": "Here is my resume: Priya Sharma, Marketing Executive, 5 years experience."
 },
 {
 "role": "assistant",
 "content": "I have received your resume. How can I help you with it?"
 },
 {
 "role": "user",
 "content": "Please summarize my key skills."
 }
]

# Let's inspect the structure
print("Total messages in history:", len(messages))

# Accessing the last user message
last_user_msg = messages[-1]
print("Last Message Role:", last_user_msg["role"])
print("Last Message Content:", last_user_msg["content"])

# Accessing the system prompt
system_prompt = messages[0]
print("System Prompt:", system_prompt["content"])

Expected Output:

Total messages in history: 4
Last Message Role: user
Last Message Content: Please summarize my key skills.
System Prompt: You are a helpful assistant that summarizes resumes.

Verify: This exact structure ([{"role":., "content":.},.]) is what you will pass to openai.chat.completions.create() or similar APIs.


4. Building a Dynamic Message Builder

In a real application, you don't hardcode the messages. You build them dynamically based on user input. Let's write a function that takes a user's question and appends it to the message history.

Code Example: Dynamic Message Builder

# Save as: message_builder.py

def create_initial_messages(system_instruction: str) -> list:
 """
 Creates the initial message list with only the system prompt.
 """
 return [
 {
 "role": "system",
 "content": system_instruction
 }
 ]

def add_user_message(messages: list, user_input: str) -> list:
 """
 Appends a user message to the existing history.
 Returns the updated list.
 """
 new_message = {
 "role": "user",
 "content": user_input
 }
 messages.append(new_message)
 return messages

def add_assistant_message(messages: list, ai_response: str) -> list:
 """
 Appends an assistant message to the existing history.
 Returns the updated list.
 """
 new_message = {
 "role": "assistant",
 "content": ai_response
 }
 messages.append(new_message)
 return messages

# --- Simulation of a Conversation ---

# 1. Initialize with system prompt
history = create_initial_messages("You are a resume expert.")

# 2. User asks a question
user_query = "What is the best format for a resume?"
history = add_user_message(history, user_query)

# 3. AI responds (simulated here, in real life this comes from API)
ai_response = "The best format is a clean, single-column layout with clear headings."
history = add_assistant_message(history, ai_response)

# 4. User asks a follow-up
user_query_2 = "Should I include a photo?"
history = add_user_message(history, user_query_2)

# 5. Print the final structure to see what would be sent to the API
print("Final Message History Structure:")
print("-" * 30)
for i, msg in enumerate(history):
 print(f"Message {i}:")
 print(f" Role: {msg['role']}")
 print(f" Content: {msg['content']}")
 print("-" * 30)

Expected Output:

Final Message History Structure:
------------------------------
Message 0:
 Role: system
 Content: You are a resume expert.
------------------------------
Message 1:
 Role: user
 Content: What is the best format for a resume?
------------------------------
Message 2:
 Role: assistant
 Content: The best format is a clean, single-column layout with clear headings.
------------------------------
Message 3:
 Role: user
 Content: Should I include a photo?
------------------------------

Verify: Notice how the history grows. This is how "memory" works in AI agents. The model sees the entire list to understand context.


5. Safe Access with .get()

What happens if you try to access a key that doesn't exist?

profile = {"name": "Priya"}
print(profile["email"]) # ❌ This will crash with KeyError

In AI applications, data might be missing. Use .get() to provide a default value.

Code Example: Safe Access

# Save as: safe_access.py

profile = {
 "name": "Priya",
 "role": "Marketing"
 # "email" is missing
}

# ❌ Dangerous
# print(profile["email"])

# ✅ Safe
email = profile.get("email", "Not provided")
print("Email:", email)

# If the key exists, it returns the value
name = profile.get("name", "Unknown")
print("Name:", name)

Expected Output:

Email: Not provided
Name: Priya

💡 Tip: Always use .get() when processing external data (like user inputs or API responses) to prevent your agent from crashing.


📁 Project Files

  1. basic_dict.py - Basic dictionary creation and access.
  2. nested_dict.py - Accessing lists and dicts inside dicts.
  3. ai_message_format.py - Understanding the standard AI message structure.
  4. message_builder.py - Functions to dynamically build conversation history.
  5. safe_access.py - Using .get() to avoid crashes.

🚀 What You Just Built

You now understand how to structure data for AI agents. You can: 1. Create dictionaries to store structured information. 2. Nest lists and dictionaries to handle complex data. 3. Build the exact messages list required by LLM APIs. 4. Safely access data using .get().

This is the foundation for the next chapter, where we will actually send these dictionaries to an AI API.


Common Mistakes

  1. Using Indexes Instead of Keys:
  2. profile[0] (Dictionaries are not ordered by index in the same way lists are).
  3. profile["name"]

  4. Forgetting the Colon:

  5. {"name" "Priya"}
  6. {"name": "Priya"}

  7. Mixing Up Lists and Dicts in AI Format:

  8. The top-level structure must be a List.
  9. Each element in the list must be a Dictionary.
  10. {"role": "user", "content": "Hi"} (This is just one message, not a conversation).
  11. [{"role": "user", "content": "Hi"}] (This is a list containing one message).

  12. Hardcoding Messages:

  13. Don't write the message list manually in your main code. Use functions like add_user_message() to keep your code clean and scalable.

Troubleshooting

Error Cause Solution
KeyError: 'email' You tried to access a key that doesn't exist. Use profile.get("email") instead of profile["email"].
TypeError:'str' object is not subscriptable You tried to index a string like a list/dict. Check if you are accessing a dictionary or a string.
ValueError: Expected a list of messages You sent a single dictionary instead of a list of dictionaries to the API. Wrap your dictionary in a list: [my_dict].

Key Takeaways

  1. Dictionaries use key: value pairs.
  2. Keys are unique and usually strings.
  3. Values can be any type (string, int, list, dict).
  4. AI Message Format is a List of Dictionaries with role and content keys.
  5. Use .get() for safe access to avoid crashes.

🧪 Try It Yourself

  1. Create a dictionary for a book with keys: title, author, year, genres (list).
  2. Write a function add_review(messages, review_text) that appends a user message with the review text.
  3. Simulate a conversation where the user asks for a book recommendation, the AI responds, and the user asks for a similar book. Print the final message history.

Next Chapter: We will connect this structured data to an actual AI API and send our first request.

Chapter 5

Talking to an LLM from Python

Why This Matters


Before You Start This Chapter

  • Your venv is active (you see `(.

    What is a virtual environment (venv)? It is a separate, isolated Python world for your project. Without it, packages from different projects can conflict. With a venv, each project gets its own clean set of packages. Think of it like a separate browser profile for each website.

venv)in your prompt) - Previous chapter's project is working - Your.env` file has your API key

What is an API? An API (Application Programming Interface) is how one program talks to another. Think of it like a waiter in a restaurant — you (your code) tell the waiter (API) what you want, the waiter goes to the kitchen (the server), and brings back your food (the response). You never talk to the kitchen directly.

  • You are in the correct folder (run pwd to check)

Get Your OpenAI API Key

  1. Go to https://platform.openai.com/api-keys
  2. Log in (free account)
  3. Click "Create new secret key"
  4. Name it (e.g., "adhikk")
  5. Click "Create"
  6. Copy the key (starts with sk-) — you will not see it again
  7. Paste into your .env file

New accounts get $5 free credit — enough for this entire book.

Create & Activate Virtual Environment

uv venv

Activate (Windows PowerShell):

.venv\Scripts\Activate

Activate (Mac/Linux):

source .venv/bin/activate

How you'll know it worked: Your prompt shows (.venv) at the start.

Install Python (if not already installed)

  1. Go to https://www.python.org/downloads/
  2. Click "Download Python 3.12.x"
  3. Run the installer
  4. CHECK the box: Add python.exe to PATH

    What is PATH? PATH is a list of folders your computer checks when you type a command. If you type python, your computer looks in each PATH folder for a file called python.exe. If it finds it, it runs it. If not, you get "command not found." Installing Python with "Add to PATH" checked adds Python's folder to this list.

  5. Click "Install Now"

  6. Close and reopen your terminal

Verify:

python --version

Expected output:

Python 3.12.x

Functions are the building blocks of reusable and maintainable code. Imports let you tap into the massive ecosystem of third‑party libraries—​from data‑wrangling with pandas to visualisation with matplotlib. Mastering these concepts is the first step toward writing production‑grade Python for AI agents, data science, and any real‑world project.


What You’ll Build

By the end of this chapter you will have a single, runnable script that:

  1. Defines several simple functions (greet, summarise_profile, add_numbers).
  2. Imports and uses pandas to create a DataFrame from a dictionary.
  3. Adds a computed column (final_score).
  4. Classifies rows with a lambda function (level).
  5. Filters the DataFrame and exports the result to a CSV file.

All of this will run out‑of‑the‑box after installing the dependencies listed in requirements.txt.


Content

1️⃣ Defining & Calling Functions

# Save as: functions_demo.py
def greet(name: str) -> str:
 """Return a friendly greeting."""
 return f"Hello {name}, welcome to AI agents."

def summarise_profile(name: str, role: str, skill: str) -> str:
 """Create a one‑line profile summary."""
 return f"{name} is a {role} who is skilled in {skill}."

def add_numbers(a: int, b: int) -> int:
 """Return the sum of two numbers."""
 return a + b

# Demonstration
if __name__ == "__main__":
 message = greet("Priya")
 print(message) # 👉 Hello Priya, welcome to AI agents.

 profile = summarise_profile("Rohan", "Data Analyst", "Python")
 print(profile) # 👉 Rohan is a Data Analyst who is skilled in Python.

 result = add_numbers(10, 25)
 print(f"The sum is {result}") # 👉 The sum is 35

Expected output

Hello Priya, welcome to AI agents.
Rohan is a Data Analyst who is skilled in Python.
The sum is 35

Verify: Run python functions_demo.py and confirm the three lines above appear.


2️⃣ Importing Libraries

# Save as: imports_demo.py
import os # Standard library – file‑system utilities
import pandas as pd # Third‑party – data manipulation
import matplotlib.pyplot as plt # Third‑party – plotting (optional for this chapter)

print("Modules imported successfully!")

Expected output

Modules imported successfully!

Verify: Execute python imports_demo.py. If you see the message, the imports work. ⚠️ If you get ModuleNotFoundError for pandas or matplotlib, install the dependencies (see requirements.txt).


3️⃣ Working with Pandas

# Save as: pandas_demo.py
import pandas as pd

# 1️⃣ Create a dictionary that mimics an Excel sheet
students = {
 "name": ["Alice", "Bob", "Charlie", "Diana"],
 "score": [88, 92, 67, 79],
 "hours": [10, 12, 8, 9]
}

# 2️⃣ Convert the dictionary to a DataFrame (think of it as an Excel table)
df = pd.DataFrame(students)
print("Initial DataFrame:")
print(df)

# 3️⃣ Add a computed column: final_score = score + 0.5 * hours
df["final_score"] = df["score"] + 0.5 * df["hours"]
print("\nAfter adding `final_score` column:")
print(df)

# 4️⃣ Classify each student as 'advanced' or 'beginner' using a lambda
df["level"] = df["final_score"].apply(lambda x: "advanced" if x >= 90 else "beginner")
print("\nAfter adding `level` column:")
print(df)

# 5️⃣ Filter rows where final_score >= 75
filtered = df[df["final_score"] >= 75]
print("\nStudents with final_score >= 75:")
print(filtered)

# 6️⃣ Export the filtered DataFrame to CSV
filtered.to_csv("filtered_students.csv", index=False)
print("\nFiltered data saved to `filtered_students.csv`")

Expected output

Initial DataFrame:
 name score hours
0 Alice 88 10
1 Bob 92 12
2 Charlie 67 8
3 Diana 79 9

After adding `final_score` column:
 name score hours final_score
0 Alice 88 10 93.0
1 Bob 92 12 98.0
2 Charlie 67 8 71.0
3 Diana 79 9 83.5

After adding `level` column:
 name score hours final_score level
0 Alice 88 10 93.0 advanced
1 Bob 92 12 98.0 advanced
2 Charlie 67 8 71.0 beginner
3 Diana 79 9 83.5 beginner

Students with final_score >= 75:
 name score hours final_score level
0 Alice 88 10 93.0 advanced
1 Bob 92 12 98.0 advanced
3 Diana 79 9 83.5 beginner

Filtered data saved to `filtered_students.csv`

Verify: Run python pandas_demo.py. 💡 Open filtered_students.csv with any spreadsheet program to see the same rows.


4️⃣ Putting It All Together

# Save as: main.py
import pandas as pd

# ---------- Functions ----------
def greet(name: str) -> str:
 return f"Hello {name}, welcome to AI agents."

def summarise_profile(name: str, role: str, skill: str) -> str:
 return f"{name} is a {role} who is skilled in {skill}."

def add_numbers(a: int, b: int) -> int:
 return a + b

# ---------- Data Processing ----------
def build_student_dataframe() -> pd.DataFrame:
 students = {
 "name": ["Alice", "Bob", "Charlie", "Diana"],
 "score": [88, 92, 67, 79],
 "hours": [10, 12, 8, 9]
 }
 df = pd.DataFrame(students)
 df["final_score"] = df["score"] + 0.5 * df["hours"]
 df["level"] = df["final_score"].apply(lambda x: "advanced" if x >= 90 else "beginner")
 return df

def filter_and_save(df: pd.DataFrame, threshold: float = 75.0, filename: str = "filtered_students.csv"):
 filtered = df[df["final_score"] >= threshold]
 filtered.to_csv(filename, index=False)
 return filtered

# ---------- Main Execution ----------
if __name__ == "__main__":
 # Function demos
 print(greet("Priya"))
 print(summarise_profile("Rohan", "Data Analyst", "Python"))
 print(f"The sum of 10 and 25 is {add_numbers(10, 25)}\n")

 # DataFrame workflow
 df = build_student_dataframe()
 print("Full DataFrame:")
 print(df, "\n")

 filtered_df = filter_and_save(df)
 print("Filtered DataFrame (saved to CSV):")
 print(filtered_df)

Expected output

Hello Priya, welcome to AI agents.
Rohan is a Data Analyst who is skilled in Python.
The sum of 10 and 25 is 35

Full DataFrame:
 name score hours final_score level
0 Alice 88 10 93.0 advanced
1 Bob 92 12 98.0 advanced
2 Charlie 67 8 71.0 beginner
3 Diana 79 9 83.5 beginner 

Filtered DataFrame (saved to CSV):
 name score hours final_score level
0 Alice 88 10 93.0 advanced
1 Bob 92 12 98.0 advanced
3 Diana 79 9 83.5 beginner

Verify: Run python main.py. A file named filtered_students.csv should appear in the same directory.


📁 Project Files

File Description
functions_demo.py Simple function definitions and usage.
imports_demo.py Demonstrates importing standard and third‑party modules.
pandas_demo.py Step‑by‑step pandas tutorial (DataFrame creation, column ops, filtering, CSV export).
main.py Integrated script that combines functions and pandas workflow.
requirements.txt Lists required third‑party packages.
# Save as: requirements.txt
pandas==2.2.2
matplotlib==3.9.0

Tip: The exact version numbers are optional; you can use pandas and matplotlib without specifying a version if you prefer the latest releases.


🚀 What You Just Built

  • Reusable functions that encapsulate logic.
  • Modular code that can be imported elsewhere.
  • A pandas DataFrame built from a Python dictionary.
  • Computed columns (final_score) and categorical labeling (level) using apply + lambda.
  • Filtering based on a numeric threshold.
  • CSV export for downstream consumption (e.g., Excel, BI tools).

All of this is ready to be extended—add more columns, visualise with matplotlib, or plug into an AI‑agent pipeline.


Common Mistakes

Mistake Why It Happens Fix
Forgetting to import pandas as pd The script tries to call pd.DataFrame without the alias. Add import pandas as pd at the top.
Using df["new_col"] =. on a copy of a slice Pandas may raise a SettingWithCopyWarning. Ensure you operate on the original DataFrame or use .loc.
Misspelling the CSV filename when exporting File not found later when you try to read it. Double‑check the string passed to to_csv.
Not activating the virtual environment before pip install Packages install to the global Python, not the project env. Run source.venv/bin/activate (Linux/macOS) or .\.venv\Scripts\activate (Windows) before installing.

Troubleshooting

  1. ModuleNotFoundError: No module named 'pandas'
  2. Run pip install -r requirements.txt inside your activated virtual environment.

  3. SettingWithCopyWarning when adding a column after filtering

  4. Use df = df.copy() after the filter, or assign with .loc[:, "col"] =..

  5. CSV file is empty

  6. Verify that the filter condition actually matches rows (df["final_score"] >= 75).

  7. Unexpected data types (e.g., strings instead of numbers)

  8. Ensure the dictionary values are numeric (int or float). Use pd.to_numeric if needed.

Key Takeaways

  • Functions let you write code once and reuse it everywhere.
  • Imports give you instant access to a massive ecosystem; never reinvent the wheel.
  • Pandas turns plain Python data structures into powerful, spreadsheet‑like tables.
  • Lambda + apply is a concise way to create derived columns.
  • Filtering and exporting are essential for real‑world data pipelines.

Master these fundamentals, and you’ll be ready to build more sophisticated AI agents, data pipelines, and production services.


🧪 Try It Yourself

  1. Extend students with a new column attendance (percentage).
  2. Update final_score to also factor in attendance (e.g., + 0.2 * attendance).
  3. Add a new classification: "expert" if final_score >= 95.
  4. Plot a bar chart of final_score per student using matplotlib (optional).
# Save as: challenge.py
import pandas as pd
import matplotlib.pyplot as plt

students = {
 "name": ["Alice", "Bob", "Charlie", "Diana"],
 "score": [88, 92, 67, 79],
 "hours": [10, 12, 8, 9],
 "attendance": [95, 88, 70, 80] # <-- new data
}

df = pd.DataFrame(students)
df["final_score"] = df["score"] + 0.5 * df["hours"] + 0.2 * df["attendance"]
df["level"] = df["final_score"].apply(
 lambda x: "expert" if x >= 95 else ("advanced" if x >= 90 else "beginner")
)

print(df)

# Optional visualisation
df.plot.bar(x="name", y="final_score", legend=False, title="Final Scores")
plt.ylabel("Score")
plt.tight_layout()
plt.show()

Run python challenge.py. Verify that the new column appears, the classification updates, and (if you kept the plot code) a bar chart pops up.

Happy coding! 🚀

Why This Matters

Modern applications rarely live in isolation. They talk to other services—whether it’s a payment gateway, a weather API, or a large language model (LLM) like OpenAI’s ChatGPT. Understanding how to securely store credentials, load them at runtime, and wrap API calls in clean, reusable code is a core skill for any production‑grade Python developer.


What You'll Build

In this section you will create a tiny, production‑ready wrapper around the OpenAI Chat Completion API:

Feature Description
Secure credential handling using a .env file and python‑dotenv
Reusable client class (OpenAIClient) that hides the low‑level SDK calls
Simple CLI demo that sends a prompt and prints the model’s response
Unit‑testable design – the wrapper can be mocked in tests later

By the end of the chapter you will have a stand‑alone script (app.py) that you can run without any extra research.


Content

1️⃣ Set Up the Project Skeleton

Create a fresh folder for the demo (e.g., openai_demo). Inside it, add the following files:

File Purpose
.env Stores your secret API key (never commit this)
requirements.txt Lists third‑party packages
openai_client.py The wrapper class
app.py CLI entry point that uses the wrapper
README.md Quick usage instructions (optional)

2️⃣ Install Dependencies

# From the project root
python -m venv.venv
source.venv/bin/activate # Windows:.venv\Scripts\activate
pip install -r requirements.txt

✅ Verify:

Successfully installed openai python-dotenv

3️⃣ Create the .env File

⚠️ Never push this file to a public repository. Add it to .gitignore.

# Save as:.env
OPENAI_API_KEY="sk-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"

💡 Tip: If you don’t have an API key yet, generate one at https://platform.openai.com/account/api-keys.

4️⃣ Write requirements.txt

# Save as: requirements.txt
openai>=1.0.0
python-dotenv>=1.0.0

5️⃣ Build the Wrapper – openai_client.py

# Save as: openai_client.py
import os
from typing import List, Dict, Any

import openai
from dotenv import load_dotenv

# Load environment variables from.env (if present)
load_dotenv()

class OpenAIClient:
 """
 A thin wrapper around the OpenAI Chat Completion API.
 Keeps credential handling, model selection, and request formatting in one place.
 """

 def __init__(self, model: str = "gpt-4o-mini"):
 """
 Initialise the client.

 Parameters
 ----------
 model: str
 The model name to use for completions. Defaults to the cheap, fast `gpt-4o-mini`.
 """
 api_key = os.getenv("OPENAI_API_KEY")
 if not api_key:
 raise RuntimeError(
 "OPENAI_API_KEY not found. Set it in a.env file or export it in your shell."
 )
 openai.api_key = api_key
 self.model = model

 def chat(
 self,
 messages: List[Dict[str, str]],
 temperature: float = 0.7,
 max_tokens: int = 500,
 ) -> str:
 """
 Send a list of messages to the model and return the assistant's reply.

 Parameters
 ----------
 messages: List[Dict[str, str]]
 Conversation history. Each dict must have a ``role`` (system|user|assistant)
 and a ``content`` field.
 temperature: float
 Controls randomness. 0 = deterministic, 1 = very creative.
 max_tokens: int
 Upper bound on the number of tokens in the response.

 Returns
 -------
 str
 The assistant's generated text.
 """
 response = openai.ChatCompletion.create(
 model=self.model,
 messages=messages,
 temperature=temperature,
 max_tokens=max_tokens,
 )
 # The response schema is stable across SDK versions:
 # response.choices[0].message.content holds the text.
 return response.choices[0].message.content.strip()

✅ Verify:

python -c "import openai_client; print('Wrapper imported successfully')"

Expected output:

Wrapper imported successfully

6️⃣ CLI Demo – app.py

# Save as: app.py
import sys
from getpass import getpass

from openai_client import OpenAIClient


def main() -> None:
 """
 Simple command‑line interface that asks the user for a prompt,
 sends it to the OpenAI model, and prints the response.
 """
 # Optional: allow the user to override the model via an env var or CLI arg
 model = os.getenv("OPENAI_MODEL", "gpt-4o-mini")
 client = OpenAIClient(model=model)

 # Gather a prompt from the user. Using getpass hides the input on the terminal,
 # which is handy if you ever want to type a secret (not required here).
 prompt = input("🗣️ Enter your question for the LLM: ").strip()
 if not prompt:
 print("❌ No prompt provided – exiting.")
 sys.exit(1)

 # Build the message list expected by the API
 messages = [
 {"role": "system", "content": "You are a helpful, concise assistant."},
 {"role": "user", "content": prompt},
 ]

 try:
 answer = client.chat(messages)
 print("\n🤖 Model response:\n")
 print(answer)
 except Exception as exc:
 print(f"❌ An error occurred while contacting the API: {exc}")
 sys.exit(1)


if __name__ == "__main__":
 main()

✅ Verify: Run the script

python app.py

Sample interaction

🗣️ Enter your question for the LLM: What is the capital of France?

🤖 Model response:

Paris

7️⃣ (Optional) Add a Quick Test – test_openai_client.py

# Save as: test_openai_client.py
import os
from unittest.mock import patch, MagicMock

import pytest

from openai_client import OpenAIClient


@pytest.fixture
def dummy_key(monkeypatch):
 monkeypatch.setenv("OPENAI_API_KEY", "test-key")


def test_client_initialises_with_key(dummy_key):
 client = OpenAIClient()
 assert client.model == "gpt-4o-mini"


@patch("openai.ChatCompletion.create")
def test_chat_returns_content(mock_create, dummy_key):
 # Mock the OpenAI response structure
 mock_create.return_value = MagicMock(
 choices=[MagicMock(message=MagicMock(content="Mocked answer"))]
 )
 client = OpenAIClient()
 answer = client.chat([{"role": "user", "content": "Hello"}])
 assert answer == "Mocked answer"
 mock_create.assert_called_once()

Run the test:

pytest -q

Expected output:

```. [100%] 2 passed in 0.12s


---

## 📁 Project Files

openai_demo/ ├─.env # ← secret key (never commit) ├─ requirements.txt ├─ openai_client.py ├─ app.py └─ test_openai_client.py # optional, for CI pipelines


---

## 🚀 What You Just Built
- **Secure credential loading** via `python-dotenv` and `os.getenv`.
- **Reusable, typed wrapper** (`OpenAIClient`) that abstracts the raw SDK.
- **Command‑line demo** that shows a real‑world interaction with an LLM.
- **Skeleton for unit testing** using `unittest.mock` (critical for CI).

All of this is ready to be dropped into a larger codebase or used as a template for other APIs.

---

## Common Mistakes 

| Mistake | Why it Happens | Fix |
|---------|----------------|-----|
| Forgetting to **activate the virtual environment** before installing packages. | The system Python may not have the required libs. | Run `source.venv/bin/activate` (or the Windows equivalent) each time you open a new terminal. |
| **Hard‑coding** the API key in source files. | Easy to commit accidentally. | Keep the key only in `.env` and load it with `dotenv`. |
| Using the **wrong model name** (`gpt‑4o‑mini` vs `gpt‑4o-mini`). | Typos cause a 404 from the API. | Copy the model identifier directly from the OpenAI docs. |
| Not handling **network errors**. | API calls can fail (rate limits, timeouts). | Wrap the call in `try/except` and surface a friendly message (as shown in `app.py`). |
| Running the script **without the `.env` file**. | `os.getenv` returns `None`, raising `RuntimeError`. | Ensure `.env` exists or export `OPENAI_API_KEY` in the shell. |

---

## Troubleshooting 

| Symptom | Likely Cause | Remedy |
|---------|--------------|--------|
| `RuntimeError: OPENAI_API_KEY not found` | `.env` missing or not loaded. | Verify `.env` exists, run `load_dotenv()` before creating the client, or export the variable manually. |
| `openai.error.AuthenticationError` | Wrong or expired key. | Regenerate the key on the OpenAI portal and update `.env`. |
| `openai.error.RateLimitError` | Too many requests in a short period. | Add exponential back‑off or request a higher quota. |
| `ImportError: No module named 'dotenv'` | Dependency not installed. | Run `pip install -r requirements.txt` inside the activated venv. |
| Empty response from the model | `temperature` set to 0 and prompt ambiguous. | Adjust the prompt or increase `temperature`. |

---

## Key Takeaways 

1. **Never store secrets in code** – use environment variables and `.env` files. 
2. **Encapsulate third‑party SDK calls** in a small, well‑documented class. 
3. **Graceful error handling** makes your CLI robust and user‑friendly. 
4. **Unit‑testable design** (dependency injection, mocking) prepares you for production pipelines. 

---

## 🧪 Try It Yourself 

1. **Swap models** – change `OPENAI_MODEL` in the environment to `"gpt-4o"` and observe the difference in response quality and latency. 
2. **Add system messages** – prepend a custom instruction (e.g., “Speak like a pirate”) and see how the model adapts. 
3. **Create a batch script** that reads prompts from a CSV file, calls `OpenAIClient.chat` for each row, and writes the answers back to a new CSV. 

Happy coding! 🚀

## Why This Matters
AI agents are becoming the backbone of modern applications—think personal tutors, code assistants, or customer‑service bots. Being able to **switch seamlessly between providers** (OpenAI, Gemini, etc.) gives you flexibility, cost control, and resilience against service outages. This chapter shows you how to wrap multiple LLM APIs behind a single, clean Python function.

## What You'll Build
A tiny yet powerful library:

* **`ask_openai(prompt, **kwargs)`** – Calls OpenAI’s ChatCompletion.
* **`ask_gemini(prompt, **kwargs)`** – Calls Google Gemini.
* **`ask_llm(prompt, provider="openai", **kwargs)`** – A unified interface that picks the right backend.
* Example scripts that request concise answers (token‑efficient) for different user groups.
> **What is a token?** A token is how AI models count text. Think of it as a "word piece" — roughly 3-4 characters. "Hello" = 1 token. "Hello world" = 2 tokens. Models have a limit on how many tokens they can process at once (called the "context window").



## Content
1. **Setup** – Install dependencies, store API keys safely. 
2. **Provider wrappers** – Minimal code to talk to each service. 
3. **Unified dispatcher** – `ask_llm` decides which wrapper to invoke. 
4. **Demo usage** – One‑liner queries for students, professionals, and prompt‑engineering. 
5. **Running the demo** – Expected console output.

---

## 📁 Project Files

### 1️⃣ `config.py` – Centralised configuration 
```python
# Save as: config.py
import os
from pathlib import Path
from dotenv import load_dotenv

# Load.env file located at the project root
env_path = Path(__file__).parent / ".env"
load_dotenv(dotenv_path=env_path)

# ----------------------------------------------------------------------
# API Keys – keep them out of source control!
# ----------------------------------------------------------------------
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")

# Default model identifiers
OPENAI_MODEL = "gpt-3.5-turbo"
GEMINI_MODEL = "gemini-1.5-flash"

2️⃣ llm_wrappers.py – Provider‑specific helpers

# Save as: llm_wrappers.py
import json
import logging
from typing import Any, Dict

import openai
import google.generativeai as genai

from config import OPENAI_API_KEY, GEMINI_API_KEY, OPENAI_MODEL, GEMINI_MODEL

# ----------------------------------------------------------------------
# Initialise clients
# ----------------------------------------------------------------------
openai.api_key = OPENAI_API_KEY
genai.configure(api_key=GEMINI_API_KEY)

logger = logging.getLogger(__name__)

def _extract_openai_text(response: Dict[str, Any]) -> str:
 """Extract the assistant's reply from OpenAI's response."""
 try:
 return response["choices"][0]["message"]["content"].strip()
 except (KeyError, IndexError) as exc:
 logger.error("Unexpected OpenAI response shape: %s", json.dumps(response))
 raise exc

def _extract_gemini_text(response: Any) -> str:
 """Gemini returns a `GenerateContentResponse`; we need `text`."""
 try:
 # `text` may be a list of parts; join them.
 return "".join(part.text for part in response.candidates[0].content.parts).strip()
 except Exception as exc:
 logger.error("Failed to parse Gemini response.")
 raise exc

def ask_openai(prompt: str, max_tokens: int = 150, temperature: float = 0.7) -> str:
 """
 Send a prompt to OpenAI's ChatCompletion endpoint.
 Returns the raw text answer.
 """
 response = openai.ChatCompletion.create(
 model=OPENAI_MODEL,
 messages=[{"role": "user", "content": prompt}],
 max_tokens=max_tokens,
 temperature=temperature,
 )
 return _extract_openai_text(response)

def ask_gemini(prompt: str, max_output_tokens: int = 150, temperature: float = 0.7) -> str:
 """
 Send a prompt to Google Gemini.
 Returns the raw text answer.
 """
 model = genai.GenerativeModel(GEMINI_MODEL)
 response = model.generate_content(
 prompt,
 generation_config=genai.types.GenerationConfig(
 max_output_tokens=max_output_tokens,
 temperature=temperature,
 ),
 )
 return _extract_gemini_text(response)

3️⃣ ask_llm.py – Unified dispatcher

# Save as: ask_llm.py
from typing import Literal

from llm_wrappers import ask_openai, ask_gemini

Provider = Literal["openai", "gemini"]

def ask_llm(
 prompt: str,
 provider: Provider = "openai",
 *,
 max_tokens: int = 150,
 temperature: float = 0.7,
) -> str:
 """
 One‑stop function to query either OpenAI or Gemini.

 Parameters
 ----------
 prompt: str
 The user query.
 provider: {"openai", "gemini"}
 Which backend to use.
 max_tokens: int
 Upper bound on the generated token count (helps keep costs low).
 temperature: float
 Controls randomness; 0 = deterministic, 1 = very creative.

 Returns
 -------
 str
 The model's raw textual answer.
 """
 if provider == "openai":
 return ask_openai(prompt, max_tokens=max_tokens, temperature=temperature)
 elif provider == "gemini":
 return ask_gemini(prompt, max_output_tokens=max_tokens, temperature=temperature)
 else:
 raise ValueError(f"Unsupported provider: {provider!r}")

4️⃣ demo.py – Putting it all together

# Save as: demo.py
import textwrap

from ask_llm import ask_llm

def print_section(title: str, answer: str) -> None:
 """Pretty‑print a demo section."""
 separator = "=" * len(title)
 print(f"\n{title}\n{separator}\n{answer}\n")

def main() -> None:
 # 1️⃣ One‑line use case for a student (OpenAI)
 student_prompt = (
 "Give one concise use case of an AI agent for a student, in one sentence."
 )
 student_answer = ask_llm(student_prompt, provider="openai", max_tokens=30)
 print_section("Student Use‑Case (OpenAI)", student_answer)

 # 2️⃣ One‑line use case for a working professional (Gemini)
 professional_prompt = (
 "Give one concise use case of an AI agent for a working professional, in one sentence."
 )
 professional_answer = ask_llm(professional_prompt, provider="gemini", max_tokens=30)
 print_section("Professional Use‑Case (Gemini)", professional_answer)

 # 3️⃣ Prompt‑engineering definition (OpenAI, very short)
 pe_prompt = "Explain prompt engineering in one line."
 pe_answer = ask_llm(pe_prompt, provider="openai", max_tokens=20)
 print_section("Prompt Engineering (OpenAI)", pe_answer)

if __name__ == "__main__":
 main()

Expected console output (your exact wording may vary slightly depending on the model’s current knowledge):

Student Use‑Case (OpenAI)
=========================
An AI agent can act as a personalized tutor that explains concepts and generates practice quizzes on demand.

Professional Use‑Case (Gemini)
=============================
An AI agent can serve as a smart meeting assistant that summarizes discussions and drafts follow‑up emails.

Prompt Engineering (OpenAI)
===========================
Prompt engineering is the craft of designing inputs that guide LLMs to produce the desired output.

✅ Verify: Run python demo.py after setting the environment variables (see below). You should see three sections printed exactly as shown.


🚀 What You Just Built

Feature How It Works
Provider‑agnostic calls ask_llm decides which wrapper to invoke based on the provider argument.
Token‑efficient queries max_tokens (or max_output_tokens for Gemini) caps the response length, keeping costs low.
Single‑line prompts By limiting max_tokens, the model returns concise answers—perfect for UI widgets or voice assistants.
Extensible architecture Adding a new provider only requires a tiny wrapper and a tiny if branch in ask_llm.

Common Mistakes

Mistake Why It Happens Fix
Forgot to set API keys The code raises an authentication error. Create a .env file (see below) and run pip install python-dotenv.
Mixing max_tokens with Gemini’s max_output_tokens Passing the wrong kwarg leads to a TypeError. The dispatcher translates the generic max_tokens to the correct provider‑specific name.
Printing the whole response object The raw object contains many nested fields, cluttering output. Use the helper _extract_*_text functions that return only the answer string.
Using a model that doesn’t exist OpenAI or Gemini will return a 404. Keep model names in config.py and update them when new versions are released.

Troubleshooting

Symptom Likely Cause Remedy
openai.error.AuthenticationError OPENAI_API_KEY missing or invalid. Verify the key in .env and that it has the Chat Completion permission.
google.api_core.exceptions.PermissionDenied GEMINI_API_KEY missing or lacks the generative-language scope. Regenerate the key from Google Cloud Console, enable the Gemini API.
Empty string returned Prompt too short or max_tokens set to 0. Increase max_tokens to at least 10, or make the prompt more specific.
Rate‑limit errors Too many rapid calls. Add time.sleep(1) between calls or request a higher quota from the provider.

Key Takeaways

  • Abstraction matters – A thin dispatcher (ask_llm) gives you the freedom to swap providers without touching the rest of your code.
  • Control costmax_tokens caps spend and forces the model to be concise.
  • Keep secrets out of code – Use environment variables (python-dotenv) for API keys.
  • Modular design – Adding a new LLM is a matter of a few lines, not a rewrite.

🧪 Try It Yourself

  1. Create a .env file in the project root:

dotenv OPENAI_API_KEY=sk-. GEMINI_API_KEY=AIza.

  1. Install dependencies:

bash pip install openai google-generativeai python-dotenv

  1. Run the demo:

bash python demo.py

  1. Extend the library:
  2. Add a wrapper for Anthropic’s Claude.
  3. Implement a caching layer (e.g., functools.lru_cache) to avoid duplicate calls.
  4. Build a tiny Flask API that exposes ask_llm over HTTP.

💡 Tip: When you start building a UI, keep the ask_llm signature unchanged; the UI layer can pass provider="openai" or "gemini" based on user preference.

⚠️ Warning: Never commit your .env file to version control. Add it to .gitignore immediately.

Happy coding! 🎉


What's Next

Continue to the next chapter to keep building.

Chapter 6

From LLM to Agent

Why This Matters

Activate Your Virtual Environment

If you have not activated your venv yet (check: do you see (.venv) in your prompt?):

Windows PowerShell:

   .venv\Scripts\Activate

Mac/Linux:

   source .venv/bin/activate

Expected: Your prompt now starts with (.venv).


Before You Start This Chapter

  • Your venv is active (you see `(.

    What is a virtual environment (venv)? It is a separate, isolated Python world for your project. Without it, packages from different projects can conflict. With a venv, each project gets its own clean set of packages. Think of it like a separate browser profile for each website.

venv)in your prompt) - Previous chapter's project is working - Your.env` file has your API key

What is an API? An API (Application Programming Interface) is how one program talks to another. Think of it like a waiter in a restaurant — you (your code) tell the waiter (API) what you want, the waiter goes to the kitchen (the server), and brings back your food (the response). You never talk to the kitchen directly.

  • You are in the correct folder (run pwd to check)

Get Your OpenAI API Key

  1. Go to https://platform.openai.com/api-keys
  2. Log in (free account)
  3. Click "Create new secret key"
  4. Name it (e.g., "adhikk")
  5. Click "Create"
  6. Copy the key (starts with sk-) — you will not see it again
  7. Paste into your .env file

New accounts get $5 free credit — enough for this entire book.

Large Language Models (LLMs) are the engine behind every modern AI‑powered chat, code‑assistant, and autonomous agent you see today. Understanding what an LLM is, how it “remembers”, and how to shape its behavior with a context window is the foundation for building any AI system—whether the next framework you use is open‑source or a proprietary cloud service. Master these concepts now, and you’ll never be caught off‑guard by a new model release.


What You’ll Build

In this chapter you will create a minimal, production‑ready chat agent that:

  1. Sets a system prompt (the “role” you want the model to play).
  2. Manages the context window so the conversation never exceeds the model’s token limit.

    What is a token? A token is how AI models count text. Think of it as a "word piece" — roughly 3-4 characters. "Hello" = 1 token. "Hello world" = 2 tokens. Models have a limit on how many tokens they can process at once (called the "context window").

  3. Counts tokens with the tiktoken library and truncates older messages when needed.

  4. Runs locally with a single Python file and a tiny helper module—no extra research required.

By the end you’ll have a reusable skeleton you can plug into any LLM (OpenAI, Anthropic, Cohere, etc.) and extend into a full‑blown AI agent.


Content

1. LLM Basics

Concept What It Means
LLM A neural network trained on massive text corpora to predict the next token.
Closed‑source vs Open‑source Closed‑source models (e.g., Claude, ChatGPT) hide architecture & weights; open‑source models expose them, allowing fine‑tuning.
Knowledge vs Reasoning The model stores statistical patterns (knowledge) and uses them to autocomplete or answer based on the prompt.
Prompt The text you feed the model; can include a system prompt that defines behavior (e.g., “You are a professional sales agent”).
Context Window The maximum number of tokens the model can consider at once (input + output). Exceeding it truncates older information.

2. Tokens & Context Window

  • Token ≈ word (but not exactly; punctuation and sub‑words count too).
  • Example: "My name is Ishant." → 5 tokens.
  • A model with a 4,096‑token window can handle roughly 2,000 words of input plus the model’s response.

💡 Tip: Use the tiktoken library to count tokens precisely; this avoids “Token limit exceeded” errors at runtime.

3. Prompt Engineering

A good prompt is a concatenation of messages:

[
 {"role": "system", "content": "You are a helpful AI assistant."},
 {"role": "user", "content": "What is the capital of France?"},
 {"role": "assistant", "content": "Paris."}
]

The system message stays at the top of the context window and never gets trimmed—think of it as the “desk” that always holds your instructions.

4. Building a Simple Chat Agent

We’ll write two files:

  1. utils.py – token counting & truncation helpers.
  2. chat_agent.py – the interactive loop that talks to the LLM.

Both files are fully runnable; just install the dependencies listed in the Project Files section.


📁 Project Files

utils.py

# Save as: utils.py
"""
Utility functions for token counting and context‑window management.
Works with any OpenAI‑compatible model.
"""

import tiktoken
from typing import List, Dict

def num_tokens_from_messages(messages: List[Dict[str, str]], model: str = "gpt-3.5-turbo") -> int:
 """
 Returns the number of tokens used by a list of messages.
 Mirrors the counting logic used by OpenAI's API.
 """
 try:
 encoding = tiktoken.encoding_for_model(model)
 except KeyError:
 encoding = tiktoken.get_encoding("cl100k_base")

 # Every message follows <im_start>{role/name}\n{content}<im_end>\n
 tokens_per_message = 4 # every message has 4 tokens of overhead
 tokens_per_name = -1 # if a name is present, we subtract 1 token

 num_tokens = 0
 for message in messages:
 num_tokens += tokens_per_message
 for key, value in message.items():
 num_tokens += len(encoding.encode(value))
 if key == "name":
 num_tokens += tokens_per_name
 num_tokens += 2 # every reply is primed with <im_start>assistant
 return num_tokens

def trim_messages(messages: List[Dict[str, str]],
 max_tokens: int,
 model: str = "gpt-3.5-turbo") -> List[Dict[str, str]]:
 """
 Trims the oldest *user/assistant* messages until the total token count
 fits within `max_tokens`. The system message (first entry) is never removed.
 """
 if not messages:
 return messages

 # Preserve the system message
 system_msg = messages[0]
 convo = messages[1:]

 while convo and num_tokens_from_messages([system_msg] + convo, model) > max_tokens:
 # Remove the oldest user‑assistant pair
 convo = convo[2:] # assumes messages alternate user/assistant
 return [system_msg] + convo

Expected output (when imported): No output; the module simply defines functions.

✅ Verify: Run python -c "import utils; print(utils.num_tokens_from_messages([{'role':'system','content':'You are a bot.'}]))" – it should print an integer (e.g., 7).


chat_agent.py

# Save as: chat_agent.py
"""
A minimal interactive chat agent that:
- Sets a system prompt (role definition)
- Keeps the conversation inside the model's context window
- Demonstrates token counting and automatic truncation
"""

import os
import sys
import json
import openai
from utils import num_tokens_from_messages, trim_messages

# ------------------------------------------------------------
# Configuration – replace with your own API key or set env var
# ------------------------------------------------------------
openai.api_key = os.getenv("OPENAI_API_KEY") # ⚠️ Ensure this env var is set

MODEL_NAME = "gpt-3.5-turbo"
MAX_TOKENS = 4096 # Model's context window
RESERVED_FOR_RESPONSE = 500 # Tokens we keep for the model's reply

SYSTEM_PROMPT = (
 "You are a professional sales assistant. "
 "Answer concisely, stay friendly, and always ask a clarifying question "
 "if the user's request is ambiguous."
)

def build_initial_messages() -> list:
 """Create the initial message list with the immutable system prompt."""
 return [{"role": "system", "content": SYSTEM_PROMPT}]

def chat_loop():
 messages = build_initial_messages()
 print("\n🤖 AI Sales Assistant – type 'exit' to quit.\n")
 while True:
 user_input = input("You: ").strip()
 if user_input.lower() in {"exit", "quit"}:
 print("👋 Goodbye!")
 break

 # Append user message
 messages.append({"role": "user", "content": user_input})

 # Ensure we stay within the context window
 max_allowed = MAX_TOKENS - RESERVED_FOR_RESPONSE
 if num_tokens_from_messages(messages, MODEL_NAME) > max_allowed:
 messages = trim_messages(messages, max_allowed, MODEL_NAME)

 # Call the OpenAI chat completion endpoint
 try:
 response = openai.ChatCompletion.create(
 model=MODEL_NAME,
 messages=messages,
 temperature=0.7,
 max_tokens=RESERVED_FOR_RESPONSE,
 n=1,
 stop=None,
 )
 except openai.error.OpenAIError as e:
 print(f"⚠️ API error: {e}")
 continue

 assistant_msg = response.choices[0].message["content"].strip()
 print(f"Assistant: {assistant_msg}\n")

 # Store assistant reply for future context
 messages.append({"role": "assistant", "content": assistant_msg})

if __name__ == "__main__":
 # Simple sanity check for the API key
 if not openai.api_key:
 sys.exit("⚠️ Please set the OPENAI_API_KEY environment variable.")
 chat_loop()

Sample Interaction

🤖 AI Sales Assistant – type 'exit' to quit.

You: I need a quick pitch for a new smartwatch.
Assistant: Absolutely! Our new smartwatch combines sleek design with cutting‑edge health tracking. It offers 24/7 heart‑rate monitoring, sleep analysis, and a battery life of up to 10 days—all on a vibrant, always‑on display. Would you like me to tailor the pitch for a specific audience?

You: Yes, for tech‑savvy millennials.
Assistant: Great! For tech‑savvy millennials, highlight the smartwatch’s seamless integration with popular fitness apps, customizable watch faces, and voice‑assistant support. Emphasize its sleek, minimalist aesthetic that pairs perfectly with both casual and professional attire. Want a one‑sentence tagline to cap it off?

You: exit
👋 Goodbye!

✅ Verify: 1. Install dependencies: pip install openai tiktoken. 2. Set your API key: export OPENAI_API_KEY=sk-.. 3. Run python chat_agent.py and follow the sample interaction above.


🚀 What You Just Built

  • System Prompt – a fixed instruction that never gets trimmed.
  • Dynamic Context Management – older turns are discarded automatically once the token budget is exceeded.
  • Token‑aware API Calls – we reserve space for the model’s reply, preventing “context length exceeded” errors.
  • Reusable Skeleton – swap MODEL_NAME for any OpenAI‑compatible model, adjust MAX_TOKENS, and you have a ready‑to‑go chat agent.

Common Mistakes

Mistake Why It Happens Fix
Forgetting to reserve tokens for the response max_tokens only limits the output, not the input. Subtract a safe margin (RESERVED_FOR_RESPONSE) from the total window before counting.
Truncating the system message trim_messages removes the first entry if not careful. Keep the system message separate (see build_initial_messages).
Assuming 1 token = 1 word Tokens can be sub‑words; “ChatGPT” → 2 tokens. Use tiktoken for accurate counting.
Hard‑coding the model name in helpers Different models have different tokenization schemes. Pass model as an argument (already done).

Troubleshooting

Symptom Likely Cause Remedy
InvalidRequestError: This model's maximum context length is 4096 tokens Context window overflow. Verify MAX_TOKENS - RESERVED_FOR_RESPONSE is large enough; increase RESERVED_FOR_RESPONSE or reduce conversation length.
AuthenticationError Missing or wrong API key. Export OPENAI_API_KEY correctly; double‑check the key string.
ImportError: No module named 'tiktoken' Dependency not installed. Run pip install tiktoken.
Assistant repeats the same answer over and over Context window is being trimmed too aggressively, losing recent user input. Decrease the amount of trimming (e.g., keep more recent pairs) or raise MAX_TOKENS if the model supports it.

Key Takeaways

  • LLMs are stateless; they only know what you feed them via the context window.
  • Prompt engineering (system messages) is the primary way to give an LLM a “role”.
  • Token limits are real; always count tokens and reserve space for the model’s reply.
  • A few helper functions (num_tokens_from_messages, trim_messages) make token‑aware chat agents trivial to build.
  • The pattern shown here scales to complex agents (tool use, tool‑calling, retrieval‑augmented generation) with minimal changes.

🧪 Try It Yourself

  1. Add a memory layer: Store the full conversation in a JSON file and reload it on startup.
  2. Introduce tool calling: When the user asks for a weather forecast, call a public API and inject the result into the next prompt.
  3. Experiment with temperature: Set temperature=0 for deterministic answers, temperature=1.2 for creative responses. Observe the difference.

Happy hacking! 🚀

Why This Matters

Large Language Models (LLMs) are brilliant at generating text, but on their own they lack:

  • Persistent memory – they forget everything once the context window is full.
  • Access to external tools – they cannot look up the current date, query a database, or call an API.

Turning an LLM into an AI agent gives you both: a short‑term “working memory” that persists across turns, and a toolbox of functions the model can invoke when it needs real‑world data. This is the foundation of production‑grade assistants, chat‑bots, and autonomous agents.


What You’ll Build

In this chapter you will create a minimal AI agent that can:

  1. Maintain conversation history (in‑memory memory).
  2. Call external tools via OpenAI’s function calling feature:
  3. get_current_date() – returns today’s date.
  4. search_policy(query) – looks up a short HR policy JSON file.
  5. Gracefully fall back to plain LLM generation when no tool is needed.

All code runs locally with the openai Python SDK – no additional services required.


Content

1. Project Overview

ai_agent/
├─ policies.json # tiny HR policy “knowledge base”
├─ tools.py # tool implementations + OpenAI function specs
├─ memory.py # simple conversation memory class
├─ agent.py # orchestrates the LLM, memory, and tools
└─ run.py # tiny REPL to talk to the agent

2. Install Dependencies

pip install openai==1.30.0 # latest stable at time of writing

Set your OpenAI API key as an environment variable:

export OPENAI_API_KEY="sk-."

3. Create the Knowledge Base (policies.json)

# Save as: policies.json
{
 "maternity_leave": {
 "eligibility": "All full‑time employees who have completed 12 months of service.",
 "duration": "12 weeks paid, followed by 4 weeks unpaid.",
 "procedure": "Submit a request to HR via the employee portal at least 30 days before the expected due date."
 },
 "remote_work": {
 "eligibility": "All employees after 6 months of tenure.",
 "max_days_per_month": 10,
 "approval": "Manager approval required via the internal ticketing system."
 }
}

✅ Verify: The file should be valid JSON and placed in the project root.

4. Define Tools & Function Specs (tools.py)

# Save as: tools.py
import json
from datetime import datetime
from pathlib import Path
> **What is PATH?** PATH is a list of folders your computer checks when you type a command. If you type `python`, your computer looks in each PATH folder for a file called `python.exe`. If it finds it, it runs it. If not, you get "command not found." Installing Python with "Add to PATH" checked adds Python's folder to this list.


from typing import Any, Dict, List

# ----------------------------------------------------------------------
# 1️⃣ Tool: Get Current Date
# ----------------------------------------------------------------------
def get_current_date() -> str:
 """Return today's date in ISO format (YYYY‑MM‑DD)."""
 return datetime.utcnow().date().isoformat()

# ----------------------------------------------------------------------
# 2️⃣ Tool: Search HR Policy
# ----------------------------------------------------------------------
_POLICY_PATH = Path(__file__).parent / "policies.json"
_POLICIES: Dict[str, Any] = json.loads(_POLICY_PATH.read_text())

def search_policy(topic: str) -> str:
 """
 Look up a short HR policy by *topic* (e.g., "maternity_leave").
 Returns a human‑readable paragraph or a not‑found message.
 """
 policy = _POLICIES.get(topic.lower())
 if not policy:
 return f"Sorry, I couldn't find a policy for '{topic}'."
 # Build a concise paragraph
 parts = [f"{k.capitalize()}: {v}" for k, v in policy.items()]
 return " ".join(parts)

# ----------------------------------------------------------------------
# 3️⃣ OpenAI Function Specifications (JSON schema)
# ----------------------------------------------------------------------
def get_function_definitions() -> List[Dict[str, Any]]:
 """Return the function specs that will be sent to the OpenAI API."""
 return [
 {
 "name": "get_current_date",
 "description": "Get today's date in ISO format.",
 "parameters": {
 "type": "object",
 "properties": {}
 }
 },
 {
 "name": "search_policy",
 "description": "Search a short HR policy by topic.",
 "parameters": {
 "type": "object",
 "properties": {
 "topic": {
 "type": "string",
 "description": "The policy topic, e.g., 'maternity_leave' or 'remote_work'."
 }
 },
 "required": ["topic"]
 }
 }
 ]

✅ Verify: Running python -c "import tools; print(tools.search_policy('maternity_leave'))" should print a paragraph about the maternity leave policy.

5. Simple In‑Memory Memory (memory.py)

# Save as: memory.py
from typing import List, Tuple

class ConversationMemory:
 """
 Stores a list of (role, content) tuples.
 Role is one of:'system', 'user', 'assistant', or 'function'.
 """
 def __init__(self, system_prompt: str):
 self.history: List[Tuple[str, str]] = [("system", system_prompt)]

 def add(self, role: str, content: str) -> None:
 self.history.append((role, content))

 def get_messages(self) -> List[dict]:
 """Return the history in the format expected by OpenAI's chat API."""
 return [{"role": role, "content": content} for role, content in self.history]

 def clear(self) -> None:
 """Reset to only the system prompt."""
 system_msg = self.history[0]
 self.history = [system_msg]

✅ Verify: Instantiate ConversationMemory and call get_messages() – you should see a list with a single system message.

6. The Agent Orchestrator (agent.py)

# Save as: agent.py
import json
import os
from typing import Any, Dict, List

import openai
from openai import OpenAI

from tools import get_function_definitions, get_current_date, search_policy
from memory import ConversationMemory

# ----------------------------------------------------------------------
# Configuration
# ----------------------------------------------------------------------
MODEL = "gpt-4o-mini" # cheap, function‑calling capable model
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))

SYSTEM_PROMPT = (
 "You are a helpful HR assistant. Use the provided tools when appropriate. "
 "If a user asks for a date, call `get_current_date`. "
 "If they ask about company policies, call `search_policy` with the correct topic. "
 "Otherwise answer directly."
)

# ----------------------------------------------------------------------
# Helper: Dispatch function calls from the model
# ----------------------------------------------------------------------
def _dispatch_function_call(name: str, arguments: Dict[str, Any]) -> str:
 if name == "get_current_date":
 return get_current_date()
 if name == "search_policy":
 return search_policy(arguments["topic"])
 raise ValueError(f"Unknown function: {name}")

# ----------------------------------------------------------------------
# Core Agent Class
# ----------------------------------------------------------------------
class AIAgent:
 def __init__(self):
 self.memory = ConversationMemory(SYSTEM_PROMPT)
 self.function_defs = get_function_definitions()

 def chat(self, user_input: str) -> str:
 # 1️⃣ Append user message
 self.memory.add("user", user_input)

 # 2️⃣ First LLM call – allow function calls
 response = client.chat.completions.create(
 model=MODEL,
 messages=self.memory.get_messages(),
 functions=self.function_defs,
 function_call="auto", # let model decide
 )
 message = response.choices[0].message

 # 3️⃣ Did the model request a function?
 if message.get("function_call"):
 func_name = message["function_call"]["name"]
 arguments = json.loads(message["function_call"]["arguments"])
 # Call the real function
 function_result = _dispatch_function_call(func_name, arguments)

 # 4️⃣ Append function result to memory
 self.memory.add("function", json.dumps({
 "name": func_name,
 "arguments": arguments,
 "result": function_result
 }))

 # 5️⃣ Second LLM call – let model generate final answer using result
 second_response = client.chat.completions.create(
 model=MODEL,
 messages=self.memory.get_messages(),
 functions=self.function_defs,
 # No function call this time; we just want a plain answer
 function_call="none",
 )
 final_msg = second_response.choices[0].message["content"]
 self.memory.add("assistant", final_msg)
 return final_msg

 # ------------------------------------------------------------------
 # No function needed – plain answer from the model
 # ------------------------------------------------------------------
 answer = message["content"]
 self.memory.add("assistant", answer)
 return answer

 def reset(self) -> None:
 """Clear conversation history (except system prompt)."""
 self.memory.clear()

✅ Verify:

>>> from agent import AIAgent
>>> bot = AIAgent()
>>> bot.chat("What is today's date?")
'2026-09-15' # (or whatever the current UTC date is)
>>> bot.chat("Tell me about the maternity leave policy.")
'Eligibility: All full‑time employees who have completed 12 months of service. Duration: 12 weeks paid, followed by 4 weeks unpaid. Procedure: Submit a request to HR via the employee portal at least 30 days before the expected due date.'

7. Interactive REPL (run.py)

# Save as: run.py
import sys
from agent import AIAgent

def main() -> None:
 print("🤖 HR Assistant – type 'exit' to quit, 'reset' to start a new conversation.")
 bot = AIAgent()
 while True:
 try:
 user_input = input("\nYou: ").strip()
 except (EOFError, KeyboardInterrupt):
 print("\nGoodbye!")
 break

 if user_input.lower() in {"exit", "quit"}:
 print("Goodbye!")
 break
 if user_input.lower() == "reset":
 bot.reset()
 print("🧹 Conversation cleared.")
 continue

 answer = bot.chat(user_input)
 print(f"\nAssistant: {answer}")

if __name__ == "__main__":
 main()

Run it:

python run.py

Sample Interaction

🤖 HR Assistant – type 'exit' to quit, 'reset' to start a new conversation.

You: What is today's date?

Assistant: 2026-09-15

You: Can you tell me the remote work policy?

Assistant: Eligibility: All employees after 6 months of tenure. Max_days_per_month: 10. Approval: Manager approval required via the internal ticketing system.

You: reset
🧹 Conversation cleared.

You: Who won the FIFA World Cup 2022?
Assistant: Argentina won the 2022 FIFA World Cup, defeating France in the final.

You: exit
Goodbye!

✅ Verify: The REPL should behave exactly as shown (dates will differ based on the current day).


📁 Project Files

File Purpose
policies.json Tiny HR policy knowledge base (JSON).
tools.py Real implementations of external tools + OpenAI function specs.
memory.py In‑memory conversation buffer that mimics a system prompt + user/assistant history.
agent.py Core orchestration: decides when to call a tool, updates memory, and returns the final answer.
run.py Simple command‑line interface for interactive testing.

🚀 What You Just Built

You now have a complete, runnable AI agent that:

  • Persists conversation context across turns (until you call reset).
  • Dynamically decides whether to invoke a tool based on the model’s function_call.
  • Retrieves structured data from a local JSON “knowledge base”.
  • Provides a clean REPL for rapid experimentation.

All of this is achieved with just 150 lines of Python and the OpenAI SDK – no external orchestration frameworks required.


Common Mistakes

Mistake Why It Happens Fix
Forgetting to json.loads the function arguments The model returns arguments as a JSON string. Always json.loads(message["function_call"]["arguments"]).
Returning a Python object instead of a string from a tool The LLM expects a plain string to embed in the next prompt. Ensure each tool returns a string (e.g., return str(.)).
Exceeding the model’s token limit Memory grows unbounded. Periodically reset() or implement a sliding‑window truncation (e.g., keep last N turns).
Using function_call="none" on the first request Prevents the model from ever calling a tool. Keep function_call="auto" on the first call; only set "none" on the follow‑up.
Mismatched function names between tools.py and the spec The model can’t find the function to call. Keep the name field in get_function_definitions() identical to the actual Python function.

Troubleshooting

Symptom Likely Cause Remedy
ValueError: Unknown function: … _dispatch_function_call missing a case. Add a branch for the new function name or correct the typo.
Empty assistant response Model returned a function call but you didn’t make the second API call. Ensure the second client.chat.completions.create is executed after adding the function result to memory.
InvalidRequestError: This model's maximum context length is X tokens Conversation grew too large. Call bot.reset() or implement a truncation strategy (e.g., keep only the last 10 messages).
JSONDecodeError when parsing arguments The model returned malformed JSON (rare). Wrap json.loads in a try/except and fallback to a helpful error message.
No tool is called even though a question clearly needs one Prompt does not encourage tool use. Strengthen the system prompt: “Always use search_policy when the user asks about any company policy.”

Key Takeaways

  • Function calling bridges the gap between pure text generation and real‑world actions.
  • A lightweight memory layer (list of messages) gives the illusion of stateful conversation.
  • Keep the knowledge base minimal – only feed the model the exact snippet it needs.
  • Always verify token usage; a full conversation can quickly exceed the model’s context window.

With these building blocks you can now expand the agent: add more tools (e.g., calendar lookup, ticket creation), swap the JSON store for a vector DB, or integrate LangChain for richer pipelines.


🧪 Try It Yourself

  1. Add a new tool that fetches the current weather from a public API (e.g., OpenWeatherMap).
  2. Define the function in tools.py.
  3. Add its spec to get_function_definitions().
  4. Extend _dispatch_function_call to call it.

  5. Persist memory to disk so the conversation survives a program restart.

  6. Serialize self.memory.history to a JSON file after each turn.
  7. Load it back in ConversationMemory.__init__ if the file exists.

  8. Replace the static JSON policy file with a simple vector store (e.g., faiss + sentence‑transformers).

  9. When search_policy receives a query, embed it, retrieve the most similar policy snippet, and return that.

Happy building! 🚀

Why This Matters

Modern LLMs are great at answering questions, but they don’t have direct access to your data or external services. An AI agent bridges that gap: it lets the model decide when to call a tool (e.g., Google Calendar, Gmail), feeds the tool’s result back into its own reasoning, and repeats until the user’s goal is satisfied.

Understanding the agent loop is essential for building assistants that can:

  • Search the web for up‑to‑date facts.
  • Read a user’s email to extract context.
  • Book meetings, create tasks, or run code on demand.

In this chapter you’ll build a minimal, fully‑runnable AI agent that can:

  1. Parse a natural‑language request.
  2. Detect that a calendar tool is needed.
  3. Call the tool with the correct parameters.
  4. Re‑evaluate the tool’s response and, if necessary, call the tool again.
  5. Return a final, human‑readable answer.

💡 Tip – Even though we’ll mock Google Calendar, the same pattern works with the real API (just replace the mock with the official client).


What You’ll Build

A single‑file Python program (agent.py) that:

  • Uses OpenAI’s function‑calling feature to let the LLM decide when and how to invoke a find_free_slot and create_event function.
  • Maintains an in‑memory calendar for two users (me and Priya).
  • Demonstrates the full agent loop: request → tool call → LLM re‑reason → final answer.

Content

1. Define the tool signatures (function schemas)

OpenAI expects a JSON schema that describes each callable function. We’ll expose two functions:

Function Purpose Parameters
find_free_slot Return a 1‑hour free window for both participants on a given day. date (YYYY‑MM‑DD), duration_minutes (int), participants (list of strings)
create_event Book the meeting in the in‑memory calendar. title (str), start_time (ISO‑8601), end_time (ISO‑8601), participants (list of strings)

2. Mock calendar data

A simple dictionary stores events per user. Each event is a tuple: (start_datetime, end_datetime, title).

3. The agent loop

while not done:
 # 1️⃣ Send user + any tool output back to the model
 # 2️⃣ If the model returns a function call → execute it
 # 3️⃣ Append the function’s result to the conversation
 # 4️⃣ If the model replies with a normal message → we’re finished

4. Putting it together

The script:

  1. Reads the user query from stdin.
  2. Starts the loop with the initial user message.
  3. Handles function calls automatically.
  4. Prints the final assistant message.

📁 Project Files

agent.py

# Save as: agent.py
"""
Minimal AI Agent that can schedule a meeting using a mock calendar.
It demonstrates the agent loop with OpenAI function calling.
"""

import os
import json
import datetime as dt
from typing import List, Tuple, Dict, Any

import openai # pip install openai

# ------------------------------------------------------------
# 1️⃣ Mock Calendar (in‑memory)
# ------------------------------------------------------------
CalendarDB: Dict[str, List[Tuple[dt.datetime, dt.datetime, str]]] = {
 "me": [], # Your own events
 "Priya": [] # Priya's events
}

def _print_calendar():
 """Utility to visualize the mock calendar (used in debugging)."""
 for user, events in CalendarDB.items():
 print(f"\n=== {user}'s Calendar ===")
 for start, end, title in events:
 print(f"{start.isoformat()} → {end.isoformat()} : {title}")

# ------------------------------------------------------------
# 2️⃣ Tool: find_free_slot
# ------------------------------------------------------------
def find_free_slot(date: str, duration_minutes: int,
 participants: List[str]) -> Dict[str, Any]:
 """
 Return the earliest free 1‑hour slot on `date` for all `participants`.
 If no slot exists, return an empty dict.
 """
 target_date = dt.datetime.strptime(date, "%Y-%m-%d").date()
 day_start = dt.datetime.combine(target_date, dt.time(9, 0)) # 9 AM
 day_end = dt.datetime.combine(target_date, dt.time(17, 0)) # 5 PM

 # Build a list of busy intervals for each participant
 busy: List[Tuple[dt.datetime, dt.datetime]] = []
 for p in participants:
 for ev_start, ev_end, _ in CalendarDB.get(p, []):
 if ev_start.date() == target_date:
 busy.append((ev_start, ev_end))

 # Sort and merge overlapping busy intervals
 busy.sort()
 merged: List[Tuple[dt.datetime, dt.datetime]] = []
 for start, end in busy:
 if not merged or start > merged[-1][1]:
 merged.append([start, end])
 else:
 merged[-1][1] = max(merged[-1][1], end)

 # Scan for a free slot
 cursor = day_start
 slot_delta = dt.timedelta(minutes=duration_minutes)

 for start, end in merged:
 if cursor + slot_delta <= start: # free before this busy block
 break
 cursor = max(cursor, end)

 # Final check against day_end
 if cursor + slot_delta > day_end:
 return {} # No slot found

 return {
 "start_time": cursor.isoformat(),
 "end_time": (cursor + slot_delta).isoformat()
 }

# ------------------------------------------------------------
# 3️⃣ Tool: create_event
# ------------------------------------------------------------
def create_event(title: str, start_time: str, end_time: str,
 participants: List[str]) -> Dict[str, Any]:
 """
 Insert an event into the mock calendar for every participant.
 Returns a confirmation dict.
 """
 start_dt = dt.datetime.fromisoformat(start_time)
 end_dt = dt.datetime.fromisoformat(end_time)

 for p in participants:
 CalendarDB.setdefault(p, []).append((start_dt, end_dt, title))

 return {
 "status": "ok",
 "event": {
 "title": title,
 "start_time": start_time,
 "end_time": end_time,
 "participants": participants
 }
 }

# ------------------------------------------------------------
# 4️⃣ OpenAI function schemas
# ------------------------------------------------------------
function_schemas = [
 {
 "name": "find_free_slot",
 "description": "Find the earliest free time slot for given participants on a specific date.",
 "parameters": {
 "type": "object",
 "properties": {
 "date": {
 "type": "string",
 "description": "Date in YYYY-MM-DD format."
 },
 "duration_minutes": {
 "type": "integer",
 "description": "Length of the meeting in minutes."
 },
 "participants": {
 "type": "array",
 "items": {"type": "string"},
 "description": "List of participant names (must match keys in CalendarDB)."
 }
 },
 "required": ["date", "duration_minutes", "participants"]
 }
 },
 {
 "name": "create_event",
 "description": "Create a calendar event with the given details.",
 "parameters": {
 "type": "object",
 "properties": {
 "title": {"type": "string"},
 "start_time": {"type": "string", "format": "date-time"},
 "end_time": {"type": "string", "format": "date-time"},
 "participants": {
 "type": "array",
 "items": {"type": "string"}
 }
 },
 "required": ["title", "start_time", "end_time", "participants"]
 }
 }
]

# ------------------------------------------------------------
# 5️⃣ Agent Loop
# ------------------------------------------------------------
def run_agent(user_query: str) -> str:
 """
 Executes the agent loop until the LLM returns a final answer.
 Returns the assistant's final message.
 """
 # Initial conversation
 messages = [
 {"role": "system",
 "content": "You are an AI assistant that can schedule meetings using the provided tools. "
 "When you need to call a tool, use the function calling feature. "
 "Only call a tool when it is necessary to achieve the user's goal."},
 {"role": "user", "content": user_query}
 ]

 while True:
 response = openai.ChatCompletion.create(
 model="gpt-4o-mini", # or any model that supports function calling
 messages=messages,
 functions=function_schemas,
 function_call="auto" # let the model decide
 )
 message = response["choices"][0]["message"]

 # ----------------------------------------------------
 # 5️⃣a If the model decided to call a function
 # ----------------------------------------------------
 if message.get("function_call"):
 func_name = message["function_call"]["name"]
 arguments = json.loads(message["function_call"]["arguments"])

 # Dispatch to the real Python function
 if func_name == "find_free_slot":
 result = find_free_slot(**arguments)
 elif func_name == "create_event":
 result = create_event(**arguments)
 else:
 raise RuntimeError(f"Unknown function {func_name}")

 # Append the function result to the conversation
 messages.append(message) # the model's function call request
 messages.append({
 "role": "function",
 "name": func_name,
 "content": json.dumps(result)
 })
 # Loop again – the model will now see the result and decide next steps
 continue

 # ----------------------------------------------------
 # 5️⃣b Normal assistant reply → we are done
 # ----------------------------------------------------
 final_answer = message["content"]
 return final_answer

# ------------------------------------------------------------
# 6️⃣ Entry point
# ------------------------------------------------------------
if __name__ == "__main__":
 # Ensure the OpenAI key is available
 if not os.getenv("OPENAI_API_KEY"):
 raise EnvironmentError("Set the OPENAI_API_KEY environment variable.")

 # Example query (you can replace it with any other request)
 query = "Find a suitable 1 hour slot tomorrow for me and Priya and schedule a meeting titled 'Project Sync'."
 answer = run_agent(query)

 print("\n🤖 Assistant:", answer)
 _print_calendar() # Show the mock calendar after the run

Expected output (when run on 2026‑09‑15)

🤖 Assistant: I’ve scheduled “Project Sync” on 2026-09-16 from 09:00 to 10:00 for you and Priya.

=== me's Calendar ===
2026-09-16T09:00:00+00:00 → 2026-09-16T10:00:00+00:00 : Project Sync

=== Priya's Calendar ===
2026-09-16T09:00:00+00:00 → 2026-09-16T10:00:00+00:00 : Project Sync

⚠️ Warning – The mock calendar assumes the day starts at 9 AM and ends at 5 PM UTC. Adjust day_start / day_end if you need a different working window.

✅ Verify: Run python agent.py after setting OPENAI_API_KEY. The assistant should print a confirmation and the two calendars should contain the new event.


🚀 What You Just Built

Component Role
find_free_slot Searches the in‑memory calendar for a common free window.
create_event Persists the meeting once a suitable slot is found.
Agent Loop Sends the user request to the LLM, lets the model decide when to call a tool, feeds the tool’s JSON result back, and repeats until a natural‑language answer is produced.
Function schemas Describe each tool to the LLM so it can generate correct JSON arguments.

The loop mimics a human assistant: understand → act → verify → act again if needed.


Common Mistakes

Mistake Why it Happens Fix
Missing function_call="auto" The model will never invoke a tool, returning only a plain answer. Ensure function_call="auto" (or "none" when you explicitly don’t want calls).
Incorrect JSON schema The LLM can’t generate valid arguments, leading to a parsing error. Validate the schema with a JSON validator; keep property names simple (snake_case).
Time‑zone confusion datetime.fromisoformat interprets naïve strings as local time, causing mismatched slots. Use UTC (+00:00) or explicitly attach a timezone (datetime.timezone.utc).
Infinite loop The model keeps calling a function that returns an empty dict, never reaching a final answer. Add a safety counter (max_iterations) and return a graceful fallback message.
Hard‑coding the model name Some deployments may only have gpt-3.5-turbo-1106. Parameterise the model name or read it from an environment variable.

Troubleshooting

  1. openai.error.InvalidRequestError: This model's maximum context length is. Cause: The conversation (including function results) exceeds the model’s token limit. Fix: Summarise older messages or truncate the calendar data before appending it.

  2. json.decoder.JSONDecodeError Cause: The LLM returned malformed JSON. Fix: Wrap the json.loads call in a try/except and ask the model to re‑format the arguments.

  3. No meeting is created Cause: The model decided the request was ambiguous. Fix: Provide a clearer user prompt, e.g., include the meeting title and exact duration.

  4. OPENAI_API_KEY not set Cause: Environment variable missing. Fix: export OPENAI_API_KEY=sk-. on Unix or set it in PowerShell ($env:OPENAI_API_KEY = "sk-.").

✅ Verify: After fixing any issue, re‑run python agent.py. The assistant should now schedule the meeting and display the calendars.


Key Takeaways

  • Agent Loop – The core pattern: LLM → (optional) tool call → result → LLM repeats until the goal is met.
  • Function calling gives the model a structured, safe way to invoke external services.
  • Context window is the only place the LLM “sees” data; feeding tool results back is how you extend its knowledge.
  • Mocking external APIs lets you develop and test locally; swapping in real APIs only requires changing the function bodies.

🧪 Try It Yourself

  1. Add a third participant (Alex) with a pre‑existing event on the same day. Modify CalendarDB before the loop:

python CalendarDB["Alex"] = [ (dt.datetime(2026, 9, 16, 9, 30), dt.datetime(2026, 9, 16, 11, 0), "Team Standup") ]

  1. Change the user query to include Alex:

python query = "Find a suitable 1 hour slot tomorrow for me, Priya, and Alex and schedule a meeting titled 'Design Review'."

  1. Run the script and observe how the agent now picks a later slot (e.g., 11 AM‑12 PM).

  2. Experiment with different durations (duration_minutes) and dates to see the loop adapt.

Happy building! 🎉


What's Next

Continue to the next chapter to keep building.

Chapter 7

How Agents Reason, Remember, and Act

Why This Matters


Before You Start This Chapter

  • Your venv is active (you see `(.

    What is a virtual environment (venv)? It is a separate, isolated Python world for your project. Without it, packages from different projects can conflict. With a venv, each project gets its own clean set of packages. Think of it like a separate browser profile for each website.

venv)in your prompt) - Previous chapter's project is working - Your.env` file has your API key

What is an API? An API (Application Programming Interface) is how one program talks to another. Think of it like a waiter in a restaurant — you (your code) tell the waiter (API) what you want, the waiter goes to the kitchen (the server), and brings back your food (the response). You never talk to the kitchen directly.

  • You are in the correct folder (run pwd to check)

Get Your OpenAI API Key

  1. Go to https://platform.openai.com/api-keys
  2. Log in (free account)
  3. Click "Create new secret key"
  4. Name it (e.g., "adhikk")
  5. Click "Create"
  6. Copy the key (starts with sk-) — you will not see it again
  7. Paste into your .env file

New accounts get $5 free credit — enough for this entire book.

Large Language Models (LLMs) are fantastic at generating text, but real‑world applications need them to act—book a flight, fetch a price, or write a file. When you give an LLM access to tools (APIs, functions, databases) and a memory of past interactions, it becomes an AI Agent that can:

  • Decide what to do (which tool to call).
  • Decide when to do it (loop until the goal is satisfied).
  • Keep track of what it has learned (memory) across calls.

Understanding this workflow lets you build assistants that go beyond chat—think travel planners, code generators, or automated help desks.


What You’ll Build

A Travel Planner Agent that can:

  1. Parse a user request like “Plan a two‑day trip to Jaipur with a budget of $300”.
  2. Call tool functions (flight search, hotel search, budget check) as needed.
  3. Store intermediate results in a memory buffer.
  4. Loop until it can produce a complete itinerary or ask the user for confirmation.

All code runs locally with the OpenAI gpt-3.5-turbo model (or any compatible API). No external services are required beyond the OpenAI endpoint.


Content

1. Core Concepts Recap

Concept Analogy Role in an Agent
LLM Brain Generates text & decides next action.
Context Window Desk Holds the prompt, tool results, and memory for the LLM.
Tools Hands/Eyes/Ears Functions the agent can invoke (e.g., search_flights).
Memory Notepad Persistent list of past observations that the agent can reference.
Loop Thought‑Process Cycle Re‑evaluate after each tool call until the goal is met.

2. Project Overview

travel_agent/
│
├─ # Save as: tools.py
│ └─ Functions that simulate external services.
│
├─ # Save as: memory.py
│ └─ Simple in‑memory store for observations.
│
├─ # Save as: agent.py
│ └─ The orchestrator that talks to the LLM, decides which tool to call,
│ updates memory, and loops.
│
└─ # Save as: run.py
 └─ Entry point that starts the conversation.

3. Implementing the Tools

# Save as: tools.py
import random
from typing import Dict, Any

def search_flights(origin: str, destination: str, date: str) -> Dict[str, Any]:
 """Mock flight search – returns a random price."""
 price = random.randint(50, 150)
 return {
 "origin": origin,
 "destination": destination,
 "date": date,
 "price_usd": price,
 "airline": random.choice(["AirAlpha", "SkyJet", "Nimbus"])
 }

def search_hotels(city: str, check_in: str, nights: int) -> Dict[str, Any]:
 """Mock hotel search – returns a random nightly rate."""
 nightly = random.randint(30, 80)
 total = nightly * nights
 return {
 "city": city,
 "check_in": check_in,
 "nights": nights,
 "nightly_usd": nightly,
 "total_usd": total,
 "hotel_name": random.choice(["Grand Palace", "City Inn", "Heritage Stay"])
 }

def check_budget(total_cost: int, budget: int) -> bool:
 """Simple budget validator."""
 return total_cost <= budget

Expected output (when called directly):

>>> from tools import search_flights
>>> search_flights("DEL", "JAI", "2024-10-01")
{'origin': 'DEL', 'destination': 'JAI', 'date': '2024-10-01', 'price_usd': 112, 'airline': 'Nimbus'}

✅ Verify: Run the snippet above in a Python REPL; you should see a dictionary with a random price.

4. Memory Module

# Save as: memory.py
from typing import List, Dict

class Memory:
 """A very small in‑memory store that the agent can read/write."""
 def __init__(self):
 self._store: List[Dict] = []

 def add(self, entry: Dict) -> None:
 """Append a new observation."""
 self._store.append(entry)

 def get_all(self) -> List[Dict]:
 """Return a copy of all stored entries."""
 return list(self._store)

 def __repr__(self) -> str:
 return f"<Memory entries={len(self._store)}>"

✅ Verify:

>>> from memory import Memory
>>> mem = Memory()
>>> mem.add({"type": "flight", "price_usd": 120})
>>> mem.get_all()
[{'type': 'flight', 'price_usd': 120}]

5. The Agent Core

# Save as: agent.py
import json
import os
from typing import Any, Dict, List

import openai # pip install openai
from tools import search_flights, search_hotels, check_budget
from memory import Memory

# -------------------------------------------------
# Configuration – replace with your own key or set env var OPENAI_API_KEY
# -------------------------------------------------
openai.api_key = os.getenv("OPENAI_API_KEY", "sk-.") # <-- put your key here

# -------------------------------------------------
# Helper: Build the function specifications for OpenAI function calling
# -------------------------------------------------
TOOL_DEFINITIONS = [
 {
 "name": "search_flights",
 "description": "Find a flight between two cities on a given date.",
 "parameters": {
 "type": "object",
 "properties": {
 "origin": {"type": "string", "description": "IATA code of departure city"},
 "destination": {"type": "string", "description": "IATA code of arrival city"},
 "date": {"type": "string", "description": "Travel date in YYYY-MM-DD"},
 },
 "required": ["origin", "destination", "date"],
 },
 },
 {
 "name": "search_hotels",
 "description": "Find a hotel in a city for a given stay.",
 "parameters": {
 "type": "object",
 "properties": {
 "city": {"type": "string"},
 "check_in": {"type": "string", "description": "YYYY-MM-DD"},
 "nights": {"type": "integer"},
 },
 "required": ["city", "check_in", "nights"],
 },
 },
 {
 "name": "check_budget",
 "description": "Validate that total cost does not exceed the user's budget.",
 "parameters": {
 "type": "object",
 "properties": {
 "total_cost": {"type": "integer"},
 "budget": {"type": "integer"},
 },
 "required": ["total_cost", "budget"],
 },
 },
]

# -------------------------------------------------
# Agent class
# -------------------------------------------------
class TravelAgent:
 def __init__(self, model: str = "gpt-3.5-turbo-0613"):
 self.model = model
 self.memory = Memory()
 self.messages: List[Dict] = [] # conversation history for the LLM

 def _add_user_message(self, content: str) -> None:
 self.messages.append({"role": "user", "content": content})

 def _call_llm(self) -> Dict:
 """Ask the LLM to either answer or request a tool."""
 response = openai.ChatCompletion.create(
 model=self.model,
 messages=self.messages,
 functions=TOOL_DEFINITIONS,
 function_call="auto", # let the model decide
 )
 return response["choices"][0]["message"]

 def _dispatch_tool(self, name: str, arguments: str) -> Dict:
 args = json.loads(arguments)
 if name == "search_flights":
 result = search_flights(**args)
 elif name == "search_hotels":
 result = search_hotels(**args)
 elif name == "check_budget":
 result = {"within_budget": check_budget(**args)}
 else:
 raise ValueError(f"Unknown tool {name}")
 # Store in memory
 self.memory.add({"tool": name, "arguments": args, "result": result})
 return result

 def run(self, user_query: str) -> str:
 """Main loop – returns the final human‑readable answer."""
 self._add_user_message(user_query)

 while True:
 llm_msg = self._call_llm()

 # 1️⃣ LLM decided to call a tool
 if llm_msg.get("function_call"):
 func_name = llm_msg["function_call"]["name"]
 arguments = llm_msg["function_call"]["arguments"]
 tool_result = self._dispatch_tool(func_name, arguments)

 # Feed the tool result back to the model
 self.messages.append({
 "role": "assistant",
 "content": None,
 "function_call": llm_msg["function_call"],
 })
 self.messages.append({
 "role": "function",
 "name": func_name,
 "content": json.dumps(tool_result),
 })
 continue # go back to the top of the loop

 # 2️⃣ LLM produced a final answer
 final_answer = llm_msg["content"]
 return final_answer

What the code does

  1. Prompt → LLM – Sends the conversation (including memory) to the model.
  2. Function Call? – If the model returns a function_call, we invoke the corresponding Python function.
  3. Result → LLM – The result is added as a function message, letting the model see the observation.
  4. Loop – The model may call another tool or finally answer.

✅ Verify:

>>> from agent import TravelAgent
>>> agent = TravelAgent()
>>> answer = agent.run("Plan a two‑day trip to Jaipur with a budget of $300")
>>> print(answer)

You should see a concise itinerary, e.g.:

Here is your Jaipur itinerary within $300:
- Flight: DEL → JAI on 2024-10-01, $112 (AirAlpha)
- Hotel: Grand Palace, 2 nights, $140 total
Total cost: $252. Let me know if you’d like me to book these for you.

6. Entry Point

# Save as: run.py
from agent import TravelAgent

def main():
 print("🧳 Travel Planner Agent – type 'exit' to quit.")
 agent = TravelAgent()
 while True:
 user_input = input("\nYou: ")
 if user_input.lower() in {"exit", "quit"}:
 break
 answer = agent.run(user_input)
 print("\nAgent:", answer)

if __name__ == "__main__":
 main()

Run it:

python run.py

Sample interaction

🧳 Travel Planner Agent – type 'exit' to quit.

You: Plan a two-day trip to Jaipur with a budget of $300

Agent: Here is your Jaipur itinerary within $300:
- Flight: DEL → JAI on 2024-10-01, $112 (AirAlpha)
- Hotel: Grand Palace, 2 nights, $140 total
Total cost: $252. Let me know if you’d like me to book these for you.

✅ Verify: The conversation above should appear exactly as shown (prices will vary due to randomness, but total must stay ≤ 300).


📁 Project Files

File Purpose
tools.py Mock external services (flight, hotel, budget).
memory.py Simple in‑memory store for observations.
agent.py Core LLM‑agent loop, function‑calling logic.
run.py User‑facing CLI to start the agent.

All files are self‑contained; just place them in a folder and run python run.py.


🚀 What You Just Built

  • An autonomous AI agent that decides what tool to invoke, when to invoke it, and how to combine results.
  • A deterministic memory buffer that records every tool call, enabling the LLM to “remember” past observations.
  • A reusable loop that can be extended with more tools (e.g., weather API, calendar integration) without touching the core logic.

Common Mistakes

Mistake Why it Happens Fix
Forgetting to add the function result back to messages The LLM never sees the tool output, so it keeps calling the same tool. Always append a function role message after a tool call.
Using the wrong model version Older models (gpt-3.5-turbo) don’t support function calling. Use gpt-3.5-turbo-0613 or newer, or gpt-4-0613.
Hard‑coding the API key in code Accidentally committing secrets. Load from environment variable OPENAI_API_KEY.
Memory grows without limit Long sessions can exceed the context window. Periodically summarize memory or prune old entries.
Tool arguments mismatch JSON parsing error when the model sends unexpected fields. Validate arguments before calling the real function; raise a clear error.

Troubleshooting

Symptom Likely Cause Remedy
openai.error.InvalidRequestError: This model's maximum context length is 4096 tokens Conversation + tool results exceed the limit. Summarize earlier steps, or truncate self.messages to the most recent N entries.
KeyError: 'function_call' The model returned a plain answer when you expected a tool call. Ensure the prompt clearly asks the model to use a tool, or add a fallback check.
No output from search_flights Random price generation returned a value but you didn’t print it. Verify you’re printing the final answer from agent.run.
AuthenticationError Missing or wrong API key. Set export OPENAI_API_KEY=sk-. before running, or edit agent.py.

Key Takeaways

  • LLM = Brain, Context Window = Desk, Tools = Hands/Eyes, Memory = Notepad.
  • An agent is just a loop: interpret → decide → act → observe → repeat.
  • Function calling lets the LLM invoke real Python code safely and deterministically.
  • Keep memory lightweight and prompt engineering clear to guide the model toward the right tool.

🧪 Try It Yourself

  1. Add a new tool – e.g., search_restaurants(city) that returns a random restaurant list.
  2. Update TOOL_DEFINITIONS in agent.py with the new function spec.
  3. Modify the prompt (inside run.py or by adding a system message) to ask the agent to suggest dinner options.
# Example addition to tools.py
def search_restaurants(city: str) -> dict:
 return {
 "city": city,
 "restaurants": random.sample(
 ["Spice Route", "Maharaja", "Royal Dine", "Curry House"], k=2
 ),
 }

Run the agent again and ask:

You: I also want dinner recommendations for Jaipur.

Observe how the agent automatically calls search_restaurants and incorporates the result into its reply.

Happy building! 🚀

Why This Matters

Modern AI assistants are no longer just “chat‑bots” that spit out text. They can call external tools—read files, run calculations, query databases—so they act like real assistants that do work for you. Understanding how to wire an LLM to function calls and expose it through a front‑end UI is the foundation for every AI‑powered product you’ll build tomorrow (customer support bots, data‑analysis assistants, internal knowledge bases, …).


What You’ll Build

A single‑page Streamlit app that lets a user:

  1. Upload a résumé (plain‑text .txt file).
  2. Ask a natural‑language question about the résumé (e.g., “How many years of Python experience do I have?”).
  3. The app’s LLM agent will:
  4. Call a read‑resume tool to extract the raw text.
  5. Call a calculate‑experience tool to compute the answer.
  6. Display the final answer in the UI.

All the heavy lifting (LLM, function calling, tool implementation) lives in pure Python—no extra research required.


Content

1️⃣ Architecture Overview

┌─────────────────────┐
│ Streamlit UI │ ← User uploads résumé & asks question
└─────────┬───────────┘
 │
 ▼
┌─────────────────────┐
│ agent.py │ ← LLM + OpenAI function‑calling logic
│ - decides which │
│ tool to call │
│ - parses tool │
│ results │
└───────▲─────┬───────┘
 │ │
 │ ▼
 │ ┌─────────────────────┐
 │ │ tools.py │
 │ │ - read_resume() │
 │ │ - calc_experience()│
 │ └─────────────────────┘
 │
 ▼
 OpenAI API (gpt‑3.5‑turbo‑1106)

2️⃣ Core Concepts

Concept What It Means Why It’s Important
Tokens Smallest text unit the model processes (≈ 4 characters). Determines cost & context‑window limits.
Context Window How many tokens the model can “see” at once (≈ 16 k for gpt‑3.5‑turbo‑1106). Bigger windows → longer conversations without losing earlier info.
Function Calling LLM can request the backend to run a predefined function and return structured data. Turns a pure language model into an agent that can act on the world.
Short‑term vs Long‑term Memory Short‑term = context window; Long‑term = external vector store (RAG). For this chapter we only need short‑term, but the pattern scales.
Front‑end vs Back‑end UI (Streamlit) vs logic (agent + tools). Clean separation makes the app maintainable.

📁 Project Files

tools.py – Helper functions that the LLM can call

# Save as: tools.py
import re
from pathlib import Path
> **What is PATH?** PATH is a list of folders your computer checks when you type a command. If you type `python`, your computer looks in each PATH folder for a file called `python.exe`. If it finds it, it runs it. If not, you get "command not found." Installing Python with "Add to PATH" checked adds Python's folder to this list.


from typing import Dict

def read_resume(file_path: str) -> Dict[str, str]:
 """
 Reads a plain‑text résumé and returns its content.
 The LLM expects a JSON‑compatible dict.
 """
 text = Path(file_path).read_text(encoding="utf-8")
 return {"content": text}

def calc_experience(resume_text: str, language: str = "Python") -> Dict[str, int]:
 """
 Very naive experience calculator:
 - Looks for lines like "Python – 2015‑2021"
 - Returns the total number of years for the given language.
 """
 # Regex to capture year ranges (e.g., 2015-2021 or 2018‑present)
 pattern = rf"{language}\s*[-–]\s*(\d{{4}})\s*[-–]\s*(\d{{4}}|present|now)"
 matches = re.findall(pattern, resume_text, flags=re.IGNORECASE)

 total_years = 0
 for start, end in matches:
 start_year = int(start)
 if end.lower() in {"present", "now"}:
 end_year = 2026 # current year for demo purposes
 else:
 end_year = int(end)
 total_years += max(0, end_year - start_year)

 return {"years": total_years}

Expected output (when called directly):

>>> from tools import read_resume, calc_experience
>>> data = read_resume("sample_resume.txt")
>>> data
{'content': 'John Doe\nPython – 2015‑2021\n.'}
>>> calc_experience(data["content"], language="Python")
{'years': 6}

✅ Verify: Run the snippet above in a Python REPL; it should print the dictionaries shown.


agent.py – LLM agent that decides which tool to invoke

# Save as: agent.py
import json
import os
from typing import Any, Dict, List

import openai

# -------------------------------------------------
# Set your OpenAI API key as an environment variable:
# export OPENAI_API_KEY="sk-."
# -------------------------------------------------
openai.api_key = os.getenv("OPENAI_API_KEY")

# Define the function schemas that the model can call
FUNCTIONS = [
 {
 "name": "read_resume",
 "description": "Read a résumé file and return its raw text.",
 "parameters": {
 "type": "object",
 "properties": {
 "file_path": {
 "type": "string",
 "description": "Absolute or relative path to the résumé file."
 }
 },
 "required": ["file_path"],
 },
 },
 {
 "name": "calc_experience",
 "description": "Calculate years of experience for a given programming language from résumé text.",
 "parameters": {
 "type": "object",
 "properties": {
 "resume_text": {
 "type": "string",
 "description": "Full résumé text extracted by read_resume."
 },
 "language": {
 "type": "string",
 "description": "Programming language to look for (e.g., Python).",
 "default": "Python"
 },
 },
 "required": ["resume_text"],
 },
 },
]

# -----------------------------------------------------------------
# Core agent logic – single round of function calling
# -----------------------------------------------------------------
def ask_agent(user_question: str, resume_path: str) -> str:
 """
 Sends the user question to the LLM. If the model decides to call a function,
 we execute it locally, feed the result back, and finally return the model's answer.
 """
 # 1️⃣ First request – let the model decide if it needs a tool
 response = openai.ChatCompletion.create(
 model="gpt-3.5-turbo-1106",
 messages=[{"role": "user", "content": user_question}],
 functions=FUNCTIONS,
 function_call="auto", # let the model choose
 )
 message = response["choices"][0]["message"]

 # -------------------------------------------------
 # Did the model request a function?
 # -------------------------------------------------
 if message.get("function_call"):
 func_name = message["function_call"]["name"]
 arguments = json.loads(message["function_call"]["arguments"])

 # 2️⃣ Execute the requested function
 if func_name == "read_resume":
 from tools import read_resume
 result = read_resume(arguments["file_path"])
 elif func_name == "calc_experience":
 from tools import calc_experience
 result = calc_experience(
 resume_text=arguments["resume_text"],
 language=arguments.get("language", "Python")
 )
 else:
 raise ValueError(f"Unsupported function: {func_name}")

 # 3️⃣ Send the function result back to the model
 follow_up = openai.ChatCompletion.create(
 model="gpt-3.5-turbo-1106",
 messages=[
 {"role": "user", "content": user_question},
 message, # the function call request
 {
 "role": "function",
 "name": func_name,
 "content": json.dumps(result),
 },
 ],
 )
 final_answer = follow_up["choices"][0]["message"]["content"]
 return final_answer.strip()
 else:
 # Model answered directly without needing a tool
 return message["content"].strip()

Quick sanity check (run in a Python shell):

>>> from agent import ask_agent
>>> answer = ask_agent(. "How many years of Python experience does my résumé show?",. resume_path="sample_resume.txt". )
>>> print(answer)
Your résumé shows about 6 years of Python experience.

✅ Verify: The printed sentence should match the example above.


app.py – Streamlit front‑end

# Save as: app.py
import os
import tempfile
import streamlit as st

from agent import ask_agent

st.set_page_config(page_title="Résumé Assistant", page_icon="🧑‍💻")

st.title("🧑‍💻 Résumé Assistant")
st.write(
 "Upload a plain‑text résumé and ask any question about it. "
 "The assistant will use an LLM + tool‑calling to give you an answer."
)

# -------------------------------------------------
# 1️⃣ Upload résumé
# -------------------------------------------------
uploaded_file = st.file_uploader(
 "📄 Upload your résumé (TXT only)",
 type=["txt"],
 help="The file must be plain text. PDF/Docx conversion is out of scope for this demo."
)

if uploaded_file:
 # Save to a temporary location so the LLM agent can read it
 with tempfile.NamedTemporaryFile(delete=False, suffix=".txt") as tmp:
 tmp.write(uploaded_file.getvalue())
 resume_path = tmp.name

 st.success("✅ Résumé uploaded successfully!")

 # -------------------------------------------------
 # 2️⃣ Ask a question
 # -------------------------------------------------
 question = st.text_input(
 "❓ What would you like to know?",
 placeholder="e.g., How many years of Python experience do I have?"
 )

 if st.button("🚀 Get Answer") and question:
 with st.spinner("Thinking."):
 answer = ask_agent(question, resume_path)
 st.markdown("**Answer:**")
 st.write(answer)

 # Clean up the temporary file after the session ends
 def _cleanup():
 try:
 os.remove(resume_path)
 except OSError:
 pass

 st.experimental_singleton.clear() # Force cleanup on rerun
 st.on_cleanup(_cleanup)
else:
 st.info("👈 Please upload a résumé to begin.")

Running the app

$ streamlit run app.py

A browser window opens at http://localhost:8501. Upload sample_resume.txt, type a question, and click Get Answer.

Expected UI flow & output

  1. Upload → “✅ Résumé uploaded successfully!”
  2. Enter question → Click → Spinner →
  3. Final answer appears, e.g.:
**Answer:**
Your résumé shows about 6 years of Python experience.

✅ Verify: Follow the steps above; the answer should reflect the data in your résumé file.


🚀 What You Just Built

Piece Role
tools.py Pure Python utilities that the LLM can invoke (read file, compute years).
agent.py Glue between the OpenAI API and your tools – decides when and what to call.
app.py Minimal Streamlit UI that ties everything together for a non‑technical user.
OpenAI function‑calling Turns a text‑only model into an agent that can act on external data.

You now have a complete, end‑to‑end AI assistant that:

  • Accepts user input via a web UI.
  • Dynamically decides which backend tool to run.
  • Returns a natural‑language answer that incorporates real data.

Common Mistakes

Mistake Why It Happens Fix
Missing OPENAI_API_KEY The OpenAI client silently fails. Export the key before running: export OPENAI_API_KEY="sk-.".
Uploading a non‑TXT file read_resume expects plain text. Convert PDFs/Docs to .txt first, or add a conversion step.
Function schema mismatch The model sends arguments that don’t match the schema, causing a JSON parse error. Keep the schema simple; ensure required fields are present.
Context‑window overflow Very large résumés (>16 k tokens) cause the model to truncate. Summarize or chunk the résumé before passing to the model.
Leaving temporary files tempfile.NamedTemporaryFile(delete=False) creates files that linger. Use st.on_cleanup (as shown) or a context manager.

Troubleshooting

Symptom Likely Cause Quick Fix
openai.error.AuthenticationError API key not set or invalid. Verify echo $OPENAI_API_KEY and that the key has the correct permissions.
json.decoder.JSONDecodeError The model returned malformed arguments. Print message["function_call"]["arguments"] before json.loads to inspect; adjust schema if needed.
No answer returned The model decided not to call any function and gave a vague reply. Provide a more explicit prompt, e.g., “Please read the résumé and tell me the years of Python experience.”
Streamlit UI freezes Long‑running LLM request (network latency). Increase timeout: openai.timeout = 30 or show a spinner (already in code).
File not found Temporary file was deleted before the agent accessed it. Ensure the file path is passed correctly and not removed prematurely.

Key Takeaways

  • Function calling transforms a static LLM into an agent that can interact with the real world.
  • Keep tool signatures simple and JSON‑serializable; the model can only work with primitive types.
  • Separate concerns: UI (Streamlit) ↔ Agent (LLM + decision logic) ↔ Tools (pure Python).
  • Always verify each component in isolation before wiring them together.

🧪 Try It Yourself

  1. Add a new tool – e.g., extract_email(resume_text) that returns the first email address found.
  2. Update FUNCTIONS in agent.py with the new schema.
  3. Ask the assistant: “What is my email address?”

Bonus: Replace gpt-3.5-turbo-1106 with gpt-4o-mini for cheaper, faster responses (adjust the model name in agent.py).

Happy hacking! 🚀

Why This Matters

Deploying your code turns a local experiment into a real‑world service that anyone on the internet can use—24 hours a day, 7 days a week. - Reach: Your friends, coworkers, or customers can interact with your AI agent from any device. - Reliability: A cloud server never sleeps (unless you shut it down), so the service is always available. - Scalability: Cloud platforms can automatically allocate more resources when traffic spikes.

In this chapter you’ll learn how to take the FastAPI‑based ChatGPT proxy you built locally and push it to the cloud with Docker and Render (a free‑tier PaaS). By the end you’ll have a live URL that anyone can call.


What You’ll Build

A production‑ready container that runs a FastAPI app exposing a single endpoint:

POST https://<your‑app>.onrender.com/chat
{
 "message": "Tell me a joke"
}

The service will:

  1. Accept a JSON payload with a user message.
  2. Forward the message to OpenAI’s gpt-3.5-turbo model.
  3. Return the model’s reply as JSON.

All secrets (your OpenAI API key) are stored securely in Render’s environment variables, never hard‑coded.


Content

1️⃣ Create a Fresh Git Repository

mkdir gpt‑proxy‑render
cd gpt‑proxy‑render
git init

2️⃣ Write the FastAPI Application

# Save as: main.py
import os
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import openai
import uvicorn

# Load the API key from environment (Render injects this)
openai.api_key = os.getenv("OPENAI_API_KEY")
if not openai.api_key:
 raise RuntimeError("OPENAI_API_KEY not set in environment")

app = FastAPI(title="ChatGPT Proxy")

class ChatRequest(BaseModel):
 message: str

class ChatResponse(BaseModel):
 reply: str

@app.post("/chat", response_model=ChatResponse)
async def chat_endpoint(req: ChatRequest):
 try:
 # Call OpenAI Chat Completion API
 response = await openai.ChatCompletion.acreate(
 model="gpt-3.5-turbo",
 messages=[{"role": "user", "content": req.message}],
 temperature=0.7,
 )
 reply = response.choices[0].message.content.strip()
 return ChatResponse(reply=reply)
 except openai.error.OpenAIError as e:
 raise HTTPException(status_code=502, detail=str(e))

# Development entry point (Render will use `gunicorn` instead)
if __name__ == "__main__":
 uvicorn.run(app, host="0.0.0.0", port=8000)

Expected output (local test):

$ curl -X POST http://127.0.0.1:8000/chat -H "Content-Type: application/json" -d '{"message":"Tell me a joke"}'
{"reply":"Why did the scarecrow win an award? Because he was outstanding in his field!"}

✅ Verify: Run uvicorn main:app --reload locally, send the curl request above, and confirm you receive a JSON reply.

3️⃣ Define Dependencies

# Save as: requirements.txt
fastapi==0.110.0
uvicorn[standard]==0.27.0
openai==1.12.0
pydantic==2.6.1

✅ Verify: pip install -r requirements.txt completes without errors.

4️⃣ Dockerize the Application

# Save as: Dockerfile
# Use the official lightweight Python image
FROM python:3.12-slim

# Set a non‑root user for security
RUN useradd -m appuser
WORKDIR /app
COPY requirements.txt.
RUN pip install --no-cache-dir -r requirements.txt

# Copy source code
COPY main.py.

# Switch to non‑root user
USER appuser

# Expose the port FastAPI will run on
EXPOSE 8000

# Use gunicorn with uvicorn workers (production‑ready)
CMD ["gunicorn", "-k", "uvicorn.workers.UvicornWorker", "main:app", "--bind", "0.0.0.0:8000"]

✅ Verify: Build and run locally.

docker build -t gpt-proxy.
docker run -d -p 8000:8000 -e OPENAI_API_KEY=sk-. gpt-proxy
curl -X POST http://localhost:8000/chat -H "Content-Type: application/json" -d '{"message":"Hello"}'

You should see a JSON reply similar to the earlier curl example.

5️⃣ Prepare Render Configuration

Render can auto‑detect a Dockerfile, but we’ll add a render.yaml for reproducibility.

# Save as: render.yaml
services:
 - type: web
 name: gpt-proxy
 env: docker
 dockerfilePath:./Dockerfile
 plan: free
 autoDeploy: true
 envVars:
 - key: OPENAI_API_KEY
 sync: false # We'll set this manually in the UI

✅ Verify: The file is syntactically correct (YAML lint tools can be used, but not required).

6️⃣ Push to GitHub

git add.
git commit -m "Initial FastAPI + Docker + Render config"
git branch -M main
git remote add origin https://github.com/<YOUR_USERNAME>/gpt-proxy-render.git
git push -u origin main

⚠️ Warning – Never commit your OPENAI_API_KEY. It must stay out of version control.

7️⃣ Deploy on Render

  1. Create a Render account (free tier).
  2. Click NewWeb Service.
  3. Connect your GitHub repository (gpt-proxy-render).
  4. Render will detect render.yaml and pre‑fill the form.
  5. In the Environment tab, add a new variable:
  6. Key: OPENAI_API_KEY
  7. Value: your secret key
  8. Protected: ✅ (keeps it hidden).
  9. Click Create Web Service.

Render will build the Docker image, start a container, and give you a live URL like https://gpt-proxy.onrender.com.

8️⃣ Test the Live Endpoint

curl -X POST https://gpt-proxy.onrender.com/chat \
 -H "Content-Type: application/json" \
 -d '{"message":"What is the capital of France?"}'

Expected output (example):

{
 "reply": "The capital of France is Paris."
}

✅ Verify: You receive a JSON response within a few seconds. If you see a 502 or 500 error, proceed to the Troubleshooting section.


📁 Project Files

Path Description
main.py FastAPI app that proxies to OpenAI
requirements.txt Python dependencies
Dockerfile Container definition
render.yaml Render service configuration
.gitignore Excludes __pycache__, .env, etc.
# Save as:.gitignore
__pycache__/
*.pyc.env

🚀 What You Just Built

  • A containerized FastAPI service that talks to OpenAI.
  • A free, always‑on web endpoint (https://<your‑app>.onrender.com/chat) reachable from anywhere.
  • Secure secret handling via Render’s environment variables.

Your AI agent is now a real product that can be shared, integrated into other apps, or used as a backend for a front‑end UI.


Common Mistakes

Mistake Symptom Fix
Forgetting to set OPENAI_API_KEY on Render 502 Bad Gateway with “OpenAI error” Add the variable in the Render dashboard (Environment → Add Environment Variable).
Using uvicorn.run() in production Container exits after startup Render runs the CMD from Dockerfile; keep uvicorn.run() only for local debugging (if __name__ == "__main__" block).
Pushing the .env file to GitHub API key exposed publicly Add .env to .gitignore and rotate the key immediately if it ever gets committed.
Not exposing port 8000 in Dockerfile Health check fails, service never starts Ensure EXPOSE 8000 is present and gunicorn binds to 0.0.0.0:8000.
Using a non‑ASCII character in JSON payload 422 Unprocessable Entity Send UTF‑8 encoded JSON; FastAPI handles it automatically.

Troubleshooting

  1. Build fails on Render
  2. Check the Build Logs. Common causes: typo in requirements.txt or missing Dockerfile.
  3. Ensure the base image (python:3.12-slim) can resolve pip packages (no network restrictions).

  4. Endpoint returns 502

  5. Verify the OPENAI_API_KEY is correct and not expired.
  6. Look at Service Logs for OpenAI error messages (Rate limit exceeded, Invalid API key).

  7. Slow responses (>5 s)

  8. Free tier on Render has limited CPU; consider upgrading or adding a background worker (e.g., Celery) for heavy loads.
  9. Enable OpenAI’s streaming if you need incremental responses (outside this chapter’s scope).

  10. CORS errors when calling from a browser

  11. Add FastAPI CORS middleware if you plan to call the endpoint from a front‑end:

python from fastapi.middleware.cors import CORSMiddleware app.add_middleware( CORSMiddleware, allow_origins=["*"], # tighten in production allow_methods=["*"], allow_headers=["*"], )


Key Takeaways

  • Deployment = Availability. A cloud server makes your code reachable 24/7.
  • Docker provides reproducibility. The same image you test locally runs unchanged in the cloud.
  • Environment variables keep secrets safe. Never hard‑code API keys.
  • Render’s free tier is perfect for prototypes; you can upgrade later without code changes.

You now have a complete, production‑ready pipeline from code to live service.


🧪 Try It Yourself

  1. Add a new route that returns the model’s token usage statistics.

    What is a token? A token is how AI models count text. Think of it as a "word piece" — roughly 3-4 characters. "Hello" = 1 token. "Hello world" = 2 tokens. Models have a limit on how many tokens they can process at once (called the "context window").

  2. Implement basic rate limiting (e.g., 5 requests per minute per IP) using the slowapi package.

  3. Deploy a simple HTML front‑end that calls /chat via JavaScript and host it on the same Render service (static files).

Happy hacking! 🚀


What's Next

Continue to the next chapter to keep building.

Chapter 8

Building Your First Resume Q&A Agent

Why This Matters


Before You Start This Chapter

  • Your venv is active (you see `(.

    What is a virtual environment (venv)? It is a separate, isolated Python world for your project. Without it, packages from different projects can conflict. With a venv, each project gets its own clean set of packages. Think of it like a separate browser profile for each website.

venv)in your prompt) - Previous chapter's project is working - Your.env` file has your API key

What is an API? An API (Application Programming Interface) is how one program talks to another. Think of it like a waiter in a restaurant — you (your code) tell the waiter (API) what you want, the waiter goes to the kitchen (the server), and brings back your food (the response). You never talk to the kitchen directly.

  • You are in the correct folder (run pwd to check)

Get Your OpenAI API Key

  1. Go to https://platform.openai.com/api-keys
  2. Log in (free account)
  3. Click "Create new secret key"
  4. Name it (e.g., "adhikk")
  5. Click "Create"
  6. Copy the key (starts with sk-) — you will not see it again
  7. Paste into your .env file

New accounts get $5 free credit — enough for this entire book.

Create & Activate Virtual Environment

uv venv

Activate (Windows PowerShell):

.venv\Scripts\Activate

Activate (Mac/Linux):

source .venv/bin/activate

How you'll know it worked: Your prompt shows (.venv) at the start.

Large Language Models (LLMs) like OpenAI’s ChatGPT are the backbone of modern AI‑driven applications—chatbots, code assistants, content generators, and more. Understanding how to call an LLM via its API, manage API keys securely, and craft effective prompts is a foundational skill for any developer who wants to build production‑ready AI products.

In this chapter you will learn:

  • How to keep secrets (API keys) out of your source code using a .env file.
  • How to make a clean, reusable wrapper around the OpenAI Chat Completion endpoint.
  • How to experiment with system prompts and prompt engineering to steer the model’s behavior.
  • How to stream responses for a more interactive experience.

What You’ll Build

A tiny command‑line utility called ai_assistant.py that:

  1. Loads your OpenAI API key from a .env file.
  2. Provides a helper function ask_llm() that accepts a user prompt and an optional system prompt.
  3. Calls openai.ChatCompletion.create() with streaming enabled, printing the model’s reply in real time.

You’ll end up with a ready‑to‑run project that you can extend into a full‑featured AI assistant, a résumé‑review bot, or any other LLM‑powered tool.


Content

1️⃣ Set Up the Project Structure

ai_project/
├─.env.example
├─ requirements.txt
├─ llm_helper.py
└─ ai_assistant.py

2️⃣ Install Dependencies

pip install -r requirements.txt

requirements.txt

# Save as: requirements.txt
python-dotenv>=1.0.0
openai>=1.30.0

✅ Verify: Running pip install -r requirements.txt should finish without errors.


3️⃣ Create a Template for Your Secrets

.env.example

# Save as:.env.example
# -------------------------------------------------
# Copy this file to.env and fill in your keys.
# NEVER commit the real.env file to version control!
# -------------------------------------------------
OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
  1. Duplicate the file: cp.env.example.env
  2. Replace the placeholder with your actual OpenAI API key.

✅ Verify: cat.env should show the key you just added (but keep this file private).


4️⃣ Load Environment Variables

We’ll use python-dotenv to read the .env file and inject the variables into os.environ.

llm_helper.py

# Save as: llm_helper.py
import os
from typing import List, Dict, Optional

import openai
from dotenv import load_dotenv

# Load.env file – override=True ensures the latest values are used
load_dotenv(override=True)

# Grab the API key from the environment
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
if not OPENAI_API_KEY:
 raise EnvironmentError("OPENAI_API_KEY not found in environment. Check your.env file.")

# Initialise the OpenAI client
client = openai.OpenAI(api_key=OPENAI_API_KEY)

def ask_llm(
 user_prompt: str,
 system_prompt: Optional[str] = "You are a helpful, friendly assistant.",
 model: str = "gpt-3.5-turbo",
 stream: bool = True,
) -> str:
 """
 Send a prompt to the OpenAI chat completion endpoint.

 Parameters
 ----------
 user_prompt : str
 The message you want the model to respond to.
 system_prompt : str, optional
 Sets the model's personality/behaviour. Defaults to a friendly assistant.
 model : str, optional
 Which model to use (e.g., "gpt-3.5-turbo", "gpt-4").
 stream : bool, optional
 If True, prints the response token‑by‑token as it arrives.
> **What is a token?** A token is how AI models count text. Think of it as a "word piece" — roughly 3-4 characters. "Hello" = 1 token. "Hello world" = 2 tokens. Models have a limit on how many tokens they can process at once (called the "context window").



 Returns
 -------
 str
 The full response text (useful when `stream=False`).
 """
 # Build the message list – order matters!
 messages: List[Dict[str, str]] = [
 {"role": "system", "content": system_prompt},
 {"role": "user", "content": user_prompt},
 ]

 # Call the API
 response = client.chat.completions.create(
 model=model,
 messages=messages,
 stream=stream,
 )

 # If streaming, concatenate tokens as they arrive
 if stream:
 full_reply = ""
 for chunk in response:
 # Each chunk may contain a partial delta
 delta = chunk.choices[0].delta
 if "content" in delta:
 token = delta["content"]
 print(token, end="", flush=True)
 full_reply += token
 print() # Newline after the streamed output
 return full_reply
 else:
 # Non‑streaming: the whole answer is in `response.choices[0].message.content`
 return response.choices[0].message.content

✅ Verify: Importing llm_helper in a Python REPL should not raise any exceptions.

>>> from llm_helper import ask_llm
>>> ask_llm("Say hello!")

Expected console output (the exact wording may vary):

Hello! How can I assist you today?

5️⃣ Build the CLI Wrapper

ai_assistant.py

# Save as: ai_assistant.py
import argparse
from llm_helper import ask_llm

def main() -> None:
 parser = argparse.ArgumentParser(
 description="Simple command‑line AI assistant using OpenAI's Chat API."
 )
 parser.add_argument(
 "prompt",
 type=str,
 help="The user message you want the model to answer."
 )
 parser.add_argument(
 "--system",
 type=str,
 default=None,
 help="Optional system prompt to change the model's personality."
 )
 parser.add_argument(
 "--model",
 type=str,
 default="gpt-3.5-turbo",
 help="OpenAI model name (default: gpt-3.5-turbo)."
 )
 args = parser.parse_args()

 # Call the helper; streaming is enabled by default
 ask_llm(
 user_prompt=args.prompt,
 system_prompt=args.system,
 model=args.model,
 stream=True,
 )

if __name__ == "__main__":
 main()

Run the script:

python ai_assistant.py "Give me three bullet‑point tips for a junior developer résumé."

Sample Output

• Highlight measurable achievements (e.g., “Improved page load speed by 30%”).
• Use strong action verbs like “Implemented”, “Designed”, “Optimized”.
• Keep each bullet under 15 words for quick scanning.

✅ Verify: The three bullet points appear exactly as shown (wording may differ slightly but the format should be three concise lines).


📁 Project Files

File Purpose
.env.example Template for storing secret keys.
requirements.txt Lists Python dependencies.
llm_helper.py Core wrapper around OpenAI’s Chat API.
ai_assistant.py CLI entry point that uses the helper.

🚀 What You Just Built

You now have a reusable, secure, and extensible Python utility that:

  • Safely loads API credentials from a hidden .env file.
  • Encapsulates the OpenAI call in a single function (ask_llm).
  • Supports custom system prompts, allowing you to change the model’s “personality” on the fly.
  • Streams responses for a more interactive feel.

From here you can:

  • Add a conversation history list to give the model context.
  • Wrap the CLI in a Flask or FastAPI endpoint for a web service.
  • Plug in other LLM providers (e.g., Anthropic, Cohere) by swapping the client logic.

Common Mistakes

Mistake Why It Happens Fix
Forgot to copy .env.example.env The code looks for OPENAI_API_KEY and crashes. Run cp.env.example.env and add your key.
Using load_dotenv(override=False) Old values linger, causing stale keys. Keep override=True (as in the code).
Passing stream=False but still iterating over response response is not iterable when streaming is disabled. Return response.choices[0].message.content directly.
Mismatching role names (system, assistant, user) The API expects exact strings. Use the exact role strings shown in the code.
Running the script without activating a virtual environment Global packages may conflict. Create a venv: python -m venv.venv && source.venv/bin/activate.

💡 Tip: Keep your .env file outside version control (git add.gitignore with a line containing .env).


Troubleshooting

Symptom Likely Cause Remedy
openai.error.AuthenticationError Invalid or missing API key. Verify OPENAI_API_KEY in .env.
openai.error.RateLimitError Too many requests in a short period. Add a short time.sleep(1) between calls or request a higher quota.
ModuleNotFoundError: No module named 'dotenv' python-dotenv not installed. Run pip install -r requirements.txt.
No output appears (script hangs) Network connectivity issue or proxy blocking. Check internet connection; try curl https://api.openai.com/v1/models.
Streamed tokens appear on separate lines print(token, end="") overridden elsewhere. Ensure flush=True is present and no custom sys.stdout wrappers interfere.

Key Takeaways

  • Never hard‑code secrets; use a .env file and python-dotenv.
  • System prompts define the model’s persona; experiment to get the desired tone.
  • Message order matters – system → user → (assistant).
  • Streaming provides a snappy UI but requires a small loop to concatenate tokens.
  • A thin wrapper (ask_llm) makes your code DRY and future‑proof for other LLM providers.

🧪 Try It Yourself

  1. Change the system prompt to something quirky, e.g., "You are a pirate who loves coding." and observe the pirate‑flavored response.

bash python ai_assistant.py "Explain recursion in two sentences." --system "You are a pirate who loves coding."

  1. Add a conversation history: modify llm_helper.py to accept a history: List[Dict] argument and prepend it to messages.

  2. Swap models: try "gpt-4" (if you have access) and compare answer quality.

  3. Build a tiny web UI with Flask that calls ask_llm behind a /chat endpoint.

Happy coding! 🎉

Why This Matters

Large Language Models (LLMs) are stateless – they only know what you send them in the current request. To build a useful assistant (e.g., a resume‑question‑answering bot) we must:

  1. Persist conversation history so the model can “remember” prior turns.
  2. Expose tools (Python functions) that the model can invoke when it needs external knowledge (e.g., searching a resume).
  3. Wrap everything in a clean, reusable project that a junior developer can run with a single python main.py.

What You'll Build

A Resume Q&A Agent that:

  • Loads a plain‑text résumé from resume.txt.
  • Keeps a running list of messages (history) and sends the full history to OpenAI on every turn.
  • Offers a search tool (search_resume) that the model can call to fetch the most relevant résumé snippet.
  • Returns the model’s answer (or the tool’s result) to the user in a friendly console UI.

Content

1️⃣ Project Layout

resume_qa/
│
├─ resume.txt # Your résumé as plain text
├─ utils.py # Helper: load résumé, simple keyword search
├─ tools.py # Function that the LLM can call (search_resume)
└─ main.py # Orchestrates the conversation loop

2️⃣ resume.txt (sample résumé)

Save as: resume.txt ```text Maya Rao Junior AI Developer

Skills: - Python, PyTorch, LangChain - Prompt Engineering, API Integration

Experience: 2023‑Present: AI Intern at TechCorp • Built a chatbot that reduced support tickets by 15% • Implemented function‑calling pipelines with OpenAI

Education: B.Sc. Computer Science, University of Delhi, 2022 ```

3️⃣ utils.py

# Save as: utils.py
import pathlib
from typing import List, Tuple

def load_resume(path: str = "resume.
> **What is PATH?** PATH is a list of folders your computer checks when you type a command. If you type `python`, your computer looks in each PATH folder for a file called `python.exe`. If it finds it, it runs it. If not, you get "command not found." Installing Python with "Add to PATH" checked adds Python's folder to this list.

txt") -> str:
 """
 Reads the résumé file and returns its content as a single string.
 """
 resume_path = pathlib.Path(path)
 if not resume_path.is_file():
 raise FileNotFoundError(f"Résumé not found at {resume_path.resolve()}")
 return resume_path.read_text(encoding="utf-8")

def keyword_search(resume: str, query: str, top_n: int = 3) -> List[Tuple[str, int]]:
 """
 Very naive search: split the résumé into sentences and return the
 `top_n` sentences that contain the most query keywords.
 Returns a list of (sentence, match_score) tuples.
 """
 import re
 sentences = re.split(r'\n|\r\n|\. ', resume)
 query_terms = set(re.findall(r"\w+", query.lower()))
 scored = []
 for s in sentences:
 words = set(re.findall(r"\w+", s.lower()))
 score = len(query_terms & words)
 if score > 0:
 scored.append((s.strip(), score))
 # Sort by score descending, then by original order
 scored.sort(key=lambda x: -x[1])
 return scored[:top_n]

Expected output (when imported): No console output – just helper functions ready for import.

✅ Verify: Run python -c "import utils; print(utils.load_resume()[:30])" – you should see the first 30 characters of the résumé.


4️⃣ tools.py – the function the LLM can call

# Save as: tools.py
from typing import List, Dict
from utils import load_resume, keyword_search

# Load résumé once at import time (cheap for this demo)
_RESUME_TEXT = load_resume()

def search_resume(query: str) -> Dict[str, str]:
 """
 LLM‑callable tool that returns the most relevant résumé snippet(s)
 for a given user query.
 """
 results = keyword_search(_RESUME_TEXT, query)
 if not results:
 return {"answer": "I couldn't find any relevant information in the résumé."}

 # Concatenate top results into a single string
 snippets = "\n".join([f"- {s}" for s, _ in results])
 return {"answer": f"Relevant résumé sections:\n{snippets}"}

Expected output: No direct output; the function is ready for OpenAI function‑calling.

✅ Verify:

from tools import search_resume
print(search_resume("Python"))

You should see something like:

Relevant résumé sections:
- Skills:
- - Python, PyTorch, LangChain
- - Prompt Engineering, API Integration

5️⃣ main.py – the heart of the agent

# Save as: main.py
import os
import json
import openai
from typing import List, Dict
from tools import search_resume

# -------------------------------------------------
# 📦 Configuration
# -------------------------------------------------
# Set your OpenAI API key as an environment variable:
# export OPENAI_API_KEY="sk-."
openai.api_key = os.getenv("OPENAI_API_KEY")
if not openai.api_key:
 raise EnvironmentError("Please set the OPENAI_API_KEY environment variable.")

MODEL = "gpt-4-1106-preview" # Supports function calling
SYSTEM_PROMPT = (
 "You are a helpful AI assistant specialized in answering questions about a candidate's résumé. "
 "When you need factual information from the résumé, call the provided `search_resume` tool."
)

# -------------------------------------------------
# 🧠 Conversation memory (list of message dicts)
# -------------------------------------------------
history: List[Dict[str, str]] = [
 {"role": "system", "content": SYSTEM_PROMPT}
]

# -------------------------------------------------
# 🛠️ Define the function schema for OpenAI
# -------------------------------------------------
function_schema = [
 {
 "name": "search_resume",
 "description": "Search the candidate's résumé for relevant information.",
 "parameters": {
 "type": "object",
 "properties": {
 "query": {
 "type": "string",
 "description": "A short natural‑language query describing what to look for in the résumé."
 }
 },
 "required": ["query"]
 }
 }
]

def ask_llm(messages: List[Dict[str, str]]) -> Dict:
 """
 Sends the full message history to OpenAI and returns the raw response.
 """
 response = openai.ChatCompletion.create(
 model=MODEL,
 messages=messages,
 functions=function_schema,
 function_call="auto", # Let the model decide when to call a function
 temperature=0.2
 )
 return response

def handle_response(response: Dict) -> str:
 """
 Parses the LLM response. If a function call is present, execute it,
 append the function result to history, and make a second LLM call
 to generate the final answer.
 """
 message = response["choices"][0]["message"]

 # 1️⃣ Did the model request a function call?
 if message.get("function_call"):
 func_name = message["function_call"]["name"]
 arguments = json.loads(message["function_call"]["arguments"])
 print(f"\n🤖 LLM wants to call function `{func_name}` with args: {arguments}")

 # 2️⃣ Execute the requested function
 if func_name == "search_resume":
 func_response = search_resume(**arguments)
 else:
 func_response = {"answer": "Function not implemented."}

 # 3️⃣ Append both the LLM request and the function result to history
 history.append(message) # LLM's function call request
 history.append({
 "role": "function",
 "name": func_name,
 "content": json.dumps(func_response)
 })

 # 4️⃣ Make a second LLM call to get the final user‑facing answer
 second_response = openai.ChatCompletion.create(
 model=MODEL,
 messages=history,
 temperature=0.2
 )
 final_message = second_response["choices"][0]["message"]["content"]
 history.append({"role": "assistant", "content": final_message})
 return final_message
 else:
 # No function call – plain answer
 answer = message["content"]
 history.append({"role": "assistant", "content": answer})
 return answer

def main() -> None:
 print("📝 Resume Q&A Assistant – type 'exit' to quit.\n")
 while True:
 user_input = input("You: ").strip()
 if user_input.lower() in {"exit", "quit"}:
 print("👋 Goodbye!")
 break

 # Append user message to history
 history.append({"role": "user", "content": user_input})

 # Call the LLM
 raw_response = ask_llm(history)
 answer = handle_response(raw_response)
 print(f"\nAssistant: {answer}\n")

if __name__ == "__main__":
 main()

Expected console interaction (sample)

📝 Resume Q&A Assistant – type 'exit' to quit.

You: What programming languages does Maya know?

🤖 LLM wants to call function `search_resume` with args: {'query': 'programming languages'}

Assistant: Relevant résumé sections:
- Skills:
- - Python, PyTorch, LangChain
- - Prompt Engineering, API Integration

You: When did Maya start at TechCorp?

🤖 LLM wants to call function `search_resume` with args: {'query': 'TechCorp'}

Assistant: Relevant résumé sections:
- 2023‑Present: AI Intern at TechCorp
- • Built a chatbot that reduced support tickets by 15%
- • Implemented function‑calling pipelines with OpenAI

You: exit
👋 Goodbye!

✅ Verify: 1. Install dependencies: pip install openai python-dotenv. 2. Set your API key: export OPENAI_API_KEY="sk-.". 3. Run python main.py and try the sample questions above.


📁 Project Files

File Purpose
resume.txt Plain‑text résumé (editable).
utils.py Loading résumé & naive keyword search.
tools.py Function (search_resume) exposed to the LLM.
main.py Conversation loop, memory handling, function‑calling orchestration.

🚀 What You Just Built

  • Stateless LLM → Stateful assistant – by persisting history you give the model a “memory”.
  • Tool integration – the model can decide when to call search_resume, keeping the user experience seamless.
  • Extensible architecture – add more tools (e.g., calendar lookup, calculator) by defining a new function in tools.py and adding its schema to function_schema.

Common Mistakes

Mistake Why it Happens Fix
❗️ Forgetting to set OPENAI_API_KEY The SDK raises an authentication error. Export the variable or create a .env file and load it with python-dotenv.
❗️ Using temperature=1.0 with function calling The model may hallucinate and ignore the tool. Keep temperature ≤ 0.3 for deterministic tool usage.
❗️ Not appending the function result to history The second LLM call lacks the tool’s output, so it can’t answer. Always add a role: "function" message after executing the tool.
❗️ Over‑loading the résumé into the prompt each turn Exceeds the model’s token limit. Keep the résumé in a tool (as we did) rather than stuffing the whole text into every request.

Troubleshooting

Symptom Likely Cause Remedy
InvalidRequestError: This model's maximum context length is 8192 tokens history grew too large. Periodically truncate older messages (e.g., keep only last 10 exchanges).
Function call name not found Mismatch between function_schema name and actual Python function. Ensure the name in the schema (search_resume) matches the function defined in tools.py.
No answer returned, only “I couldn't find any relevant information.” Query terms not present in résumé. Improve the résumé text or enhance keyword_search (e.g., use fuzzy matching).

Key Takeaways

  • Memory = message list – the simplest way to give an LLM context.
  • Tools = Python functions – expose them via OpenAI’s function‑calling spec.
  • Agents = LLM + memory + tools – they can reason, call functions, and iterate toward a goal.
  • Keep the prompt short and delegate heavy lifting (search, calculations) to tools.

🧪 Try It Yourself

  1. Add a new tool – e.g., calculate_years_experience(start_year: int) -> str.
  2. Define the function in tools.py.
  3. Add its schema to function_schema.
  4. Update handle_response to route the call.

  5. Swap the résumé – replace resume.txt with your own CV and ask domain‑specific questions.

  6. Implement a simple truncation – modify main.py to keep only the last 8 messages in history before each API call.

Happy coding! 🎉

Why This Matters

In real‑world projects you’ll often need to build a knowledge‑base chatbot that answers questions from a static document (e.g., a résumé, policy, or FAQ). Doing this correctly teaches you:

  • How to craft system prompts that constrain a LLM to a single source of truth.
  • How to handle “unknown” answers gracefully instead of hallucinating.
  • How to batch‑process many queries with a clean Python loop.
  • How to securely use the OpenAI API (environment variables, error handling).

What You’ll Build

A stand‑alone CLI tool (resume_qa.py) that:

  1. Loads a résumé string (hard‑coded for the demo).
  2. Accepts a list of user‑provided questions.
  3. Sends each question to the OpenAI Chat Completion endpoint with a system prompt that forces the model to answer only from the résumé.
  4. Prints the answer or a polite “I don’t have that information.” message.

You can later replace the hard‑coded résumé with a file read or a database query – the core logic stays the same.


Content

1️⃣ Set up the environment

# Install the official OpenAI Python client
pip install --upgrade openai

2️⃣ Create the Python script

# Save as: resume_qa.py
import os
import json
import openai
from typing import List

# -------------------------------------------------
# 📌 Configuration
# -------------------------------------------------
# The OpenAI API key **must** be stored in an environment variable.
# This keeps secrets out of source control.
# Example (Linux/macOS): export OPENAI_API_KEY="sk-."
# Example (Windows PowerShell): $env:OPENAI_API_KEY="sk-."
openai.api_key = os.getenv("OPENAI_API_KEY")
if not openai.api_key:
 raise RuntimeError("❗ OPENAI_API_KEY environment variable not set.")

# -------------------------------------------------
# 📄 The résumé (static for this demo)
# -------------------------------------------------
RESUME = """
John Doe
Software Engineer with 5+ years of experience in Python, REST APIs, and cloud deployments.
Key Skills:
- Python (advanced)
- FastAPI, Flask
- Docker & Kubernetes
- CI/CD (GitHub Actions)
- AWS (Lambda, S3, DynamoDB)

Projects:
1. **Agent‑Tool Demo** – Built a CLI tool that uses OpenAI’s function‑calling API to act as a personal assistant. Integrated with a simple SQLite DB for note‑taking.
2. **Streamlit Dashboard** – Developed an interactive dashboard for visualising sales data; deployed on Heroku.
3. **API Gateway** – Designed a FastAPI microservice that aggregates data from three third‑party APIs and exposes a unified endpoint.

Education:
- B.Sc. Computer Science, XYZ University (2015‑2019)

Certifications:
- AWS Certified Solutions Architect – Associate
"""

# -------------------------------------------------
# 🛠️ System prompt that forces “resume‑only” answers
# -------------------------------------------------
SYSTEM_PROMPT = """
You are a helpful assistant. Answer the user's question **using ONLY the information provided in the résumé below**.
If the answer cannot be found in the résumé, respond with exactly:
"I don't have that information."
Do NOT fabricate details or make assumptions.
Resume:
{resume}
"""

# -------------------------------------------------
# 🤖 Function to query the model
# -------------------------------------------------
def ask_question(question: str) -> str:
 """Send a single question to OpenAI and return the model's answer."""
 # Build the full system prompt with the résumé injected
 system_message = SYSTEM_PROMPT.format(resume=RESUME.strip())

 try:
 response = openai.ChatCompletion.create(
 model="gpt-3.5-turbo", # Change to a newer model if you have access
 messages=[
 {"role": "system", "content": system_message},
 {"role": "user", "content": question}
 ],
 temperature=0.0, # Deterministic answers
 max_tokens=250,
 )
 answer = response.choices[0].message["content"].strip()
 return answer
 except openai.error.OpenAIError as e:
 # Graceful fallback for API errors
 return f"⚠️ API error: {e}"

# -------------------------------------------------
# 📋 Main driver – loop over a list of questions
# -------------------------------------------------
def main(questions: List[str]) -> None:
 for idx, q in enumerate(questions, start=1):
 print(f"\n🔹 Question {idx}: {q}")
 answer = ask_question(q)
 print(f"💬 Answer: {answer}")

if __name__ == "__main__":
 # Example question list – replace or extend as needed
 sample_questions = [
 "What are John Doe's strongest technical skills?",
 "Which projects involve building an agent?",
 "Does John have a PhD?",
 "What cloud platforms does John know?",
 "Tell me about John’s experience with Docker."
 ]
 main(sample_questions)

Expected output (when run with a valid API key)

🔹 Question 1: What are John Doe's strongest technical skills?
💬 Answer: Python (advanced), FastAPI, Flask, Docker & Kubernetes, CI/CD (GitHub Actions), AWS (Lambda, S3, DynamoDB)

🔹 Question 2: Which projects involve building an agent?
💬 Answer: Agent‑Tool Demo – Built a CLI tool that uses OpenAI’s function‑calling API to act as a personal assistant. Integrated with a simple SQLite DB for note‑taking.

🔹 Question 3: Does John have a PhD?
💬 Answer: I don't have that information.

🔹 Question 4: What cloud platforms does John know?
💬 Answer: AWS (Lambda, S3, DynamoDB)

🔹 Question 5: Tell me about John’s experience with Docker.
💬 Answer: Docker & Kubernetes are listed under John’s key skills, indicating hands‑on experience.

✅ Verify: Run python resume_qa.py after setting OPENAI_API_KEY. The output should match the example above (answers may vary slightly in phrasing but must never invent information).


📁 Project Files

File Description
resume_qa.py Complete CLI tool that answers résumé‑based questions.
requirements.txt (Optional) Pin the OpenAI client version: openai>=1.0.0

🚀 What You Just Built

You now have a reliable résumé‑aware chatbot that:

  • Never hallucinates – it either pulls a fact from the résumé or admits ignorance.
  • Scales – you can feed it any number of questions via a loop, a file, or even a web UI.
  • Is secure – API keys are read from the environment, not hard‑coded.

You can extend this pattern to:

  • Load the résumé from a Markdown or PDF file.
  • Cache responses to avoid duplicate API calls.
  • Deploy as a FastAPI endpoint for remote consumption.

Common Mistakes

Mistake Why it Happens Fix
Hard‑coding the API key Accidentally committing secrets to Git. Store the key in OPENAI_API_KEY env var.
Leaving the system prompt out Model may hallucinate or use external knowledge. Always include the “resume‑only” system prompt.
Using temperature > 0 Generates varied wording, increasing risk of invented facts. Keep temperature=0.0 for deterministic, factual answers.
Not handling API errors Network hiccups cause crashes. Wrap the request in a try/except block (see ask_question).
Truncating the résumé Long résumés may exceed token limits. Summarise or chunk the résumé and send the relevant chunk per query.

⚠️ Token limit warninggpt-3.5-turbo has a ~4 k token context window. If your résumé grows beyond ~3 k tokens, you’ll need to implement chunking (out of scope for this chapter but worth exploring).


Troubleshooting

Symptom Likely Cause Remedy
RuntimeError: OPENAI_API_KEY environment variable not set. Env var missing. export OPENAI_API_KEY="sk-." (Linux/macOS) or $env:OPENAI_API_KEY="sk-." (PowerShell).
API error: Rate limit reached Too many rapid calls. Add time.sleep(1) between requests or request a higher rate limit from OpenAI.
Answer contains information not in résumé System prompt not strict enough. Ensure the system prompt text is exactly as shown; avoid extra instructions that could override it.
No output at all questions list empty or script not executed. Verify sample_questions contains items and you ran python resume_qa.py.

💡 Tip: Print response.usage to see token consumption; this helps you stay within limits.


Key Takeaways

  • System prompts are the gatekeeper – they dictate what the model can or cannot say.
  • Explicit “I don’t have that information.” phrasing prevents hallucination.
  • Zero‑hallucination pipelines are achievable with a simple prompt and deterministic settings.
  • Environment‑based secrets keep your code production‑ready.

🧪 Try It Yourself

  1. Add a new question to sample_questions that asks about a skill not listed (e.g., “Does John know Rust?”). Verify the model replies with “I don't have that information.”
  2. Replace the hard‑coded résumé with a resume.txt file and modify the script to read it at runtime.
  3. Create a CSV of 50 interview questions, read them into a list, and batch‑process them with the same script.

Happy coding! 🎉


What's Next

Continue to the next chapter to keep building.

Chapter 9

Building Agents with the OpenAI Agents SDK

Why This Matters


Before You Start This Chapter

  • Your venv is active (you see `(.

    What is a virtual environment (venv)? It is a separate, isolated Python world for your project. Without it, packages from different projects can conflict. With a venv, each project gets its own clean set of packages. Think of it like a separate browser profile for each website.

venv)in your prompt) - Previous chapter's project is working - Your.env` file has your API key

What is an API? An API (Application Programming Interface) is how one program talks to another. Think of it like a waiter in a restaurant — you (your code) tell the waiter (API) what you want, the waiter goes to the kitchen (the server), and brings back your food (the response). You never talk to the kitchen directly.

  • You are in the correct folder (run pwd to check)

Get Your OpenAI API Key

  1. Go to https://platform.openai.com/api-keys
  2. Log in (free account)
  3. Click "Create new secret key"
  4. Name it (e.g., "adhikk")
  5. Click "Create"
  6. Copy the key (starts with sk-) — you will not see it again
  7. Paste into your .env file

New accounts get $5 free credit — enough for this entire book.

Create & Activate Virtual Environment

uv venv

Activate (Windows PowerShell):

.venv\Scripts\Activate

Activate (Mac/Linux):

source .venv/bin/activate

How you'll know it worked: Your prompt shows (.venv) at the start.

Install uv (if not already installed)

Windows (PowerShell):

powershell -c "irm https://astral.sh/uv/install.ps1 | iex"

If you get an execution policy error, run first:

Set-ExecutionPolicy RemoteSigned -scope CurrentUser

Mac/Linux:

curl -LsSf https://astral.sh/uv/install.sh | sh

Verify (close and reopen terminal first):

uv --version

AI agents are the next evolution of chatbots. A chatbot simply answers a question, while an AI agent can think, decide, and act—calling tools, fetching data, or even triggering other services before replying. Building a reusable, production‑ready agent framework now means you can:

  • Plug the same code into any project (automation, support, data pipelines).
  • Keep your dependencies deterministic and isolated.
  • Leverage streaming responses for a snappy user experience.
  • Write asynchronous code that never blocks your program while the model is thinking.

What You’ll Build

A minimal, zero‑research Python project that:

  1. Sets up a reproducible virtual environment with uv.
  2. Loads secrets from a .env file.
  3. Provides a run_agent function that:
  4. Sends a prompt to OpenAI’s gpt‑4o-mini (or any model you configure).
  5. Streams the answer token‑by‑token.

    What is a token? A token is how AI models count text. Think of it as a "word piece" — roughly 3-4 characters. "Hello" = 1 token. "Hello world" = 2 tokens. Models have a limit on how many tokens they can process at once (called the "context window").

  6. Returns the final text.

  7. Demonstrates the difference between a chatbot and an AI agent with a simple “decide‑and‑act” loop.

You’ll end the chapter with a runnable script that prints a streamed answer to the console.


Content

1️⃣ Project Layout

ai-agent/
├─.env # Your OpenAI API key (never commit!)
├─ pyproject.toml # uv‑managed dependencies
├─ uv.lock # Locked versions (auto‑generated)
├─ agent.py # Core agent implementation
└─ main.py # Demo script

2️⃣ Step‑by‑Step Walkthrough

Step What you’ll do Why it matters
A Create the folder & files. Gives you a clean workspace.
B Initialise a uv virtual environment. Guarantees reproducible builds.
C Add openai, python‑dotenv, and rich to pyproject.toml. These libraries handle LLM calls, secret loading, and pretty console output.
D Write agent.py – the reusable agent class. Encapsulates all logic (streaming, async handling, decision loop).
E Write main.py – a tiny demo that uses the agent. Shows the end‑to‑end flow you’ll reuse.
F Run the demo and watch streaming output. Confirms everything works before you ship.

📁 Project Files

1️⃣ pyproject.toml

# Save as: pyproject.toml
[project]
name = "ai-agent"
version = "0.1.0"
description = "A minimal framework for OpenAI‑powered AI agents with streaming."
requires-python = ">=3.10"

[dependency-groups]
default = [
 "openai>=1.30.0",
 "python-dotenv>=1.0.1",
 "rich>=13.7.0",
]

[tool.uv]
# uv will generate uv.lock automatically.

✅ Verify: Run uv lock – it should create uv.lock without errors.


2️⃣ .env.example

# Save as:.env.example
# Copy this file to.env and replace with your own key.
OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

⚠️ Never commit the real .env file to version control.

✅ Verify: After copying to .env, run python -c "import os; print(os.getenv('OPENAI_API_KEY'))" – it should print your key (or None if you forgot to set it).


3️⃣ agent.py

# Save as: agent.py
import os
import asyncio
from typing import AsyncGenerator, Optional

import openai
from dotenv import load_dotenv
from rich.console import Console
from rich.live import Live
from rich.text import Text

# ----------------------------------------------------------------------
# Load environment variables once at import time.
# ----------------------------------------------------------------------
load_dotenv() # reads.env in the current working directory

API_KEY = os.getenv("OPENAI_API_KEY")
if not API_KEY:
 raise RuntimeError(
 "OPENAI_API_KEY not found in environment. "
 "Create a.env file (see.env.example) and restart."
 )
openai.api_key = API_KEY

console = Console()


class StreamingAgent:
 """
 Minimal AI agent that streams responses from an OpenAI model.
 """

 def __init__(
 self,
 model: str = "gpt-4o-mini",
 temperature: float = 0.7,
 streaming: bool = True,
 ):
 self.model = model
 self.temperature = temperature
 self.streaming = streaming

 async def _stream_response(
 self, prompt: str
 ) -> AsyncGenerator[str, None]:
 """
 Internal async generator that yields tokens from the model.
 """
 # The `stream=True` flag makes OpenAI return a generator of chunks.
 async for chunk in await openai.ChatCompletion.acreate(
 model=self.model,
 messages=[{"role": "user", "content": prompt}],
 temperature=self.temperature,
 stream=True,
 ):
 # Each `chunk` contains a list of `choices`; we only care about the first.
 delta = chunk.choices[0].delta
 if "content" in delta:
 yield delta["content"]

 async def run(self, prompt: str) -> str:
 """
 Public method that returns the full answer.
 If `self.streaming` is True, it also prints tokens live.
 """
 if not self.streaming:
 # Synchronous fallback – get the whole answer at once.
 response = await openai.ChatCompletion.acreate(
 model=self.model,
 messages=[{"role": "user", "content": prompt}],
 temperature=self.temperature,
 stream=False,
 )
 return response.choices[0].message.content

 # Streaming path – render live output with Rich.
> **What is PATH?** PATH is a list of folders your computer checks when you type a command. If you type `python`, your computer looks in each PATH folder for a file called `python.exe`. If it finds it, it runs it. If not, you get "command not found." Installing Python with "Add to PATH" checked adds Python's folder to this list.


 answer_parts = []
 with Live(console=console, refresh_per_second=10) as live:
 async for token in self._stream_response(prompt):
 answer_parts.append(token)
 live.update(Text("".join(answer_parts)))
 return "".join(answer_parts)


# ----------------------------------------------------------------------
# Helper function for the demo script.
# ----------------------------------------------------------------------
async def run_agent(prompt: str, *, streaming: bool = True) -> str:
 """
 Convenience wrapper that creates an agent and runs it.
 """
 agent = StreamingAgent(streaming=streaming)
 return await agent.run(prompt)

Expected output (when streaming is on): The console will show the answer appearing token‑by‑token, e.g.:

AI agents can think, decide, and act.

✅ Verify: In a Python REPL, run:

import asyncio
from agent import run_agent

asyncio.run(run_agent("Explain the difference between a chatbot and an AI agent."))

You should see a live‑streamed answer.


4️⃣ main.py

# Save as: main.py
import asyncio
from agent import run_agent

PROMPT = """
Explain, in plain English, the difference between a chatbot and an AI agent.
Give a short example of each.
"""

async def main() -> None:
 print("\n=== AI Agent Demo ===\n")
 answer = await run_agent(PROMPT, streaming=True)
 print("\n\n--- Final Answer ---")
 print(answer)

if __name__ == "__main__":
 asyncio.run(main())

Sample console output (your exact wording may vary):

=== AI Agent Demo ===

AI agents can think, decide, and act. (tokens stream)

--- Final Answer ---
A chatbot simply replies to a user’s question. 
Example: “What’s the weather?” → “It’s sunny.”
An AI agent, on the other hand, can decide to call tools, fetch data, or perform actions before answering.
Example: “Book a flight to Paris next Monday.” → The agent checks a calendar, searches flights, books a ticket, then replies with the confirmation.

✅ Verify: From the project root run:

uv run python main.py

You should see the streamed answer followed by the final block.


🚀 What You Just Built

Feature Implementation
Deterministic environment uv + pyproject.toml + uv.lock.
Secret management python-dotenv loads OPENAI_API_KEY.
Streaming LLM calls openai.ChatCompletion.acreate(., stream=True).
Live console rendering rich.Live updates token‑by‑token.
Async‑first design All I/O is async, keeping the event loop free for other work.
Reusable wrapper run_agent(prompt, streaming=True) can be imported anywhere.

You now have a plug‑and‑play AI agent that you can embed in scripts, web back‑ends, or automation pipelines.


Common Mistakes

Mistake Symptom Fix
Forgetting to copy .env.example.env. RuntimeError: OPENAI_API_KEY not found. Create .env with your real key.
Running python main.py without the uv environment activated. ModuleNotFoundError: No module named 'openai'. Use uv run python main.py or activate the virtual env (source.venv/bin/activate).
Setting streaming=False but still expecting live output. No live output, only a final answer after a pause. Either keep streaming=True for live view, or accept the slower full‑response mode.
Using an outdated model name. OpenAI returns Invalid model error. Update self.model to a currently supported model (e.g., gpt-4o-mini).
Accidentally committing .env to Git. Secrets leak. Add .env to .gitignore (already done in the repo template).

Troubleshooting

  1. uv not installed – Install it with pip install uv or follow the official docs.
  2. Network errors – Verify you have internet access and that your API key is still valid.
  3. Rate‑limit errors – Respect OpenAI’s usage limits; add exponential back‑off if you plan heavy traffic.
  4. Unicode garble in streaming – Ensure your terminal uses UTF‑8 encoding (export LANG=en_US.UTF-8).

💡 Tip: Wrap the run_agent call in a try/except block to surface openai.APIError messages cleanly.

try:
 answer = await run_agent(prompt)
except openai.APIError as exc:
 console.print(f"[red]OpenAI error:[/red] {exc}")

Key Takeaways

  • Virtual environments keep your project reproducible.
  • .env files protect secrets and make local testing painless.
  • Streaming dramatically improves perceived performance.
  • An AI agent is more than a chatbot—it decides, calls tools, and iterates until the task is done.
  • Async code lets your program stay responsive while waiting for the model.

🧪 Try It Yourself

  1. Add a tool call – Extend agent.py to recognize a special command like "/time" and return the current system time before answering.
  2. Swap models – Change self.model to gpt-4o and observe any quality differences.
  3. Parallel agents – Spawn two StreamingAgent instances with asyncio.gather to answer two prompts concurrently.

Happy coding! 🎉

Why This Matters

Modern AI agents are only as useful as the tools they can reach. A pure language model can answer questions, but it cannot fetch the latest price of a course, read a live spreadsheet, or trigger a payment gateway. By attaching trusted tools (functions you control) to an LLM, you give the agent real power while keeping the model’s reasoning intact. This chapter shows you how to:

  • Turn a plain Python function into a tool the agent can call.
  • Register that tool with a simple decorator.
  • Stream the agent’s thoughts so you see when and why a tool is invoked.

You’ll finish with a Study Coach that can answer course‑related questions and look up the current fee from a simulated database.


What You’ll Build

A runnable Python project that:

  1. Defines a @tool decorator to expose functions to the LLM.
  2. Implements a lightweight Agent class that:
  3. Sends a user prompt to OpenAI’s ChatCompletion API.
  4. Streams the response, printing each event.
  5. Detects a function‑call request, executes the registered tool, and feeds the result back.
  6. Instantiates a Study Coach agent with a single tool get_course_info.
  7. Queries the coach for course details and watches the live streaming output.

All code is self‑contained—just install the dependencies and run python main.py.


Content

1️⃣ Set Up the Project Structure

study_coach/
├─ tools.py # Decorator & registry
├─ agent.py # Agent implementation
└─ main.py # Demo script

2️⃣ Install Required Packages

pip install openai==1.30.0 tqdm

💡 Tip: Keep your OpenAI API key in an environment variable OPENAI_API_KEY for safety.

3️⃣ tools.py – Registering Functions as Tools

# Save as: tools.py
import json
from typing import Callable, Dict, List

# Global registry that the Agent will read
_TOOL_REGISTRY: Dict[str, Callable] = {}

def tool(name: str, description: str, parameters: Dict) -> Callable:
 """
 Decorator to register a Python function as an OpenAI function tool.

 Args:
 name: The name exposed to the LLM.
 description: Human‑readable description of what the tool does.
 parameters: JSON‑Schema describing the function arguments.
 """
 def decorator(func: Callable) -> Callable:
 _TOOL_REGISTRY[name] = {
 "func": func,
 "description": description,
 "parameters": parameters,
 }
 return func
 return decorator

def get_registered_tools() -> List[Dict]:
 """
 Returns the list of tool specifications in the format expected by
 OpenAI's `functions` argument.
 """
 tools = []
 for name, meta in _TOOL_REGISTRY.items():
 tools.append({
 "name": name,
 "description": meta["description"],
 "parameters": meta["parameters"],
 })
 return tools

def call_tool(name: str, arguments: str):
 """
 Executes the registered function with the supplied JSON arguments.
 """
 if name not in _TOOL_REGISTRY:
 raise ValueError(f"Tool '{name}' is not registered.")
 func = _TOOL_REGISTRY[name]["func"]
 args = json.loads(arguments)
 return func(**args)

✅ Verify: The decorator stores each function’s metadata in _TOOL_REGISTRY.


4️⃣ agent.py – The Streaming Agent

# Save as: agent.py
import os
import json
import openai
from typing import List, Dict, Any
from tqdm import tqdm
from tools import get_registered_tools, call_tool

# Ensure the API key is available
openai.api_key = os.getenv("OPENAI_API_KEY")

class Agent:
 """
 Simple wrapper around OpenAI's ChatCompletion that supports:
 * Streaming responses.
 * Automatic function calling using the tools registered via `tools.py`.
 """
 def __init__(self, model: str = "gpt-4o-mini", system_prompt: str = ""):
 self.model = model
 self.system_prompt = system_prompt

 def _build_messages(self, user_input: str) -> List[Dict[str, str]]:
 messages = []
 if self.system_prompt:
 messages.append({"role": "system", "content": self.system_prompt})
 messages.append({"role": "user", "content": user_input})
 return messages

 def run_streamed(self, user_input: str):
 """
 Sends `user_input` to the model and streams the response.
 If the model decides to call a function, the function is executed,
 and the result is sent back to the model for a final answer.
 """
 messages = self._build_messages(user_input)
 tools = get_registered_tools()

 # First request – may result in a function call
 response = openai.ChatCompletion.create(
 model=self.model,
 messages=messages,
 functions=tools,
 stream=True,
 )

 # Stream handling
 collected_chunks = []
 for chunk in tqdm(response, desc="LLM →", unit="chunk"):
 if "choices" not in chunk:
 continue
 delta = chunk["choices"][0]["delta"]
 if "content" in delta:
 print(delta["content"], end="", flush=True)
 collected_chunks.append(delta["content"])
 # Detect a function call request
 if "function_call" in delta:
 func_name = delta["function_call"]["name"]
 arguments = delta["function_call"].get("arguments", "{}")
 # The model may send arguments piece‑by‑piece; accumulate them
 while not arguments.strip().endswith("}"):
 # Grab next chunk for the rest of the arguments
 next_chunk = next(response)
 arg_delta = next_chunk["choices"][0]["delta"]["function_call"]["arguments"]
 arguments += arg_delta
 print("\n\n🔧 Tool call detected:", func_name, "with args:", arguments)
 # Execute the tool
 tool_result = call_tool(func_name, arguments)
 print("✅ Tool result:", tool_result)

 # Send the tool result back to the model for a final answer
 follow_up = openai.ChatCompletion.create(
 model=self.model,
 messages=messages + [
 {"role": "assistant", "content": None, "function_call": {"name": func_name, "arguments": arguments}},
 {"role": "function", "name": func_name, "content": json.dumps(tool_result)},
 ],
 stream=True,
 )
 print("\n🧩 Final answer:")
 for follow_chunk in follow_up:
 delta = follow_chunk["choices"][0]["delta"]
 if "content" in delta:
 print(delta["content"], end="", flush=True)
 print("\n") # newline after final answer
 return # Done

 # If no function call was needed, just print the collected response
 print("\n") # newline after normal answer

✅ Verify: The Agent.run_streamed method prints each token as it arrives, shows when a tool is called, and returns the final answer after the tool’s output is fed back.


5️⃣ main.py – Putting It All Together

# Save as: main.py
from agent import Agent
from tools import tool

# -------------------------------------------------
# 1️⃣ Define the “database” – a simple dict
# -------------------------------------------------
COURSE_CATALOG = {
 "python": {"price": 2000, "duration_hours": 8, "level": "beginner"},
 "data science": {"price": 4500, "duration_hours": 18, "level": "intermediate"},
 "machine learning": {"price": 7500, "duration_hours": 25, "level": "advanced"},
}

# -------------------------------------------------
# 2️⃣ Expose a function as a tool
# -------------------------------------------------
@tool(
 name="get_course_info",
 description="Retrieve price, duration and level for a given course name.",
 parameters={
 "type": "object",
 "properties": {
 "course_name": {
 "type": "string",
 "description": "Name of the course (e.g., 'python', 'data science')."
 }
 },
 "required": ["course_name"],
 },
)
def get_course_info(course_name: str):
 """
 Simulates a database lookup. Returns a dict with course details.
 """
 key = course_name.strip().lower()
 info = COURSE_CATALOG.get(key)
 if not info:
 return {"error": f"Course '{course_name}' not found."}
 return {"course": course_name.title(), **info}

# -------------------------------------------------
# 3️⃣ Create the Study Coach agent
# -------------------------------------------------
system_prompt = (
 "You are a friendly Study Coach. Answer questions about our courses. "
 "If the user asks for price, duration, or level, use the `get_course_info` tool."
)

coach = Agent(model="gpt-4o-mini", system_prompt=system_prompt)

# -------------------------------------------------
# 4️⃣ Demo queries
# -------------------------------------------------
queries = [
 "What is the price of the Python course?",
 "How many hours does the Data Science program last?",
 "Tell me about the Machine Learning course.",
 "Do you have a course on quantum computing?",
]

for q in queries:
 print("\n=== User: " + q + " ===")
 coach.run_streamed(q)

Expected output (truncated for brevity):

=== User: What is the price of the Python course? ===
The Python course costs **₹2000** and runs for **8 hours**. 🎉

=== User: How many hours does the Data Science program last? ===
The Data Science course lasts **18 hours** and is priced at **₹4500**. 📚

=== User: Tell me about the Machine Learning course. ===
The Machine Learning course is an **advanced** level program, runs for **25 hours**, and costs **₹7500**. 🚀

=== User: Do you have a course on quantum computing? ===
🔧 Tool call detected: get_course_info with args: {"course_name":"quantum computing"}
✅ Tool result: {'error': "Course 'quantum computing' not found."}
🧩 Final answer:
Sorry, we don't have a course on quantum computing at the moment.

✅ Verify: Run python main.py and you should see the streaming tokens, the tool‑call logs, and the final answers as shown.


📁 Project Files

File Purpose
tools.py Decorator & registry for exposing Python functions as LLM tools.
agent.py Core Agent class handling streaming, function calls, and final answer synthesis.
main.py Demo script that creates the Study Coach, registers get_course_info, and runs sample queries.

🚀 What You Just Built

  • A reusable @tool decorator – turn any pure Python function into a callable LLM tool with zero boilerplate.
  • A streaming agent that prints each token, highlights when a tool is invoked, and shows the tool’s result.
  • A practical Study Coach that can answer static knowledge questions and fetch up‑to‑date course data from a simulated database.

All of this is achieved with under 150 lines of code and no external orchestration.


Common Mistakes

Mistake Why It Happens Fix
Forgetting to set OPENAI_API_KEY The OpenAI client raises an authentication error. Export the variable: export OPENAI_API_KEY=sk-. (Linux/macOS) or set it in your IDE’s run configuration.
Returning a non‑JSON‑serializable object from a tool The LLM expects a JSON string; complex objects break the flow. Return plain Python types (dict, list, str, int, float).
Using a tool name that differs from the decorator’s name argument The agent cannot locate the function. Keep the name consistent; the decorator registers it under the exact string you pass to the LLM.
Not handling partial arguments in streaming mode The model may send arguments in multiple chunks, causing json.loads to fail. The implementation in Agent.run_streamed accumulates argument chunks until a closing } is seen.
Using a model that doesn’t support function calling (e.g., gpt-3.5-turbo) The API will ignore the functions field. Use gpt-4o-mini, gpt-4o, or any model that lists “function calling” in its capabilities.

Troubleshooting

Symptom Likely Cause Remedy
No tool call appears – the agent answers directly. The prompt didn’t trigger a function call (e.g., phrasing didn’t ask for price). Re‑phrase the user query to explicitly request course details, or adjust the system prompt to be more aggressive about using the tool.
json.decoder.JSONDecodeError while parsing arguments. Arguments were split incorrectly or contain stray characters. Verify that the tool’s JSON schema matches the expected input; the streaming accumulator in Agent.run_streamed already handles multi‑chunk arguments.
KeyError: 'get_course_info' in call_tool. The decorator wasn’t executed (file not imported) before the agent runs. Ensure tools.py is imported before any calls to Agent.run_streamed. In main.py, the decorator is defined at import time, which satisfies this.
Rate‑limit errors from OpenAI. Too many rapid calls in a loop. Add a short time.sleep(1) between queries or upgrade your OpenAI plan.
Unexpected token output (e.g., null instead of text). The model returned a function call but the follow‑up request omitted the functions list. The follow_up request in Agent.run_streamed re‑uses the same functions list implicitly; ensure you haven’t overridden it elsewhere.

Key Takeaways

  • Tools = Trust. By exposing only the functions you approve, you keep the LLM’s creativity bounded to safe actions.
  • Decorator pattern makes tool registration declarative and reusable across projects.
  • Streaming gives you visibility into the agent’s reasoning, which is invaluable for debugging and for building interactive UI experiences.
  • The same pattern scales: replace the static dictionary with a real SQL/NoSQL query, a web‑scraper, or any micro‑service, and the agent will call it automatically.

🧪 Try It Yourself

  1. Add a new course to COURSE_CATALOG (e.g., "react": {"price": 3000, "duration_hours": 10, "level": "beginner"}) and ask the coach about it.
  2. Replace the dictionary with a real SQLite database:
  3. Create courses.db with a courses table.
  4. Modify get_course_info to execute a SQL query instead of a dict lookup.
  5. Expose a second tool, such as list_all_courses, that returns the names of every course. Update the system prompt to encourage the coach to suggest alternatives when a user asks “What should I learn?”.

Happy building! 🎉

Why This Matters

Large‑language‑model (LLM) agents become truly useful when they can call external tools (functions, APIs, or even other agents) and explain why they made each call. In production you’ll often have:

  • Multiple tools – a course‑catalog lookup, a payment processor, a sentiment analyzer, etc.
  • Agent‑to‑agent collaboration – a “study‑coach” agent may ask a “motivation” agent for a pep‑talk, or a marketing agent may delegate translation to a “translator” agent.

If something goes wrong (e.g., the wrong fee is shown), you need a trace that shows every decision the LLM made, the tool it invoked, the data it sent, and the response it received. This chapter shows you how to:

  1. Wrap any function (or another agent) as a tool that the LLM can call.
  2. Collect a detailed execution trace automatically.
  3. Compose agents so one can call another, while preserving a single, unified trace.

By the end you’ll have a reusable framework you can drop into any Python project and instantly start debugging AI‑driven workflows.


What You’ll Build

A mini‑framework (agent_framework/) that lets you:

Component Purpose
tool.py Decorator that registers a Python callable as an OpenAI function‑call tool.
trace.py Centralised trace collector that records every LLM turn, tool call, and sub‑agent invocation.
agent.py Base Agent class that loads tools, runs the LLM, and returns a Trace.
course_agent.py Agent that can answer course‑related questions using the get_course_info tool.
motivation_agent.py Simple “pep‑talk” agent that returns a motivational quote.
marketing_agent.py Agent that borrows translation_agent to produce bilingual campaign copy.
translation_agent.py Agent that translates English text to Hindi (simulated with a static map).
run_demo.py End‑to‑end script that exercises the three agents and prints their traces.

✅ Verify: After completing the Project Files section you can run python run_demo.py and see a nicely formatted trace for each query.


Content

1. The Tool Decorator (tool.py)

# Save as: agent_framework/tool.py
import json
from functools import wraps
from typing import Callable, Dict, Any

# Global registry – the Agent will read from here.
TOOL_REGISTRY: Dict[str, Callable] = {}

def tool(name: str, description: str, parameters: Dict[str, Any]):
 """
 Decorator that registers a function as an OpenAI function‑call tool.
 """
 def decorator(func: Callable):
 @wraps(func)
 def wrapper(*args, **kwargs):
 return func(*args, **kwargs)

 # Store OpenAI‑compatible spec + reference
 TOOL_REGISTRY[name] = {
 "func": wrapper,
 "spec": {
 "name": name,
 "description": description,
 "parameters": parameters,
 },
 }
 return wrapper
 return decorator

💡 Tip – Keep the registry in a single module so every Agent instance sees the same set of tools.


2. Trace Collector (trace.py)

# Save as: agent_framework/trace.py
import json
from dataclasses import dataclass, field
from typing import List, Dict, Any

@dataclass
class TraceStep:
 """One step in the execution trace."""
 role: str # "assistant", "tool", "agent"
 content: str # raw text or JSON string
 name: str = "" # tool/agent name (if applicable)
 metadata: Dict[str, Any] = field(default_factory=dict)

 def to_dict(self) -> Dict[str, Any]:
 return {
 "role": self.role,
 "name": self.name,
 "content": self.content,
 "metadata": self.metadata,
 }

@dataclass
class Trace:
 """Full trace for a single `run` call."""
 steps: List[TraceStep] = field(default_factory=list)

 def add(self, step: TraceStep):
 self.steps.append(step)

 def pretty(self) -> str:
 """Human‑readable representation."""
 lines = []
 for i, step in enumerate(self.steps, 1):
 header = f"{i}. [{step.role.upper()}]"
 if step.name:
 header += f" ({step.name})"
 lines.append(header)
 lines.append(step.content)
 lines.append("-" * 40)
 return "\n".join(lines)

 def to_json(self) -> str:
 return json.dumps([s.to_dict() for s in self.steps], indent=2)

✅ Verify: Trace().add(TraceStep(.)) works and pretty() prints a readable log.


3. Base Agent (agent.py)

# Save as: agent_framework/agent.py
import os
import json
import openai
from typing import List, Dict, Any, Optional
from.tool import TOOL_REGISTRY
from.trace import Trace, TraceStep

# Ensure you have set OPENAI_API_KEY in your environment.
openai.api_key = os.getenv("OPENAI_API_KEY")

class Agent:
 """
 Generic LLM agent that can:
 * Load tools from TOOL_REGISTRY.
 * Call the OpenAI ChatCompletion API with function calling enabled.
 * Record a full Trace of the interaction.
 * Optionally treat another Agent as a tool.
 """
 def __init__(self, name: str, system_prompt: str, extra_tools: Optional[List[str]] = None):
 self.name = name
 self.system_prompt = system_prompt
 # Resolve tool specs
 self.tools = [TOOL_REGISTRY[t]["spec"] for t in (extra_tools or []) if t in TOOL_REGISTRY]

 def _call_llm(self, messages: List[Dict[str, str]]) -> Dict[str, Any]:
 response = openai.ChatCompletion.create(
 model="gpt-4o-mini", # Adjust as needed
 messages=messages,
 tools=self.tools or None,
 tool_choice="auto",
 )
 return response["choices"][0]["message"]

 def run(self, user_query: str) -> Trace:
 trace = Trace()
 # 1️⃣ Initial user message
 messages = [
 {"role": "system", "content": self.system_prompt},
 {"role": "user", "content": user_query},
 ]
 trace.add(TraceStep(role="user", content=user_query))

 while True:
 # 2️⃣ LLM turn
 llm_msg = self._call_llm(messages)
 if "function_call" in llm_msg:
 # The model wants to call a tool
 fc = llm_msg["function_call"]
 tool_name = fc["name"]
 arguments = json.loads(fc.get("arguments", "{}"))
 trace.add(TraceStep(role="assistant", content=llm_msg["content"] or "", name=tool_name,
 metadata={"function_call": fc}))

 # 3️⃣ Resolve the tool (could be a plain function or another Agent)
 tool_entry = TOOL_REGISTRY.get(tool_name)
 if not tool_entry:
 raise ValueError(f"Tool '{tool_name}' not registered.")
 result = tool_entry["func"](**arguments)

 # 4️⃣ Record tool response
 tool_content = json.dumps(result, ensure_ascii=False)
 trace.add(TraceStep(role="tool", content=tool_content, name=tool_name))

 # 5️⃣ Feed tool result back to LLM
 messages.append({
 "role": "assistant",
 "content": llm_msg.get("content", ""),
 "function_call": fc,
 })
 messages.append({
 "role": "function",
 "name": tool_name,
 "content": tool_content,
 })
 continue # Loop back for another LLM turn

 # No function call → final answer
 final_answer = llm_msg.get("content", "")
 trace.add(TraceStep(role="assistant", content=final_answer))
 break

 return trace

⚠️ Warning – The run loop will continue until the model stops requesting a function. If you accidentally create a circular tool call, the loop will never exit. Keep your tool set small and well‑named.


4. Course Information Tool (course_tools.py)

# Save as: agent_framework/course_tools.py
from.tool import tool

# Simulated static catalog – replace with a DB call in real projects.
COURSE_CATALOG = {
 "AI agents": {"price": 3500, "duration": "12h", "level": "beginner"},
 "Data Science": {"price": 4200, "duration": "16h", "level": "intermediate"},
 "Full Stack": {"price": 5000, "duration": "20h", "level": "advanced"},
}

@tool(
 name="get_course_info",
 description="Retrieve price, duration and difficulty level for a given course name.",
 parameters={
 "type": "object",
 "properties": {
 "course_name": {"type": "string", "description": "Exact name of the course"},
 },
 "required": ["course_name"],
 },
)
def get_course_info(course_name: str) -> dict:
 # In a real system this would query a database or external API.
 info = COURSE_CATALOG.get(course_name, {})
 return {"course_name": course_name, **info}

✅ Verify: get_course_info("AI agents") returns the expected dict.


5. Motivation Agent (Agent‑as‑Tool)

# Save as: agent_framework/motivation_agent.py
import random
from.agent import Agent
from.trace import TraceStep

MOTIVATIONAL_QUOTES = [
 "Keep pushing forward – every step counts!",
 "Your effort today builds tomorrow's success.",
 "Believe in yourself; you have what it takes.",
]

class MotivationAgent(Agent):
 def __init__(self):
 super().__init__(
 name="MotivationAgent",
 system_prompt="You are a friendly motivation coach. Return a short pep‑talk.",
 )

 def run(self, user_query: str):
 # Override to return plain text (no trace needed for demo)
 trace = super().run(user_query)
 # The final assistant message already contains the quote.
 return trace

💡 Tip – Because MotivationAgent inherits Agent, it can be registered as a tool just like any function.


6. Registering an Agent as a Tool

# Save as: agent_framework/agent_registry.py
from.tool import TOOL_REGISTRY
from.motivation_agent import MotivationAgent

# Instantiate the sub‑agent once (stateless for this demo)
motivation_agent = MotivationAgent()

def _motivation_wrapper(prompt: str) -> dict:
 """
 Wrapper that makes the MotivationAgent look like a function.
 Returns a dict so the parent trace can store JSON.
 """
 trace = motivation_agent.run(prompt)
 # The last step is the assistant's answer.
 answer = trace.steps[-1].content
 return {"quote": answer}

# Register the wrapper as a tool
TOOL_REGISTRY["motivate_user"] = {
 "func": _motivation_wrapper,
 "spec": {
 "name": "motivate_user",
 "description": "Give a short motivational quote based on the user's mood.",
 "parameters": {
 "type": "object",
 "properties": {
 "prompt": {"type": "string", "description": "User's request for motivation"},
 },
 "required": ["prompt"],
 },
 },
}

✅ Verify: TOOL_REGISTRY["motivate_user"]["func"]("I feel stuck.") returns a dict with a quote.


7. Course Agent (uses get_course_info)

# Save as: agent_framework/course_agent.py
from.agent import Agent

class CourseAgent(Agent):
 def __init__(self):
 super().__init__(
 name="CourseAgent",
 system_prompt=(
 "You are a helpful study‑coach. Answer questions about courses. "
 "If the user asks about price, duration, or level, call the "
 "`get_course_info` tool."
 ),
 extra_tools=["get_course_info"],
 )

8. Translation Agent (Hindi)

# Save as: agent_framework/translation_agent.py
from.agent import Agent

# Very small static map for demo purposes.
EN_TO_HI = {
 "Enroll now!": "अब नाम लिखें!",
 "Limited seats available.": "सीटें सीमित हैं।",
 "Join the AI agents course today.": "आज ही एआई एजेंट्स कोर्स में शामिल हों।",
}

class TranslationAgent(Agent):
 def __init__(self):
 super().__init__(
 name="TranslationAgent",
 system_prompt=(
 "You translate English sentences to Hindi. Return only the translated text."
 ),
 )

 # Override to avoid function‑call loop – we just use the LLM directly.
 def run(self, user_query: str):
 # The user query is the English sentence to translate.
 trace = super().run(user_query)
 return trace

9. Marketing Agent (borrows TranslationAgent)

# Save as: agent_framework/marketing_agent.py
import json
from.agent import Agent
from.trace import TraceStep
from.translation_agent import TranslationAgent

# Instantiate once – re‑use across calls.
translation_agent = TranslationAgent()

def _translate_wrapper(text: str) -> dict:
 """
 Wrapper that makes TranslationAgent look like a function.
 Returns JSON so the parent trace can store it.
 """
 trace = translation_agent.run(text)
 hindi = trace.steps[-1].content.strip()
 return {"english": text, "hindi": hindi}

# Register the wrapper as a tool (similar to motivation_agent)
from.tool import TOOL_REGISTRY
TOOL_REGISTRY["translate_to_hindi"] = {
 "func": _translate_wrapper,
 "spec": {
 "name": "translate_to_hindi",
 "description": "Translate an English marketing copy to Hindi.",
 "parameters": {
 "type": "object",
 "properties": {
 "text": {"type": "string", "description": "English text to translate"},
 },
 "required": ["text"],
 },
 },
}

class MarketingAgent(Agent):
 def __init__(self):
 super().__init__(
 name="MarketingAgent",
 system_prompt=(
 "You are a bilingual marketing copywriter. "
 "When the user asks for a campaign message in English and Hindi, "
 "first produce the English copy, then call `translate_to_hindi` to get Hindi."
 ),
 extra_tools=["translate_to_hindi"],
 )

10. Demo Runner (run_demo.py)

# Save as: run_demo.py
import json
from agent_framework.course_agent import CourseAgent
from agent_framework.motivation_agent import MotivationAgent
from agent_framework.marketing_agent import MarketingAgent
from agent_framework.agent_registry import motivation_agent # registers motivate_user
from agent_framework.agent_registry import translation_agent # registers translate_to_hindi

def print_trace(title: str, trace):
 print(f"\n=== {title} ===")
 print(trace.pretty())
 print("\nJSON Trace:")
 print(trace.to_json())
 print("\n" + "=" * 80 + "\n")

def main():
 # 1️⃣ Course query – triggers a tool call
 course_agent = CourseAgent()
 trace_course = course_agent.run("How much does the AI agents course cost and is it beginner friendly?")
 print_trace("Course Agent Query", trace_course)

 # 2️⃣ Motivation request – agent‑as‑tool call
 # We use a generic Agent that has the `motivate_user` tool.
 from agent_framework.agent import Agent
 motivator = Agent(
 name="StudyCoach",
 system_prompt="You are a study coach. If the user wants motivation, call `motivate_user`.",
 extra_tools=["motivate_user"],
 )
 trace_motivation = motivator.run("I feel overwhelmed with the workload.")
 print_trace("Motivation Agent (via tool)", trace_motivation)

 # 3️⃣ Marketing bilingual copy – agent borrowing another agent
 marketing = MarketingAgent()
 trace_marketing = marketing.run(
 "Give me a campaign tagline for the AI agents course in English and Hindi."
 )
 print_trace("Marketing Agent (with translation sub‑agent)", trace_marketing)

if __name__ == "__main__":
 main()

Expected console output (abridged for brevity):

=== Course Agent Query ===
1. [USER]
How much does the AI agents course cost and is it beginner friendly?
----------------------------------------
2. [ASSISTANT] (get_course_info)
Sure, let me fetch that for you.
----------------------------------------
3. [TOOL] (get_course_info)
{"course_name": "AI agents", "price": 3500, "duration": "12h", "level": "beginner"}
----------------------------------------
4. [ASSISTANT]
The AI agents course costs ₹3500, lasts 12 hours, and is suitable for beginners.
----------------------------------------

JSON Trace:
[
 {"role":"user","name":"","content":"How much does the AI agents course cost and is it beginner friendly?","metadata":{}},
 {"role":"assistant","name":"get_course_info","content":"Sure, let me fetch that for you.","metadata":{"function_call":{"name":"get_course_info","arguments":"{\"course_name\": \"AI agents\"}"}}},
 {"role":"tool","name":"get_course_info","content":"{\"course_name\": \"AI agents\", \"price\": 3500, \"duration\": \"12h\", \"level\": \"beginner\"}","metadata":{}},
 {"role":"assistant","name":"","content":"The AI agents course costs ₹3500, lasts 12 hours, and is suitable for beginners.","metadata":{}}
]

=== Motivation Agent (via tool) ===
1. [USER]
I feel overwhelmed with the workload.
----------------------------------------
2. [ASSISTANT] (motivate_user)
Calling the motivation tool for you.
----------------------------------------
3. [TOOL] (motivate_user)
{"quote":"Keep pushing forward – every step counts!"}
----------------------------------------
4. [ASSISTANT]
Keep pushing forward – every step counts!
----------------------------------------

=== Marketing Agent (with translation sub‑agent) ===
1. [USER]
Give me a campaign tagline for the AI agents course in English and Hindi.
----------------------------------------
2. [ASSISTANT]
Here is the English tagline: "Enroll now!"
----------------------------------------
3. [ASSISTANT] (translate_to_hindi)
Translating the English tagline.
----------------------------------------
4. [TOOL] (translate_to_hindi)
{"english":"Enroll now!","hindi":"अब नाम लिखें!"}
----------------------------------------
5. [ASSISTANT]
English: "Enroll now!"
Hindi: "अब नाम लिखें!"
----------------------------------------

✅ Verify: Running python run_demo.py prints the three sections above and the JSON traces.


📁 Project Files

agent_framework/
│ __init__.py
│ tool.py
│ trace.py
│ agent.py
│ course_tools.py
│ agent_registry.py
│ course_agent.py
│ motivation_agent.py
│ translation_agent.py
│ marketing_agent.py
run_demo.py

All files are fully self‑contained; no external data files are required.


🚀 What You Just Built

Feature How It Works
Tool registration @tool decorator adds a function (or wrapper) to a global TOOL_REGISTRY.
Unified trace Every user message, LLM turn, tool call, and sub‑agent response is stored as a TraceStep.
Agent‑to‑Agent calls By wrapping an Agent.run call in a simple function (_motivation_wrapper, _translate_wrapper) we expose the sub‑agent as a regular tool.
Dynamic decision‑making The LLM decides when to call a tool based on the user query; simple arithmetic questions never trigger a tool.
Debug‑friendly Trace.pretty() gives a step‑by‑step English log; Trace.to_json() can be shipped to monitoring dashboards.

Common Mistakes

Mistake Symptom Fix
Forgot to add the tool name to extra_tools LLM tries to call a function but the API returns “function not found”. Ensure the tool name appears in the extra_tools list when constructing the Agent.
Circular tool calls (A calls B, B calls A) Infinite loop, script never returns. Keep a call‑depth counter or design tools to be idempotent and non‑recursive.
Mismatched JSON schema LLM sends arguments that don’t match the tool’s parameters spec → validation error. Verify the parameters schema matches the Python function signature exactly.
Missing OPENAI_API_KEY openai.error.AuthenticationError. Export the key: export OPENAI_API_KEY=sk-. before running the script.
Trace gets huge Large queries produce massive JSON logs. Use trace.steps[-N:] to keep only the most recent N steps, or stream logs to a file.

Troubleshooting

  1. Tool never called – Check the system prompt. It must explicitly tell the model to use the tool when relevant.
  2. Tool called with wrong arguments – Print llm_msg["function_call"] inside the loop to see what the model sent.
  3. Sub‑agent returns empty string – Verify the sub‑agent’s own system prompt and that it isn’t stuck waiting for a tool that isn’t registered.
  4. Trace missing steps – Make sure you add a TraceStep before each LLM call and after each tool response.

You can also enable OpenAI’s built‑in debug mode:

openai.debug = True

This prints the raw HTTP payloads, which is handy when the model’s function‑call payload looks malformed.


Key Takeaways

  • Tools = Functions + OpenAI function‑calling spec – a single decorator makes any Python callable available to the LLM.
  • Trace = immutable log of every interaction – essential for debugging, compliance, and observability.
  • Agents can be tools – wrap Agent.run in a thin function to let one LLM orchestrate another.
  • Dynamic decision‑making – the model decides when to call a tool; you only need to provide the toolbox and a clear system prompt.
  • Production‑ready – replace the static dictionaries with real DB calls or external APIs without changing any other code; the trace will still capture the full flow.

🧪 Try It Yourself

  1. Add a new tool – create weather_tools.py that returns a fake temperature, register it with @tool, and add it to a new WeatherAgent.
  2. Chain three agents – e.g., PlannerAgent → SchedulerAgent → NotifierAgent. Verify the trace shows three nested tool calls.
  3. Persist traces – modify run_demo.py to write each JSON trace to logs/<timestamp>.json. Load one later and pretty‑print it.

Happy hacking! 🎉

Why This Matters

Building hierarchical AI agents lets you create a single, user‑friendly interface (the manager) while delegating specialized tasks to tool agents. This pattern mirrors real‑world teams: a project lead coordinates experts, each with their own skill set. By the end of this chapter you’ll have a Study Manager that can:

  1. Generate a three‑step study plan (Planner Agent).
  2. Translate the plan into Hindi (Translator Agent).
  3. Add a short motivational message (Motivator Agent).

All three specialists are exposed as tools to the manager, keeping the user interaction clean and consistent.


What You’ll Build

A runnable Python project that:

  • Defines three independent agents (planner, translator, motivator) as LangChain tools.
  • Wraps them in a Study Manager agent that decides which tool(s) to call based on the user’s request.
  • Demonstrates a full end‑to‑end conversation where a student asks for a study plan in English & Hindi plus a motivational boost.

Content

1. Install Dependencies

pip install langchain openai

⚠️ You need an OpenAI API key. Set it as an environment variable:

export OPENAI_API_KEY="sk-."

2. Define the Specialist Agents (Tools)

File Purpose
planner_agent.py Generates a three‑step study plan.
translator_agent.py Translates English text to Hindi.
motivator_agent.py Returns a short motivational quote.

3. Create the Study Manager Agent

Uses LangChain’s AgentExecutor with a custom prompt that can invoke the three tools.

4. Run a Sample Query

Give me a study plan to learn AI agents in English and Hindi, and motivate me.

You’ll see the manager orchestrate the three tools and return a single, polished response.


📁 Project Files

# Save as: planner_agent.py

# Save as: planner_agent.py
from langchain.tools import BaseTool
from langchain.schema import AgentAction, AgentFinish
from typing import Any, Dict, List, Tuple
import json

class PlannerTool(BaseTool):
 """Tool that creates a three‑step study plan."""

 name = "make_study_plan"
 description = (
 "Creates a concise three‑step study plan for a given subject. "
 "Input should be a short description of the learning goal."
 )

 def _run(self, goal: str) -> str:
 # Simple deterministic plan – replace with LLM if you wish.
 steps = [
 f"1️⃣ Understand the fundamentals of {goal}.",
 f"2️⃣ Build hands‑on projects related to {goal}.",
 f"3️⃣ Review and iterate on what you learned."
 ]
 return "\n".join(steps)

 async def _arun(self, goal: str) -> str:
 raise NotImplementedError("PlannerTool does not support async")

# Save as: translator_agent.py

# Save as: translator_agent.py
from langchain.tools import BaseTool
import openai

class TranslatorTool(BaseTool):
 """Translates English text to Hindi using OpenAI's gpt‑3.5‑turbo."""

 name = "translate_to_hindi"
 description = (
 "Translates the given English text into Hindi. "
 "Input should be a plain English string."
 )

 def _run(self, text: str) -> str:
 response = openai.ChatCompletion.create(
 model="gpt-3.5-turbo",
 messages=[
 {"role": "system", "content": "You are a translator that converts English to Hindi."},
 {"role": "user", "content": text}
 ],
 temperature=0,
 )
 hindi = response["choices"][0]["message"]["content"].strip()
 return hindi

 async def _arun(self, text: str) -> str:
 raise NotImplementedError("TranslatorTool does not support async")

# Save as: motivator_agent.py

# Save as: motivator_agent.py
from langchain.tools import BaseTool
import random

class MotivatorTool(BaseTool):
 """Returns a short motivational message."""

 name = "give_motivation"
 description = (
 "Provides a concise motivational quote or encouragement. "
 "No input is required."
 )

 _quotes = [
 "Believe you can and you're halfway there.",
 "The only limit to our realization of tomorrow is our doubts today.",
 "Success is the sum of small efforts, repeated day in and day out.",
 "Keep going – every step forward is progress."
 ]

 def _run(self) -> str:
 return random.choice(self._quotes)

 async def _arun(self) -> str:
 raise NotImplementedError("MotivatorTool does not support async")

# Save as: study_manager.py

# Save as: study_manager.py
import os
from langchain.agents import AgentExecutor, Tool, ZeroShotAgent, AgentOutputParser
from langchain.prompts import PromptTemplate
from langchain.llms import OpenAI
from langchain.schema import AgentAction, AgentFinish
from typing import List, Union
import json

# Import the three tool classes we just created
from planner_agent import PlannerTool
from translator_agent import TranslatorTool
from motivator_agent import MotivatorTool

# ----------------------------------------------------------------------
# 1️⃣ Build the LangChain tools list
# ----------------------------------------------------------------------
tools: List[Tool] = [
 PlannerTool(),
 TranslatorTool(),
 MotivatorTool(),
]

# ----------------------------------------------------------------------
# 2️⃣ Create a custom prompt that tells the manager how to use tools
# ----------------------------------------------------------------------
template = """You are a **Study Manager**. Your job is to help a student by:
1. Generating a study plan (use `make_study_plan`).
2. Translating the plan to Hindi if requested (use `translate_to_hindi`).
3. Adding a motivational message if requested (use `give_motivation`).

When you need to call a tool, output a JSON object:
{{"action": "<tool_name>", "input": "<tool_input>"}}
If you have all the information, output the final answer directly.

User query: {input}
"""

prompt = PromptTemplate(
 template=template,
 input_variables=["input"],
)

# ----------------------------------------------------------------------
# 3️⃣ Define a simple parser that reads the JSON action format
# ----------------------------------------------------------------------
class SimpleJSONParser(AgentOutputParser):
 def parse(self, llm_output: str) -> Union[AgentAction, AgentFinish]:
 try:
 data = json.loads(llm_output.strip())
 if "action" in data and "input" in data:
 return AgentAction(tool=data["action"], tool_input=data["input"], log=llm_output)
 else:
 # Assume final answer
 return AgentFinish(return_values={"output": llm_output}, log=llm_output)
 except json.JSONDecodeError:
 # If not JSON, treat as final answer
 return AgentFinish(return_values={"output": llm_output}, log=llm_output)

# ----------------------------------------------------------------------
# 4️⃣ Assemble the agent
# ----------------------------------------------------------------------
llm = OpenAI(temperature=0) # deterministic output for reproducibility
agent = ZeroShotAgent(llm=llm, tools=tools, prompt=prompt, output_parser=SimpleJSONParser())
manager_executor = AgentExecutor.from_agent_and_tools(agent=agent, tools=tools, verbose=True)

def ask_manager(query: str) -> str:
 """Convenient wrapper for the manager."""
 result = manager_executor.run(query)
 return result

# Save as: main.py

# Save as: main.py
from study_manager import ask_manager

def main():
 query = (
 "Give me a study plan to learn AI agents in English and Hindi, "
 "and motivate me."
 )
 print("🗣️ Student asks:", query)
 print("\n🤖 Study Manager replies:\n")
 answer = ask_manager(query)
 print(answer)

if __name__ == "__main__":
 main()

🚀 What You Just Built

Running python main.py produces a trace of the manager’s reasoning (because verbose=True) followed by a single, polished response:

🗣️ Student asks: Give me a study plan to learn AI agents in English and Hindi, and motivate me.

🤖 Study Manager replies:

Here’s your three‑step study plan for AI agents, plus a Hindi translation and a quick boost of motivation:

**Study Plan (English)**
1️⃣ Understand the fundamentals of AI agents.
2️⃣ Build hands‑on projects related to AI agents.
3️⃣ Review and iterate on what you learned.

**Study Plan (Hindi)**
AI एजेंट्स के मूल सिद्धांतों को समझें।
AI एजेंट्स से संबंधित प्रोजेक्ट्स बनाएं।
जो आपने सीखा है उसकी समीक्षा करें और दोहराएँ।

**Motivation**
Success is the sum of small efforts, repeated day in and day out.

The console also shows the internal tool calls:

Calling tool make_study_plan with input: AI agents
Calling tool translate_to_hindi with input: <English plan>
Calling tool give_motivation with input: 

Verify: The output contains the English plan, Hindi translation, and a motivational quote—all generated by separate specialist tools but presented as one cohesive answer.


Common Mistakes

Mistake Why It Happens Fix
Forgetting to set OPENAI_API_KEY The Translator tool calls OpenAI and fails without credentials. Export the key or set os.environ["OPENAI_API_KEY"] before running.
Returning a non‑JSON string from a tool The manager’s parser expects JSON for tool calls. Ensure each tool’s _run returns a plain string; only the manager should emit JSON.
Using async methods without an async loop Tools only implement _run, not _arun. Call the synchronous run method (as we do in ask_manager).
Duplicate tool names LangChain identifies tools by name. Keep each name unique (make_study_plan, translate_to_hindi, give_motivation).

Troubleshooting

  1. No output from Translator Check that the OpenAI request succeeded (no rate‑limit error). Tip: Add print(response) inside TranslatorTool._run to debug.

  2. Manager loops forever This usually means the LLM never produced valid JSON. Solution: Lower temperature to 0 (deterministic) or adjust the prompt to be more explicit.

  3. Motivational quote repeats The random.choice may pick the same quote on successive runs. Fix: Expand the _quotes list or seed random with random.seed() for reproducibility.


Key Takeaways

  • Agent‑as‑Tool: Wrap any specialized LLM logic in a BaseTool to expose it to a higher‑level manager.
  • Separation of Concerns: Each specialist focuses on a single responsibility, making testing and maintenance trivial.
  • Orchestration Logic: The manager’s prompt + JSON‑based parser give you full control over when and how tools are invoked.
  • Scalability: Adding a new specialist (e.g., a summarizer) only requires a new tool class and a line in the tools list—no changes to the manager’s core code.

🧪 Try It Yourself

  1. Add a Summarizer
  2. Create summarizer_agent.py that shortens a paragraph.
  3. Register it in study_manager.py.
  4. Update the manager prompt to allow a summarize action.

  5. Make the Planner LLM‑driven Replace the deterministic plan in PlannerTool._run with a call to openai.ChatCompletion that generates a custom plan based on the user’s exact goal.

  6. Switch to Async Implement _arun for each tool and use await manager_executor.ainvoke(query) in an async main().

Happy building! 🚀

Why This Matters

A polished, production‑ready AI‑powered Study Manager that can cheer up users, fetch relevant resources, and answer questions on the fly is the culmination of everything you’ve learned so far. By the end of this chapter you’ll have a single executable script that:

  1. Instantiates the core agent.
  2. Wraps the agent as a reusable tool (CheerUpTool).
  3. Registers the tool alongside the existing ResourceFetcher and QnAResponder.
  4. Exposes a clean command‑line interface (CLI) for the study manager to invoke the tool chain.

Having this end‑to‑end flow in one place means you can ship the solution to non‑technical stakeholders (e.g., a study coordinator) without them needing to understand the internals.


What You’ll Build

A stand‑alone CLI application called study_manager.py that:

  • Loads the three tools (CheerUpTool, ResourceFetcher, QnAResponder).
  • Creates a StudyAgent that can orchestrate calls to any of those tools.
  • Accepts a user prompt from the terminal, runs the agent, and prints a friendly, context‑aware response.

Running the script will look like this:

$ python study_manager.py
> I'm feeling stuck with my statistics homework.
Cheer up! 🎉 Remember, every problem is a stepping stone. Here’s a quick tip:.

Content

1️⃣ Define the Cheer‑Up Tool

# Save as: cheer_up_tool.py
import random
from typing import Any, Dict

class CheerUpTool:
 """
 A simple tool that returns a random encouraging message.
 """

 name = "cheer_up"
 description = "Provides a friendly, uplifting message to the user."

 def __call__(self, _: Dict[str, Any] = None) -> str:
 messages = [
 "You’ve got this! 💪",
 "Keep pushing—you’re closer than you think! 🚀",
 "Every step forward counts. Stay curious! 🌟",
 "Remember, mistakes are proof you’re trying. 🎉",
 "Take a deep breath and keep going. 🌈"
 ]
 return random.choice(messages)

Expected output (when called directly):

>>> from cheer_up_tool import CheerUpTool
>>> CheerUpTool()()
'You’ve got this! 💪'

✅ Verify: Run the snippet above in a Python REPL; you should see one of the five messages.


2️⃣ Assemble All Tools in a Registry

# Save as: tool_registry.py
from typing import Dict, Callable

from cheer_up_tool import CheerUpTool
from resource_fetcher import ResourceFetcher # assumed from earlier chapters
from qna_responder import QnAResponder # assumed from earlier chapters

def get_tool_registry() -> Dict[str, Callable]:
 """
 Returns a dictionary mapping tool names to callable instances.
 """
 return {
 CheerUpTool.name: CheerUpTool(),
 ResourceFetcher.name: ResourceFetcher(),
 QnAResponder.name: QnAResponder(),
 }

✅ Verify: Import get_tool_registry and print the keys; you should see cheer_up, resource_fetcher, and qna_responder.


3️⃣ Build the Core Agent

# Save as: study_agent.py
from typing import Dict, Any
import json

class StudyAgent:
 """
 Orchestrates calls to registered tools based on a simple rule‑engine.
 """

 def __init__(self, tools: Dict[str, Callable]):
 self.tools = tools

 def _select_tool(self, user_input: str) -> str:
 """
 Very naive selector:
 - If the user mentions 'cheer' or feels'sad', use cheer_up.
 - If the user asks a factual question, use qna_responder.
 - Otherwise, fall back to resource_fetcher.
 """
 lowered = user_input.lower()
 if any(word in lowered for word in ["sad", "stuck", "frustrated", "down"]):
 return "cheer_up"
 if "?" in user_input:
 return "qna_responder"
 return "resource_fetcher"

 def run(self, user_input: str) -> str:
 tool_name = self._select_tool(user_input)
 tool = self.tools.get(tool_name)

 if tool is None:
 return "Sorry, I couldn't find a suitable tool."

 # Most tools ignore the input dict; we keep the signature uniform.
 result = tool()
 # For QnAResponder we might want to pass the query; adapt if needed.
 if tool_name == "qna_responder":
 result = tool({"question": user_input})
 elif tool_name == "resource_fetcher":
 result = tool({"query": user_input})

 # Combine the tool output with a friendly prefix.
 return f"{result}"

Sample run (isolated):

>>> from study_agent import StudyAgent
>>> from tool_registry import get_tool_registry
>>> agent = StudyAgent(get_tool_registry())
>>> print(agent.run("I'm feeling down about my exam."))
You’ve got this! 💪

✅ Verify: Execute the snippet above; you should receive one of the cheer‑up messages.


4️⃣ Create the CLI Entrypoint

# Save as: study_manager.py
import sys
from study_agent import StudyAgent
from tool_registry import get_tool_registry

def main() -> None:
 """
 Simple REPL that forwards user input to the StudyAgent.
 Exit with Ctrl‑C or an empty line.
 """
 agent = StudyAgent(get_tool_registry())
 print("🧑‍🏫 Study Manager – ask anything or type 'exit' to quit.")
 while True:
 try:
 user_input = input("> ").strip()
 if user_input.lower() in {"exit", "quit", ""}:
 print("Good luck with your studies! 🎓")
 break
 response = agent.run(user_input)
 print(response)
 except KeyboardInterrupt:
 print("\nGood luck with your studies! 🎓")
 break
 except Exception as e:
 # ⚠️ Unexpected error – show a friendly message but keep the REPL alive.
 print(f"❗ Oops! Something went wrong: {e}")

if __name__ == "__main__":
 main()

Running the CLI

$ python study_manager.py
🧑‍🏫 Study Manager – ask anything or type 'exit' to quit.
> I'm stuck on linear regression.
Here’s a quick tip: remember that the slope is.
> I'm feeling sad about my progress.
You’ve got this! 💪
> exit
Good luck with your studies! 🎓

✅ Verify: Execute python study_manager.py and try the three example prompts above. Each should trigger the appropriate tool.


📁 Project Files

File Purpose
cheer_up_tool.py Generates random encouraging messages.
resource_fetcher.py (From earlier chapters) Retrieves study resources.
qna_responder.py (From earlier chapters) Answers factual questions.
tool_registry.py Central registry that bundles all tools.
study_agent.py Core orchestration logic.
study_manager.py CLI entry point for end users.
requirements.txt Lists third‑party dependencies (e.g., requests, openai).

requirements.txt (add if you haven’t already):

requests>=2.28
openai>=1.0

Install with:

pip install -r requirements.txt

✅ Verify: pip install -r requirements.txt completes without errors.


🚀 What You Just Built

You now have a complete, runnable AI assistant that:

  • Detects user sentiment and automatically offers encouragement.
  • Answers questions using the QnAResponder.
  • Fetches supplemental material via ResourceFetcher.

All of this is wrapped in a single, user‑friendly command‑line tool that a study manager can hand to students without any Python knowledge.


Common Mistakes

Mistake Symptom Fix
Forgetting to import a tool in tool_registry.py NameError: name 'ResourceFetcher' is not defined Add the missing import line.
Returning a dict from a tool when the agent expects a string Agent prints {'answer':.} instead of plain text Ensure each tool’s __call__ returns a string.
Using input() inside a Jupyter notebook cell The cell hangs waiting for terminal input Run the script from a terminal, not inside a notebook.
Not handling KeyboardInterrupt REPL crashes on Ctrl‑C The except KeyboardInterrupt block already handles this; keep it.

💡 Tip: Keep the tool signatures uniform (Callable[[Dict], str]) even if a tool ignores the argument. This prevents future mismatches.


Troubleshooting

  1. “No module named ‘resource_fetcher’” Check that resource_fetcher.py exists in the same directory and that the filename matches the import case.

  2. Agent always selects cheer_up regardless of input The _select_tool logic is case‑insensitive but looks for specific keywords. Verify the list of keywords or adjust the condition.

  3. OpenAI API errors in QnAResponder Make sure you have set the OPENAI_API_KEY environment variable.

bash export OPENAI_API_KEY="sk-."

  1. Empty response from ResourceFetcher Confirm the external API endpoint is reachable and that you pass the correct query dict ({"query": user_input}).

If you encounter an unexpected exception, the REPL will print a friendly error prefixed with . Use that message to locate the offending line.


Key Takeaways

  • Tool‑centric design keeps each capability isolated and testable.
  • A registry makes it trivial to add, remove, or replace tools without touching the agent logic.
  • A simple rule‑engine (_select_tool) can be swapped for a more sophisticated LLM‑driven router later.
  • Providing a CLI abstracts away all the plumbing, delivering a clean user experience.

You now have a solid foundation to iterate on: replace the naive selector with a language‑model router, add more domain‑specific tools, or even expose the agent via a web API.


🧪 Try It Yourself

  1. Add a new “MotivationQuoteTool” that returns a random quote from a local JSON file. Register it in tool_registry.py and extend _select_tool to use it when the user says “motivate me”.

  2. Replace the rule‑engine with an OpenAI‑driven router: send the user prompt to gpt-4o and ask it to output the name of the tool to invoke.

  3. Dockerize the whole application. Write a Dockerfile that copies the source, installs dependencies, and sets ENTRYPOINT ["python", "study_manager.py"].

  4. Write unit tests for StudyAgent._select_tool using pytest. Verify that each keyword classifies correctly.

Happy coding! 🎉


What's Next

Continue to the next chapter to keep building.

Chapter 10

Feeding LinkedIn Data into Your Agent

Why This Matters


Before You Start This Chapter

  • Your venv is active (you see `(.

    What is a virtual environment (venv)? It is a separate, isolated Python world for your project. Without it, packages from different projects can conflict. With a venv, each project gets its own clean set of packages. Think of it like a separate browser profile for each website.

venv)in your prompt) - Previous chapter's project is working - Your.env` file has your API key

What is an API? An API (Application Programming Interface) is how one program talks to another. Think of it like a waiter in a restaurant — you (your code) tell the waiter (API) what you want, the waiter goes to the kitchen (the server), and brings back your food (the response). You never talk to the kitchen directly.

  • You are in the correct folder (run pwd to check)

Get Your OpenAI API Key

  1. Go to https://platform.openai.com/api-keys
  2. Log in (free account)
  3. Click "Create new secret key"
  4. Name it (e.g., "adhikk")
  5. Click "Create"
  6. Copy the key (starts with sk-) — you will not see it again
  7. Paste into your .env file

New accounts get $5 free credit — enough for this entire book.

Create & Activate Virtual Environment

uv venv

Activate (Windows PowerShell):

.venv\Scripts\Activate

Activate (Mac/Linux):

source .venv/bin/activate

How you'll know it worked: Your prompt shows (.venv) at the start.

AI agents are becoming the backbone of modern productivity tools. By the end of this chapter you will have a single, self‑contained Python project that demonstrates how to:

  1. Load a personal knowledge base (your LinkedIn profile) from a PDF.
  2. Answer user queries with a small LLM (OpenAI’s gpt‑3.5‑turbo).
  3. Enrich the LLM with external services:
  4. Real‑time desktop notifications.
  5. Email sending.
  6. Calendar slot lookup (mocked for simplicity).
  7. Web search fallback when the LLM lacks knowledge.
  8. Persist every interaction in a SQLite “leads” database for later analysis.

All of this is built without any hidden steps—just copy the files, run the commands, and you have a functional AI assistant.


What You’ll Build

A folder called ai_assistant/ containing:

File Purpose
requirements.txt Pin all third‑party packages.
pyproject.toml Minimal Poetry configuration (optional).
pdf_reader.py Extract plain text from a LinkedIn PDF.
notification.py Push a desktop notification (cross‑platform).
emailer.py Send an email via SMTP.
calendar.py Mock a calendar lookup that returns free slots.
web_search.py Query Google (via SerpAPI) when the LLM says I don’t know.
leads_db.py Store every conversation in SQLite.
assistant.py Orchestrates everything – the “brain” of the project.
run.py Tiny CLI that lets you chat with the agent.

When you run python run.py you’ll be able to type a question, get an answer, and see a desktop notification appear instantly.


Content

1. Set Up the Environment

# Save as: setup.sh
python -m venv.venv
source.venv/bin/activate # On Windows:.venv\Scripts\activate
pip install -r requirements.txt

✅ Verify: After activation, pip list should show the packages from requirements.txt.


2. PDF Parsing – pdf_reader.py

# Save as: pdf_reader.py
import pathlib
from typing import List
from pypdf import PdfReader

def extract_text_from_pdf(pdf_path: pathlib.Path) -> str:
> **What is PATH?** PATH is a list of folders your computer checks when you type a command. If you type `python`, your computer looks in each PATH folder for a file called `python.exe`. If it finds it, it runs it. If not, you get "command not found." Installing Python with "Add to PATH" checked adds Python's folder to this list.


 """
 Reads *all* pages of a PDF and returns a single string.
 Empty pages are ignored.
 """
 reader = PdfReader(str(pdf_path))
 full_text = []
 for page in reader.pages:
 txt = page.extract_text()
 if txt:
 full_text.append(txt.strip())
 return "\n".join(full_text)

if __name__ == "__main__":
 # Quick sanity check
 pdf_file = pathlib.Path("LinkedIn.pdf")
 print(extract_text_from_pdf(pdf_file)[:300])

Expected output (first 300 characters of your LinkedIn PDF):

John Doe
AI Engineer & Instructor.

✅ Verify: The script prints a non‑empty string; otherwise check the PDF path.


3. Desktop Notification – notification.py

# Save as: notification.py
import platform
from plyer import notification

def push_notification(title: str, message: str) -> None:
 """
 Cross‑platform desktop notification.
 """
 notification.notify(
 title=title,
 message=message,
 app_name="AI Assistant",
 timeout=5,
 )

if __name__ == "__main__":
 push_notification("Test", "If you see this, notifications work!")

Expected output: A small pop‑up on your desktop saying Test – If you see this, notifications work!

✅ Verify: You see the pop‑up. If not, ensure plyer supports your OS (Linux may need notify-send).


4. Email Sending – emailer.py

# Save as: emailer.py
import smtplib
from email.message import EmailMessage
from typing import Tuple

def send_email(
 smtp_server: str,
 smtp_port: int,
 credentials: Tuple[str, str],
 subject: str,
 body: str,
 to_addr: str,
) -> None:
 """
 Sends a plain‑text email via SMTP.
 """
 user, password = credentials
 msg = EmailMessage()
 msg["From"] = user
 msg["To"] = to_addr
 msg["Subject"] = subject
 msg.set_content(body)

 with smtplib.SMTP_SSL(smtp_server, smtp_port) as server:
 server.login(user, password)
 server.send_message(msg)

if __name__ == "__main__":
 # Demo – replace with real credentials before running
 send_email(
 smtp_server="smtp.gmail.com",
 smtp_port=465,
 credentials=("you@example.com", "your_app_password"),
 subject="AI Assistant Test",
 body="Hello from your AI assistant!",
 to_addr="friend@example.com",
 )

Expected output: No console output; check the recipient inbox for the test email.

✅ Verify: Email arrives. If Gmail blocks the login, enable App passwords or Less secure apps.


5. Calendar Slot Lookup – calendar.py

⚠️ Real calendar APIs (Google, Outlook) require OAuth flows. For this chapter we provide a mock that returns a deterministic free slot.

# Save as: calendar.py
from datetime import datetime, timedelta

def get_next_free_slot(duration_minutes: int = 30) -> str:
 """
 Returns a human‑readable string representing the next free slot.
 In a real project you would query Google Calendar or Outlook.
 """
 now = datetime.now()
 # Assume the next free slot starts 1 hour from now
 start = now + timedelta(hours=1)
 end = start + timedelta(minutes=duration_minutes)
 return f"{start.strftime('%Y-%m-%d %H:%M')} – {end.strftime('%H:%M')}"

if __name__ == "__main__":
 print("Next free slot:", get_next_free_slot())

Expected output:

Next free slot: 2026-09-15 14:30 – 15:00

✅ Verify: The printed slot is in the future relative to your current time.


6. Web Search Fallback – web_search.py

# Save as: web_search.py
import os
import requests
from typing import List

SERPAPI_KEY = os.getenv("SERPAPI_KEY") # Obtain from https://serpapi.com/

def google_search(query: str, num_results: int = 3) -> List[str]:
 """
 Calls SerpAPI's Google Search endpoint and returns the top snippets.
 """
 params = {
 "engine": "google",
 "q": query,
 "api_key": SERPAPI_KEY,
 "num": num_results,
 }
 resp = requests.get("https://serpapi.com/search", params=params, timeout=10)
 resp.raise_for_status()
 data = resp.json()
 snippets = [result["snippet"] for result in data.get("organic_results", [])]
 return snippets

if __name__ == "__main__":
 for s in google_search("What is LangChain?"):
 print("- ", s)

Expected output (example snippets):

- LangChain is a framework for developing applications powered by language models.
- It provides composability, memory, and tool‑integration utilities.
- LangChain supports LLMs from OpenAI, Anthropic, Cohere, and more.

✅ Verify: You see three bullet points; if you get an error, ensure SERPAPI_KEY is set.


7. Leads Database – leads_db.py

# Save as: leads_db.py
import sqlite3
from datetime import datetime
from typing import Tuple, List

DB_PATH = "leads.db"

def init_db() -> None:
 conn = sqlite3.connect(DB_PATH)
 cur = conn.cursor()
 cur.execute(
 """
 CREATE TABLE IF NOT EXISTS interactions (
 id INTEGER PRIMARY KEY AUTOINCREMENT,
 timestamp TEXT NOT NULL,
 user_query TEXT NOT NULL,
 assistant_reply TEXT NOT NULL
 )
 """
 )
 conn.commit()
 conn.close()

def store_interaction(user_query: str, assistant_reply: str) -> None:
 conn = sqlite3.connect(DB_PATH)
 cur = conn.cursor()
 cur.execute(
 "INSERT INTO interactions (timestamp, user_query, assistant_reply) VALUES (?, ?, ?)",
 (datetime.utcnow().isoformat(), user_query, assistant_reply),
 )
 conn.commit()
 conn.close()

def fetch_all() -> List[Tuple[int, str, str, str]]:
 conn = sqlite3.connect(DB_PATH)
 cur = conn.cursor()
 cur.execute("SELECT * FROM interactions ORDER BY id DESC")
 rows = cur.fetchall()
 conn.close()
 return rows

if __name__ == "__main__":
 init_db()
 store_interaction("Hello", "Hi there!")
 print(fetch_all())

Expected output (example):

[(1, '2026-09-15T12:34:56.789012', 'Hello', 'Hi there!')]

✅ Verify: The table is created and a row appears.


8. The Core Assistant – assistant.py

# Save as: assistant.py
import os
import pathlib
from typing import Optional

import openai

from pdf_reader import extract_text_from_pdf
from notification import push_notification
from emailer import send_email
from calendar import get_next_free_slot
from web_search import google_search
from leads_db import init_db, store_interaction

# ----------------------------------------------------------------------
# Configuration – replace with your own keys
# ----------------------------------------------------------------------
openai.api_key = os.getenv("OPENAI_API_KEY")
SERPAPI_KEY = os.getenv("SERPAPI_KEY") # used indirectly by web_search
EMAIL_CREDS = (os.getenv("EMAIL_USER"), os.getenv("EMAIL_PASS"))
SMTP_SERVER = "smtp.gmail.com"
SMTP_PORT = 465

# ----------------------------------------------------------------------
# Load personal knowledge base (LinkedIn PDF)
# ----------------------------------------------------------------------
PROFILE_PDF = pathlib.Path("LinkedIn.pdf")
if PROFILE_PDF.is_file():
 PROFILE_TEXT = extract_text_from_pdf(PROFILE_PDF)
else:
 PROFILE_TEXT = ""
 print("⚠️ Warning: LinkedIn.pdf not found – the agent will have no personal context.")

# ----------------------------------------------------------------------
# Prompt engineering helper
# ----------------------------------------------------------------------
SYSTEM_PROMPT = f"""You are an AI assistant for a software engineer.
You have access to the following personal data:
{PROFILE_TEXT[:1000]} # truncated for brevity

When you do NOT know an answer, respond with the exact phrase:
"I don't know, let me look it up."

When you see that phrase, the caller will invoke the web search tool.
"""

def ask_llm(user_query: str) -> str:
 """Calls OpenAI ChatCompletion and returns the assistant's reply."""
 response = openai.ChatCompletion.create(
 model="gpt-3.5-turbo",
 messages=[
 {"role": "system", "content": SYSTEM_PROMPT},
 {"role": "user", "content": user_query},
 ],
 temperature=0.2,
 )
 return response.choices[0].message["content"].strip()

# ----------------------------------------------------------------------
# High‑level orchestrator
# ----------------------------------------------------------------------
def handle_query(user_query: str) -> str:
 # 1️⃣ Store raw query for analytics
 init_db() # safe‑idempotent call
 # 2️⃣ Ask the LLM
 answer = ask_llm(user_query)

 # 3️⃣ If LLM says it doesn't know, fallback to web search
 if "I don't know, let me look it up." in answer:
 snippets = google_search(user_query)
 answer = "\n".join(snippets) or "Sorry, I couldn't find anything."

 # 4️⃣ Push a desktop notification (real‑time feedback)
 push_notification("AI Assistant", answer[:100] + ".")

 # 5️⃣ Example side‑effects (email & calendar) – triggered by keywords
 if "email" in user_query.lower():
 send_email(
 smtp_server=SMTP_SERVER,
 smtp_port=SMTP_PORT,
 credentials=EMAIL_CREDS,
 subject="Message from your AI Assistant",
 body=answer,
 to_addr="friend@example.com",
 )
 if "schedule" in user_query.lower() or "meeting" in user_query.lower():
 slot = get_next_free_slot()
 answer += f"\n\n🗓️ Suggested free slot: {slot}"

 # 6️⃣ Persist interaction
 store_interaction(user_query, answer)

 return answer

if __name__ == "__main__":
 # Simple REPL for manual testing
 print("🤖 AI Assistant ready – type 'exit' to quit.")
 while True:
 q = input("\nYou: ")
 if q.strip().lower() in {"exit", "quit"}:
 break
 resp = handle_query(q)
 print("\nAssistant:", resp)

Expected REPL interaction (sample):

🤖 AI Assistant ready – type 'exit' to quit.

You: What is LangChain?

Assistant: LangChain is a framework for developing applications powered by language models.
It provides composability, memory, and tool‑integration utilities.

A desktop notification appears with the first 100 characters of the answer.

✅ Verify: * The REPL prints a sensible answer. * A notification pops up. * If you ask “Send me an email with the summary”, an email is dispatched. * If you ask “Schedule a meeting”, the assistant appends a suggested slot. * The SQLite DB now contains the conversation.


📁 Project Files

ai_assistant/
├─ requirements.txt
├─ pyproject.toml # optional, for Poetry users
├─ LinkedIn.pdf # your exported LinkedIn profile
├─ pdf_reader.py
├─ notification.py
├─ emailer.py
├─ calendar.py
├─ web_search.py
├─ leads_db.py
├─ assistant.py
└─ run.py # tiny wrapper that just calls assistant.handle_query()

requirements.txt

openai==1.30.0
pypdf==4.2.0
plyer==2.1.0
requests==2.32.3
sqlite3==2.6.0 # built‑in, listed for completeness

pyproject.toml (optional)

[tool.poetry]
name = "ai_assistant"
version = "0.1.0"
description = "A minimal AI assistant with notifications, email, calendar, web search, and lead storage."
authors = ["Your Name <you@example.com>"]

[tool.poetry.dependencies]
python = "^3.10"
openai = "^1.30.0"
pypdf = "^4.2.0"
plyer = "^2.1.0"
requests = "^2.32.3"

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

run.py

# Save as: run.py
from assistant import handle_query

def main():
 print("🚀 AI Assistant – type 'exit' to stop.")
 while True:
 q = input("\nYou: ")
 if q.lower() in {"exit", "quit"}:
 break
 print("\nAssistant:", handle_query(q))

if __name__ == "__main__":
 main()

🚀 What You Just Built

Feature How It Works
Personal Knowledge Base PDF → plain text → injected into system prompt.
LLM Reasoning OpenAI gpt‑3.5‑turbo answers queries.
Live Notification plyer.notify fires as soon as the answer is ready.
Email smtplib.SMTP_SSL sends a message when the query contains “email”.
Calendar Mock function returns the next free hour‑long slot.
Web Search If LLM says I don’t know, google_search fetches top snippets via SerpAPI.
Lead Storage Every (timestamp, query, answer) row is saved in leads.db.

All components are loosely coupled – you can replace the mock calendar with a real Google Calendar integration without touching the rest of the code.


Common Mistakes

Mistake Why It Happens Fix
Forgetting to set OPENAI_API_KEY or SERPAPI_KEY. Environment variables are not exported. export OPENAI_API_KEY=sk-. and export SERPAPI_KEY=your_key.
Using a PDF that contains scanned images. pypdf can only extract text layers. Run OCR first (e.g., pytesseract) or use a text‑based PDF.
Desktop notifications not appearing on Linux. plyer relies on notify-send. Install libnotify-bin (sudo apt install libnotify-bin).
Email rejected by Gmail. Gmail blocks “less secure” logins. Enable App passwords or use a dedicated SMTP service.
SQLite file locked. Multiple processes writing simultaneously. Keep a single writer (the assistant) or use a connection pool.

Troubleshooting

  1. No notification appears
  2. Run python -c "import plyer; print(plyer.notification.notify.__doc__)" – if it raises an ImportError, reinstall plyer.
  3. On macOS, ensure the script has permission to post notifications (System Settings → Notifications).

  4. OpenAI returns a 401 error

  5. Double‑check the API key, and make sure you haven’t exceeded the free‑tier quota.

  6. SerpAPI returns “quota exceeded”

  7. Verify your account’s remaining credits; you can also lower num_results to 1.

  8. SQLite DB is empty after a session

  9. Ensure init_db() runs before store_interaction(). The call is idempotent, but if you accidentally edited leads_db.py and removed the call, the table won’t exist.

  10. PDF parsing returns an empty string

  11. Open the PDF in a viewer and confirm you can select text. If not, the PDF is image‑only; run OCR first.

Key Takeaways

  • Modular design lets you swap out any external service (email, calendar, search) without rewriting the core logic.
  • Prompt engineering is crucial – we embed the personal knowledge base directly into the system prompt.
  • Graceful degradation (fallback to web search) makes the assistant feel always‑on even when the LLM lacks knowledge.
  • Persisting interactions gives you a data source for future analytics or fine‑tuning.

🧪 Try It Yourself

  1. Add a new tool – e.g., a Slack notifier. Create slack_notifier.py that posts a message via Slack’s Web API, then call it from assistant.handle_query when the user mentions “Slack”.

  2. Replace the mock calendar – follow Google’s Calendar API quickstart, store the OAuth token, and modify `calendar.

    What is a token? A token is how AI models count text. Think of it as a "word piece" — roughly 3-4 characters. "Hello" = 1 token. "Hello world" = 2 tokens. Models have a limit on how many tokens they can process at once (called the "context window").

get_next_free_slot` to query real events.

  1. Fine‑tune the LLM – export all rows from leads.db into a JSONL file and use OpenAI’s fine‑tuning endpoint to create a custom model that better reflects your personal style.

Happy building! 🎉

Why This Matters

Modern AI agents are only as useful as the way people can interact with them. A clean backend that knows who you are and what you can do is half the battle—​the other half is a front‑end that lets anyone type a question and get an instant answer. By the end of this chapter you’ll have a fully functional web UI (via Gradio) that:

  • Keeps conversation history so the agent can maintain context.
  • Exposes a single chat function that any front‑end can call.
  • Runs locally with a public URL (via Gradio’s sharing feature) – no extra deployment steps required.

⚠️ Why you need this – Without a UI you’d have to run Jupyter cells or curl commands every time you want a response. That’s fine for experiments, but not for real users (recruiters, clients, students) who expect a polished chat experience.


What You’ll Build

A two‑file Python project:

File Purpose
agent.py Defines the Ishant AI agent, loads the system prompt, and provides a run_agent helper that talks to the LLM.
app.py Spins up a Gradio chat interface, passes user messages (and history) to run_agent, and displays the response.

When you run app.py you’ll see a local URL like http://127.0.0.1:7860 and a shareable link (https://.gradio.live). Opening that link gives you a chat window titled “Chat with Ishant”.

Verify: After completing the steps, opening the URL should show a chat UI where you can ask “Tell me about yourself in three lines.” and receive a concise answer.


Content

1️⃣ Create the Agent (agent.py)

# Save as: agent.py
import os
import json
from typing import List, Dict, Any

# ⚡ Use OpenAI's ChatCompletion API – replace with your provider if needed
import openai

# ----------------------------------------------------------------------
# 1️⃣ System prompt – defines Ishant’s personality and extra rules
# ----------------------------------------------------------------------
SYSTEM_PROMPT = """
You are Ishant, a data scientist and AI educator based in Delhi.
You answer questions on Ishant's personal website from visitors who may be recruiters, potential clients, students, or collaborators.
Speak in first person as Ishant. Be warm, direct, professional.
Keep answers short: 3‑4 sentences unless asked for detail.
"""

# ----------------------------------------------------------------------
# 2️⃣ Helper to call the LLM
# ----------------------------------------------------------------------
def run_agent(user_message: str, history: List[Dict[str, str]]) -> str:
 """
 Sends the conversation (system prompt + history + new user message) to the LLM
 and returns the assistant's reply.
 """
 # Build the messages list expected by OpenAI
 messages = [{"role": "system", "content": SYSTEM_PROMPT}]
 # Append prior turns (if any)
 messages.extend(history)
 # Append the new user turn
 messages.append({"role": "user", "content": user_message})

 # Call the model – you must set OPENAI_API_KEY in your environment
 response = openai.ChatCompletion.create(
 model="gpt-4o-mini", # Change to the model you have access to
 messages=messages,
 temperature=0.7,
 max_tokens=500,
 )
 # Extract the assistant's reply
 reply = response.choices[0].message["content"].strip()
 return reply

# ----------------------------------------------------------------------
# 3️⃣ Simple test harness (run `python agent.py` to sanity‑check)
# ----------------------------------------------------------------------
if __name__ == "__main__":
 # Minimal test: no prior history
 test_msg = "Hi Ishant, tell me about yourself in three lines."
 print("User:", test_msg)
 answer = run_agent(test_msg, [])
 print("\nIshant:", answer)

Expected output (your answer may vary slightly):

User: Hi Ishant, tell me about yourself in three lines.

Ishant: I’m Ishant, a data scientist and AI educator based in Delhi. I build practical AI agents and LLM‑powered solutions that deliver measurable business impact. I love teaching, publishing research, and helping organizations adopt responsible AI.

Verify: Run python agent.py. You should see a concise three‑sentence introduction.


2️⃣ Build the Gradio Front‑End (app.py)

# Save as: app.py
import os
import gradio as gr
from typing import List, Tuple

# Import the agent helper we just wrote
from agent import run_agent

# ----------------------------------------------------------------------
# 3️⃣ Chat function – called by Gradio on every user turn
# ----------------------------------------------------------------------
def chat(user_message: str, chat_history: List[Tuple[str, str]]) -> List[Tuple[str, str]]:
 """
 Parameters
 ----------
 user_message : str
 The latest message typed by the user.
 chat_history : List[Tuple[str, str]]
 Existing conversation history in (user, assistant) pairs.

 Returns
 -------
 List[Tuple[str, str]]
 Updated history with the assistant's new reply appended.
 """
 # Convert Gradio's tuple history into OpenAI's message format
 # Gradio stores as [(user, assistant),.]
 # We need a flat list of dicts: [{"role": "user",.}, {"role": "assistant",.},.]
 formatted_history = []
 for user, assistant in chat_history:
 formatted_history.append({"role": "user", "content": user})
 formatted_history.append({"role": "assistant", "content": assistant})

 # Get the assistant's reply from the LLM
 assistant_reply = run_agent(user_message, formatted_history)

 # Append the new turn to the history and return
 chat_history.append((user_message, assistant_reply))
 return chat_history

# ----------------------------------------------------------------------
# 4️⃣ Launch Gradio Interface
# ----------------------------------------------------------------------
def launch_interface():
 """
 Spins up a Gradio chat UI.
 """
 with gr.Blocks() as demo:
 gr.Markdown(
 """
 # 🤖 Chat with Ishant 
 Ask me anything about my work, background, or AI projects. 
 *Answers are short, warm, and professional.* 
 """
 )
 chatbot = gr.Chatbot()
 msg = gr.Textbox(
 placeholder="Type your question here.",
 label="Your Message",
 lines=1,
 )
 clear = gr.Button("Clear Chat")

 # Define interaction
 msg.submit(
 fn=chat,
 inputs=[msg, chatbot],
 outputs=chatbot,
 )
 clear.click(lambda: [], None, chatbot, queue=False)

 # Launch with sharing enabled (public URL)
 demo.launch(
 server_name="0.0.0.0", # Makes it reachable on your LAN
 server_port=7860,
 share=True, # Generates a public https://.gradio.live link
 )

if __name__ == "__main__":
 launch_interface()

What you should see after running python app.py:

  1. Terminal output similar to:
Running on local URL: http://127.0.0.1:7860
Running on public URL: https://xxxxxx.gradio.live
  1. A browser window (or you can copy the public URL) showing a chat UI with:

  2. Title “Chat with Ishant”

  3. A text box for typing questions
  4. A scrolling area that displays the conversation

Ask: Hi Ishant, tell me about yourself in three lines.

You’ll receive the same concise answer as in the test harness.

Verify: Open the URL, type the example question, and confirm the response appears in the chat window.


📁 Project Files

my_ishant_agent/
│
├─ agent.py # LLM wrapper + system prompt
└─ app.py # Gradio UI that calls agent.py

Make sure both files sit in the same directory so app.py can import run_agent from agent.py.


🚀 What You Just Built

Component Role
System Prompt Encodes Ishant’s personality, tone, and response length constraints.
run_agent Sends a full conversation (system + history + new message) to the LLM and returns the reply.
Chat History Preserves prior turns, enabling context‑aware answers.
Gradio UI Provides a zero‑code front‑end: text input, chat display, clear button, and a shareable URL.
Public Share Link Lets anyone on your network (or the internet) interact with the agent without installing anything.

You now have a complete, production‑ready AI chat agent that can be shown to recruiters, clients, or students with a single click.


Common Mistakes

Mistake Why it Happens Fix
Missing OPENAI_API_KEY The LLM call fails with authentication error. Export the key before running: export OPENAI_API_KEY=sk-. (Linux/macOS) or set it in Windows env vars.
Using the wrong model name gpt-4o-mini may not be available on your plan. Replace with a model you have access to, e.g., gpt-3.5-turbo.
History not preserved Returning only the new reply instead of the updated list. Ensure chat returns chat_history (the list of tuples).
Port conflict Another service already uses 7860. Change server_port in demo.launch() to an unused port, e.g., 7870.
Gradio not installed ImportError: No module named 'gradio'. Run pip install gradio openai.

Troubleshooting

  1. “Invalid request error – model not found” Check your OpenAI dashboard for the exact model name you have access to.

  2. “Rate limit reached” Add a time.sleep(1) between calls or upgrade your quota.

  3. Chat UI freezes after the first message Make sure msg.submit is wired to chat with both inputs (msg, chatbot) and that chat returns the updated history.

  4. Public URL not reachable Gradio’s sharing service may be blocked on corporate networks. Use the local URL or set up a tunnel (e.g., ngrok).

💡 Tip: Keep a small log file (log.txt) inside chat to dump the raw messages payload. This helps debug why the LLM returned an unexpected answer.

import json, datetime
def chat(.):
 #. existing code.
 with open("log.txt", "a") as f:
 f.write(f"{datetime.datetime.now()} | payload: {json.dumps(formatted_history)}\n")
 #. rest of function.

Key Takeaways

  • System prompts are the single source of truth for an agent’s personality.
  • Conversation history must be transformed into the LLM’s expected format on every turn.
  • Gradio turns a few lines of Python into a fully functional, shareable UI—no HTML/CSS needed.
  • A clean separation (agent.py vs. app.py) makes the codebase maintainable and easy to extend (e.g., add authentication, logging, or a different front‑end).

🧪 Try It Yourself

  1. Add a “knowledge base” – create a JSON file faq.json with Q/A pairs and modify run_agent to prepend relevant snippets before calling the LLM.

  2. Enable streaming responses – replace openai.ChatCompletion.create with openai.ChatCompletion.create(., stream=True) and feed tokens to Gradio in real time.

  3. Deploy to the cloud – push app.py to a free tier on Render, Fly.io, or Railway and share the permanent URL with your network.

Happy building! 🎉

Why This Matters

In real‑world workflows an LLM is rarely just a “chat‑only” bot. When the model detects a time‑sensitive request (e.g., “Can we schedule a 30‑minute call tomorrow?”) you want the system to escalate – push a notification to your phone, fire an email, create a calendar event, etc.

By wiring a lightweight push‑notification service (Push Over) to your LLM‑driven agent you get:

  • Immediate awareness of high‑priority messages.
  • Zero‑code mobile alerts – no need for a custom mobile app or Firebase.
  • A clear pattern you can reuse for other side‑effects (emails, Slack, SMS).

What You’ll Build

A minimal, end‑to‑end Python project that:

  1. Loads your Push Over credentials from a .env file.
  2. Wraps the Push Over HTTP API in a reusable Notifier class.
  3. Runs a simple LLM “agent” that reads incoming messages, decides (via a prompt) whether a notification is required, and triggers it automatically.
  4. Logs the conversation history so the model can keep context across turns.

You’ll be able to run the script, type a few example messages, and see a real push notification appear on your phone.


Content

1️⃣ Install the required packages

pip install openai python-dotenv requests

💡 Tip: Use a virtual environment (python -m venv.venv) to keep dependencies isolated.

2️⃣ Create the environment file

Create a file named .env in the project root and paste your Push Over credentials (you obtained them from the Push Over dashboard).

# Save as:.env
PUSHOVER_USER=your_user_key_here
PUSHOVER_TOKEN=your_api_token_here
OPENAI_API_KEY=your_openai_api_key_here

Verify: The file exists and contains the three keys (no extra spaces).

3️⃣ Implement the Push Over notifier

# Save as: notifier.py
import os
import requests
from dotenv import load_dotenv

load_dotenv() # Load.env variables into the environment

class Notifier:
 """Simple wrapper around PushOver's HTTP API."""
 PUSHOVER_ENDPOINT = "https://api.pushover.net/1/messages.json"

 def __init__(self):
 self.user = os.getenv("PUSHOVER_USER")
 self.token = os.getenv("PUSHOVER_TOKEN")
 if not self.user or not self.token:
 raise ValueError("PushOver credentials not found in environment variables.")

 def send(self, title: str, message: str, priority: int = 0) -> None:
 """Send a notification to the registered device."""
 payload = {
 "token": self.token,
 "user": self.user,
 "title": title,
 "message": message,
 "priority": priority, # 1 = high priority (bypass quiet hours)
 }
 response = requests.post(self.PUSHOVER_ENDPOINT, data=payload, timeout=5)
 response.raise_for_status() # Raise an exception on HTTP error

# Quick sanity‑check (run `python -c "import notifier; notifier.Notifier().send('Test','Hello from the book!')"` )

Verify: Run the sanity‑check line above. You should feel a vibration on your phone with the title Test.

4️⃣ Build the LLM‑driven agent

# Save as: agent.py
import os
import json
import openai
from datetime import datetime
from typing import List, Dict

# Load OpenAI key from environment (already loaded by dotenv in notifier)
openai.api_key = os.getenv("OPENAI_API_KEY")

SYSTEM_PROMPT = """You are a helpful personal assistant. 
When a user asks for something that requires immediate human attention (e.g., scheduling a call, sending a resume, urgent hiring request), respond with the JSON object:
{
 "notify": true,
 "title": "<short title for the notification>",
 "message": "<detailed message for the notification>"
}
For all other queries, respond with:
{
 "notify": false,
 "reply": "<your normal conversational answer>"
}
Only output valid JSON, no extra text."""

def call_llm(messages: List[Dict[str, str]]) -> Dict:
 """Calls OpenAI's chat completion and returns parsed JSON."""
 response = openai.ChatCompletion.create(
 model="gpt-3.5-turbo",
 messages=messages,
 temperature=0.2,
 )
 # The model's content should be a JSON string
 content = response.choices[0].message["content"]
 try:
 return json.loads(content)
 except json.JSONDecodeError as e:
 # Fallback: return a safe dict if parsing fails
 return {"notify": False, "reply": f"⚠️ Could not parse LLM response: {e}"}

class Assistant:
 """Keeps conversation history and decides when to notify."""
 def __init__(self):
 self.history: List[Dict[str, str]] = [{"role": "system", "content": SYSTEM_PROMPT}]

 def add_user_message(self, text: str) -> None:
 self.history.append({"role": "user", "content": text})

 def process(self) -> Dict:
 result = call_llm(self.history)
 # Append the LLM's raw response to history for context
 self.history.append({"role": "assistant", "content": json.dumps(result)})
 return result

Verify: Import Assistant in a Python REPL and call process() after adding a user message. It should return a dictionary with either notify true or false.

5️⃣ Wire everything together

# Save as: main.py
import os
from datetime import datetime
from notifier import Notifier
from agent import Assistant

def main():
 print("🤖 LLM Assistant – type 'exit' to quit.\n")
 notifier = Notifier()
 assistant = Assistant()

 while True:
 user_input = input("You: ").strip()
 if user_input.lower() == "exit":
 break

 # Feed the message to the LLM
 assistant.add_user_message(user_input)
 result = assistant.process()

 # Handle the LLM's decision
 if result.get("notify"):
 title = result.get("title", "Alert")
 message = result.get("message", "You have a new notification.")
 # Append timestamp for clarity
 timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
 full_message = f"[{timestamp}] {message}"
 try:
 notifier.send(title, full_message, priority=1)
 print(f"🔔 Notification sent: {title}")
 except Exception as exc:
 print(f"⚠️ Failed to send notification: {exc}")
 else:
 reply = result.get("reply", "I didn't understand that.")
 print(f"Assistant: {reply}")

if __name__ == "__main__":
 main()

Expected interactive session

🤖 LLM Assistant – type 'exit' to quit.

You: Hi, can we schedule a 30‑minute call tomorrow?
🔔 Notification sent: Schedule Call
You: What is the capital of France?
Assistant: The capital of France is Paris.
You: Please send my resume to hiring@acme.com
🔔 Notification sent: Send Resume
You: exit

When the two “notify” messages appear, you’ll feel a vibration on your phone with the corresponding titles.

Verify: Run python main.py, type the sample messages above, and confirm you receive two push notifications.


📁 Project Files

your-project/
│
├─.env # ← your PushOver & OpenAI credentials
├─ notifier.py # ← PushOver wrapper
├─ agent.py # ← LLM prompt & conversation manager
└─ main.py # ← CLI entry point

🚀 What You Just Built

Component Responsibility
.env Securely stores API keys (no hard‑coding).
notifier.py Sends a single HTTP POST to Push Over; raises on failure.
agent.py Maintains chat history, crafts the system prompt, parses LLM JSON output.
main.py CLI loop, decides whether to push a notification or just reply.

Together they demonstrate LLM‑driven decision making + real‑time mobile alerts – a pattern you can extend to Slack, email, or any webhook.


Common Mistakes

Mistake Why it Happens Fix
Missing .env variables Notifier raises ValueError. Double‑check the variable names (PUSHOVER_USER, PUSHOVER_TOKEN, OPENAI_API_KEY).
LLM returns non‑JSON Prompt not strict enough or temperature too high. Keep temperature=0.2 and use the exact system prompt provided.
PushOver rate‑limit Sending many notifications quickly. Use priority=0 for low‑urgency messages or batch them.
Network timeout Slow internet or firewall blocks. Increase timeout in requests.post or test with curl first.

Troubleshooting

  1. No notification arrives
  2. Verify the user key and token in .env.
  3. Run a manual test: bash python -c "from notifier import Notifier; Notifier().send('Test','Hello')"
  4. Check Push Over dashboard → “Messages” for any error codes.

  5. LLM response cannot be parsed

  6. Print the raw content before json.loads to see what the model returned.
  7. If the model adds extra text, tighten the system prompt or set response_format (OpenAI API v1.0+ supports JSON schema).

  8. OpenAI authentication error

  9. Ensure OPENAI_API_KEY is correct and has enough quota.
  10. Run openai api models.list from the terminal to confirm connectivity.

Key Takeaways

  • Push Over gives you a zero‑setup push‑notification channel that works with a single HTTP POST.
  • By asking the LLM to output structured JSON, you can let the model decide when to trigger side‑effects.
  • Keeping conversation history in the Assistant class lets the model maintain context across multiple turns.
  • The pattern—LLM → decision JSON → external tool—is reusable for email, Slack, calendar APIs, etc.

🧪 Try It Yourself

  1. Add a new rule: Extend the system prompt so the model also notifies you when a user asks for a “price quote”.
  2. Swap Push Over: Replace Notifier with a simple SMTP email sender and watch the same JSON drive an email instead of a phone ping.
  3. Persist history: Store assistant.history to a JSON file after each turn and reload it on startup, so the agent remembers past conversations across runs.

Happy hacking! 🚀

Why This Matters

Modern applications rarely live in isolation. They need to talk to other services, fetch data, and push updates. Understanding how to:

  • expose a clean REST API (GET & POST)
  • call external tools (e.g., an LLM) from inside your service
  • keep the service stateless and testable

is essential for building production‑grade systems that can evolve independently.


What You'll Build

A tiny, self‑contained Order Service that:

  1. GET /order/<order_id> – returns an estimated delivery time.
  2. POST /send_message – accepts a JSON payload and pretends to forward it to a registered device.
  3. Calls an LLM (via OpenAI’s API) to compute a realistic delivery estimate based on order metadata.

All code runs locally; no extra research required.


Content

Step Description
1️⃣ Set up the Flask API (order_service.py).
2️⃣ Add a thin LLM client (llm_client.py) that talks to OpenAI.
3️⃣ Wire the LLM into the GET endpoint.
4️⃣ Implement the POST endpoint for device messaging.
5️⃣ Write a client script (client.py) to exercise the API.
6️⃣ Run the whole stack and verify the output.

📁 Project Files

1️⃣ order_service.py

# Save as: order_service.py
from flask import Flask, request, jsonify, abort
import uuid
from llm_client import get_estimated_delivery

app = Flask(__name__)

# In‑memory store for registered devices (device_id → info)
registered_devices = {
 "device-123": {"owner": "Alice", "type": "mobile"},
 "device-456": {"owner": "Bob", "type": "tablet"},
}

# Dummy order database (order_id → details)
orders = {
 "order-001": {"items": ["book", "pen"], "destination": "New York"},
 "order-002": {"items": ["laptop"], "destination": "San Francisco"},
}


@app.route("/order/<order_id>", methods=["GET"])
def get_order_status(order_id):
 """
 Returns an estimated delivery time for the given order.
 The estimate is generated by an LLM (simulated via OpenAI).
 """
 order = orders.get(order_id)
 if not order:
 abort(404, description="Order not found")

 # Call the LLM to get a human‑readable estimate
 estimate = get_estimated_delivery(order_id, order["destination"], order["items"])
 return jsonify({
 "order_id": order_id,
 "estimated_delivery": estimate,
 })


@app.route("/send_message", methods=["POST"])
def send_message():
 """
 Accepts a JSON payload:
 {
 "device_id": "device-123",
 "message": "Your order is on the way!"
 }
 The endpoint pretends to forward the message to the device.
 """
 data = request.get_json()
 if not data:
 abort(400, description="Invalid JSON payload")

 device_id = data.get("device_id")
 message = data.get("message")

 if not device_id or not message:
 abort(400, description="Both 'device_id' and 'message' are required")

 device = registered_devices.get(device_id)
 if not device:
 abort(404, description="Device not registered")

 # Simulate sending – in a real system you'd push via MQTT, FCM, etc.
 print(f"[SIMULATION] Sent to {device_id} ({device['owner']}): {message}")

 return jsonify({
 "status": "sent",
 "device_id": device_id,
 "message_id": str(uuid.uuid4())
 }), 202


if __name__ == "__main__":
 # Run on localhost:5000
 app.run(debug=True)

Expected output when you start the server

 * Serving Flask app 'order_service'
 * Debug mode: on
WARNING: This is a development server. Do not use it in production.
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)

✅ Verify: Run python order_service.py and see the message above.


2️⃣ llm_client.py

# Save as: llm_client.py
import os
import openai
from typing import List

# -------------------------------------------------
# ⚠️ IMPORTANT: You need an OpenAI API key.
# Set it in your environment before running:
# export OPENAI_API_KEY="sk-."
# -------------------------------------------------

openai.api_key = os.getenv("OPENAI_API_KEY")

def _format_prompt(order_id: str, destination: str, items: List[str]) -> str:
 items_str = ", ".join(items)
 return (
 f"You are a logistics assistant. Estimate the delivery time for order "
 f"**{order_id}** that contains {items_str} and is headed to {destination}. "
 f"Give a short, human‑readable answer like '2‑3 business days'."
 )

def get_estimated_delivery(order_id: str, destination: str, items: List[str]) -> str:
 """
 Calls OpenAI's Chat Completion endpoint and returns a plain‑text estimate.
 """
 prompt = _format_prompt(order_id, destination, items)

 try:
 response = openai.ChatCompletion.create(
 model="gpt-3.5-turbo",
 messages=[{"role": "user", "content": prompt}],
 temperature=0.2, # low randomness for consistent answers
 max_tokens=30,
 )
 # Extract the assistant's reply
 estimate = response.choices[0].message.content.strip()
 return estimate
 except Exception as e:
 # Fallback to a deterministic placeholder if the API fails
 print(f"[LLM] Warning: {e}. Using fallback estimate.")
 return "3‑5 business days (fallback)"

Sample console output when the LLM is called

[LLM] Warning: Invalid API key. Using fallback estimate.

✅ Verify: With a valid OPENAI_API_KEY you should see no warning and get a realistic estimate.


3️⃣ client.py

# Save as: client.py
import requests
import json

BASE_URL = "http://127.0.0.1:5000"

def get_order(order_id: str):
 resp = requests.get(f"{BASE_URL}/order/{order_id}")
 if resp.status_code == 200:
 print("✅ GET /order response:")
 print(json.dumps(resp.json(), indent=2))
 else:
 print(f"❌ GET failed ({resp.status_code}): {resp.text}")

def post_message(device_id: str, message: str):
 payload = {"device_id": device_id, "message": message}
 resp = requests.post(f"{BASE_URL}/send_message", json=payload)
 if resp.status_code == 202:
 print("✅ POST /send_message response:")
 print(json.dumps(resp.json(), indent=2))
 else:
 print(f"❌ POST failed ({resp.status_code}): {resp.text}")

if __name__ == "__main__":
 # 1️⃣ Test the GET endpoint
 get_order("order-001")

 # 2️⃣ Test the POST endpoint
 post_message("device-123", "Your package is out for delivery.")

Expected output when you run the client (assuming the server is up and the LLM key is valid)

✅ GET /order response:
{
 "order_id": "order-001",
 "estimated_delivery": "2‑3 business days"
}
✅ POST /send_message response:
{
 "status": "sent",
 "device_id": "device-123",
 "message_id": "a1b2c3d4-5678-90ab-cdef-1234567890ab"
}
[SIMULATION] Sent to device-123 (Alice): Your order is on the way!

✅ Verify: With the Flask server running, execute python client.py and compare the output to the block above.


4️⃣ requirements.txt

Flask==2.3.3
requests==2.31.0
openai==1.3.5

Install with:

pip install -r requirements.txt

✅ Verify: No import errors when you start order_service.py.


🚀 What You Just Built

Component Responsibility
order_service.py Exposes two REST endpoints (GET /order/<id> & POST /send_message).
llm_client.py Encapsulates the OpenAI call, keeping the API layer clean.
client.py Demonstrates how a consumer (mobile app, another microservice) talks to your service.
requirements.txt Pin‑points exact third‑party versions for reproducibility.

You now have a stand‑alone microservice that can:

  • Query an LLM for dynamic business logic (delivery estimate).
  • Push messages to registered devices (simulated).
  • Be tested with a simple Python client.

Common Mistakes

Mistake Why it Happens Fix
Forgetting to set OPENAI_API_KEY The LLM client raises an authentication error. Export the key in your shell: export OPENAI_API_KEY="sk-.".
Using GET for a payload‑heavy request GET URLs have length limits and no body. Use POST (as we did for /send_message).
Hard‑coding URLs in production Ties code to a single environment. Externalize with environment variables (API_HOST, API_PORT).
Not handling network failures The LLM call can time‑out. Wrap the call in a try/except and provide a fallback (already done).

Troubleshooting

Symptom Likely Cause Remedy
ConnectionError when running client.py Flask server not running or wrong port. Start order_service.py first; verify http://127.0.0.1:5000 is reachable.
404 Not Found for /order/xyz Order ID not present in orders dict. Add the ID to orders or use an existing one (order-001, order-002).
LLM returns a long paragraph instead of a short estimate Prompt not specific enough. Adjust _format_prompt to ask for a concise answer (already done).
ImportError: No module named 'openai' Dependencies not installed. Run pip install -r requirements.txt.

Key Takeaways

  • REST APIs are the lingua franca for inter‑service communication.
  • Keep business logic (LLM calls) separate from request handling – it makes testing easier.
  • Always fail gracefully: provide deterministic fallbacks when external tools are unavailable.
  • A tiny, well‑documented service can be run locally with a single python command, yet scales to production with minimal changes.

🧪 Try It Yourself

  1. Add a new order to orders (e.g., "order-003": {"items": ["phone"], "destination": "Chicago"}) and query it with the client.
  2. Swap the LLM: replace openai.ChatCompletion.create with a local mock function that returns "1 business day" – see how the service behaves offline.
  3. Persist devices: change registered_devices to a tiny SQLite DB using sqlite3 (bonus exercise).

Happy coding! 🚀


What's Next

Continue to the next chapter to keep building.

Chapter 11

Wiring Up Email and Notifications

Why This Matters


Before You Start This Chapter

  • Your venv is active (you see `(.

    What is a virtual environment (venv)? It is a separate, isolated Python world for your project. Without it, packages from different projects can conflict. With a venv, each project gets its own clean set of packages. Think of it like a separate browser profile for each website.

venv)in your prompt) - Previous chapter's project is working - Your.env` file has your API key

What is an API? An API (Application Programming Interface) is how one program talks to another. Think of it like a waiter in a restaurant — you (your code) tell the waiter (API) what you want, the waiter goes to the kitchen (the server), and brings back your food (the response). You never talk to the kitchen directly.

  • You are in the correct folder (run pwd to check)

Get Your OpenAI API Key

  1. Go to https://platform.openai.com/api-keys
  2. Log in (free account)
  3. Click "Create new secret key"
  4. Name it (e.g., "adhikk")
  5. Click "Create"
  6. Copy the key (starts with sk-) — you will not see it again
  7. Paste into your .env file

New accounts get $5 free credit — enough for this entire book.

Create & Activate Virtual Environment

uv venv

Activate (Windows PowerShell):

.venv\Scripts\Activate

Activate (Mac/Linux):

source .venv/bin/activate

How you'll know it worked: Your prompt shows (.venv) at the start.

Install Python (if not already installed)

  1. Go to https://www.python.org/downloads/
  2. Click "Download Python 3.12.x"
  3. Run the installer
  4. CHECK the box: Add python.exe to PATH

    What is PATH? PATH is a list of folders your computer checks when you type a command. If you type python, your computer looks in each PATH folder for a file called python.exe. If it finds it, it runs it. If not, you get "command not found." Installing Python with "Add to PATH" checked adds Python's folder to this list.

  5. Click "Install Now"

  6. Close and reopen your terminal

Verify:

python --version

Expected output:

Python 3.12.x

Push notifications are the fastest way to get real‑time alerts on a mobile device. When you combine them with a Large Language Model (LLM) you can turn a chatbot into a proactive assistant that notifies you the moment a user asks for something you need to act on (e.g., “schedule a call”, “hire me”, “ask a question I can’t answer”).

In this chapter you will learn how to:

  1. Send a Pushover notification from pure Python.
  2. Wrap the notification function as an LLM‑callable tool using a tiny decorator.
  3. Integrate the tool into a simple OpenAI‑based agent so the LLM decides when to fire the notification automatically.

All the code is ready‑to‑run; you only need a Pushover account and an OpenAI API key.


What You’ll Build

Component Purpose
push_notification.py Sends a Pushover message (send_push(title, message)).
tools.py Decorator that turns any Python function into an LLM‑friendly tool (@llm_tool).
agent.py Minimal OpenAI chat agent that loads the tools and calls them when the LLM decides it’s appropriate.
.env Stores secrets (PUSHOVER_USER_KEY, PUSHOVER_API_TOKEN, OPENAI_API_KEY).
unknown_questions.log Simple log file where the agent records questions it cannot answer.

After completing the chapter you will have a self‑contained notification system that can be dropped into any Python project.

Verify: Run the test script at the end of the chapter and you should see a push notification on your phone and a printed confirmation in the console.


Content

1. Set Up Your Environment

  1. Create a virtual environment (optional but recommended).

bash python -m venv.venv source.venv/bin/activate # Windows:.venv\Scripts\activate

  1. Install dependencies

bash pip install python-dotenv requests openai

  1. Create a .env file in the project root and fill in the values you obtain from Pushover and OpenAI:

text # Save as:.env PUSHOVER_USER_KEY=your_pushover_user_key PUSHOVER_API_TOKEN=your_pushover_api_token OPENAI_API_KEY=sk-.

⚠️ Never commit .env to version control – it contains secrets.

2. Send a Pushover Notification

The core of the system is a single‑line HTTP POST to Pushover’s API. push_notification.py encapsulates that logic and returns the HTTP status code.

# Save as: push_notification.py
import os
import requests
from dotenv import load_dotenv

# Load environment variables once at import time
load_dotenv()

PUSHOVER_USER = os.getenv("PUSHOVER_USER_KEY")
PUSHOVER_TOKEN = os.getenv("PUSHOVER_API_TOKEN")
PUSHOVER_URL = "https://api.pushover.net/1/messages.json"


def send_push(title: str, message: str) -> int:
 """
 Send a push notification via Pushover.

 Parameters
 ----------
 title : str
 The notification title (appears bold on the device).
 message : str
 The body of the notification.

 Returns
 -------
 int
 HTTP status code returned by Pushover (200 means success).

 Raises
 ------
 RuntimeError
 If the Pushover credentials are missing.
 """
 if not PUSHOVER_USER or not PUSHOVER_TOKEN:
 raise RuntimeError(
 "Pushover not configured – check PUSHOVER_USER_KEY and PUSHOVER_API_TOKEN in.env"
 )

 payload = {
 "token": PUSHOVER_TOKEN,
> **What is a token?** A token is how AI models count text. Think of it as a "word piece" — roughly 3-4 characters. "Hello" = 1 token. "Hello world" = 2 tokens. Models have a limit on how many tokens they can process at once (called the "context window").


 "user": PUSHOVER_USER,
 "title": title,
 "message": message,
 }

 response = requests.post(PUSHOVER_URL, data=payload, timeout=10)
 return response.status_code

Expected output (when called from a script):

✅ Notification sent – status code: 200

Verify: Run the snippet below in a Python REPL.

from push_notification import send_push

status = send_push("Test Title", "Hello from the book chapter!")
print(f"✅ Notification sent – status code: {status}")

You should feel a vibration or see a banner on your phone.

3. Turn a Normal Function into an LLM‑Callable Tool

LLMs understand docstrings and function signatures. A tiny decorator can expose that metadata to the model.

# Save as: tools.py
import json
import inspect
from functools import wraps
from typing import Callable, Any, Dict

# Registry that the agent will read
TOOL_REGISTRY: Dict[str, Callable] = {}


def llm_tool(func: Callable) -> Callable:
 """
 Decorator that registers a function as an LLM‑accessible tool.

 The function’s name, signature, and docstring are stored in TOOL_REGISTRY.
 The wrapper simply forwards arguments to the original function.
 """
 @wraps(func)
 def wrapper(*args, **kwargs) -> Any:
 return func(*args, **kwargs)

 # Store a JSON‑serialisable description for the LLM
 sig = inspect.signature(func)
 TOOL_REGISTRY[func.__name__] = {
 "function": wrapper,
 "description": func.__doc__ or "",
 "parameters": {
 "type": "object",
 "properties": {
 name: {"type": "string", "description": str(param.annotation)}
 for name, param in sig.parameters.items()
 },
 "required": list(sig.parameters.keys()),
 },
 }
 return wrapper


def get_tool_specifications() -> str:
 """
 Return a JSON string that can be sent to the LLM as the list of available tools.
 """
 spec = {
 name: {
 "description": meta["description"],
 "parameters": meta["parameters"],
 }
 for name, meta in TOOL_REGISTRY.items()
 }
 return json.dumps(spec, indent=2)

💡 Tip: Keep the decorator in a separate module so you can reuse it across projects.

4. Define the Notification & Logging Tools

# Save as: agent_tools.py
from tools import llm_tool
from push_notification import send_push
import datetime

UNKNOWN_LOG = "unknown_questions.log"


@llm_tool
def notify_me(title: str, message: str) -> str:
 """
 Send an instant push notification to the site owner.

 Parameters
 ----------
 title : str
 Short headline for the notification.
 message : str
 Detailed message body.

 Returns
 -------
 str
 Confirmation text for the LLM to include in its response.
 """
 status = send_push(title, message)
 if status == 200:
 return f"✅ Notification delivered (title: '{title}')."
 else:
 return f"⚠️ Failed to deliver notification (status {status})."


@llm_tool
def record_unknown_question(question: str) -> str:
 """
 Persist a question the LLM could not answer.

 Parameters
 ----------
 question : str
 The user query that lacked an answer.

 Returns
 -------
 str
 Confirmation that the question was logged.
 """
 timestamp = datetime.datetime.utcnow().isoformat()
 with open(UNKNOWN_LOG, "a", encoding="utf-8") as f:
 f.write(f"{timestamp} | {question}\n")
 return "✅ Question recorded for later review."

5. Build a Minimal OpenAI Agent

The agent sends the user’s message to OpenAI, receives a response that may contain a function call request, and executes the appropriate tool.

# Save as: agent.py
import os
import json
import openai
from tools import TOOL_REGISTRY, get_tool_specifications
import agent_tools # This import registers the tools via the decorator

# Load OpenAI key from.env (already loaded by tools.py if you import it first)
openai.api_key = os.getenv("OPENAI_API_KEY")


def chat_with_agent(user_input: str) -> str:
 """
 Send a user message to the LLM and let it decide whether to call a tool.

 Returns the final textual response that would be shown to the user.
 """
 # 1️⃣ Build the system prompt that explains the tools
 system_prompt = (
 "You are a helpful assistant for a professional website. "
 "When a user asks to schedule a call, hire you, or any request that "
 "requires immediate human attention, call the `notify_me` tool. "
 "If you cannot answer a question, call `record_unknown_question` with the original query. "
 "Otherwise, answer normally."
 )

 # 2️⃣ Send the request with tool specifications
 response = openai.ChatCompletion.create(
 model="gpt-3.5-turbo-1106", # Supports function calling
 messages=[
 {"role": "system", "content": system_prompt},
 {"role": "user", "content": user_input},
 ],
 functions=json.loads(get_tool_specifications()),
 function_call="auto", # Let the model decide
 )

 # 3️⃣ Inspect if a function call was requested
 message = response["choices"][0]["message"]
 if message.get("function_call"):
 func_name = message["function_call"]["name"]
 arguments = json.loads(message["function_call"]["arguments"])
 tool = TOOL_REGISTRY[func_name]["function"]
 result = tool(**arguments) # Execute the real Python function
 # 4️⃣ Send the result back to the model so it can incorporate it
 follow_up = openai.ChatCompletion.create(
 model="gpt-3.5-turbo-1106",
 messages=[
 {"role": "system", "content": system_prompt},
 {"role": "user", "content": user_input},
 {"role": "assistant", "content": None, "function_call": message["function_call"]},
 {"role": "function", "name": func_name, "content": result},
 ],
 )
 final_reply = follow_up["choices"][0]["message"]["content"]
 return final_reply
 else:
 # No tool needed – just return the LLM answer
 return message["content"]


if __name__ == "__main__":
 # Simple interactive loop
 print("🤖 Agent ready – type 'exit' to quit.")
 while True:
 user_msg = input("\nYou: ")
 if user_msg.lower() in {"exit", "quit"}:
 break
 reply = chat_with_agent(user_msg)
 print(f"\nAgent: {reply}")

Sample run (you’ll see a push notification on your phone):

🤖 Agent ready – type 'exit' to quit.

You: Hi, can we schedule a 30‑minute call this week?

Agent: ✅ Notification delivered (title:'Schedule Request'). I’ve notified the owner and will get back to you shortly.

You: What is your hourly rate for workshops?

Agent: ✅ Question recorded for later review.

Verify: Run python agent.py, type a request like “Please schedule a 15‑minute call tomorrow.” and confirm you receive a push notification.


📁 Project Files

my_notification_project/
│
├─.env # ← your secrets (do NOT commit)
├─ push_notification.py
├─ tools.py
├─ agent_tools.py
├─ agent.py
└─ unknown_questions.log # ← created automatically

🚀 What You Just Built

  • A reusable Pushover wrapper (send_push).
  • A generic decorator (@llm_tool) that registers any function as an LLM‑callable tool.
  • Two concrete tools: notify_me (real‑time alerts) and record_unknown_question (persistent log).
  • A minimal OpenAI chat agent that decides when to invoke those tools, turning ordinary user queries into actionable notifications.

Common Mistakes

Mistake Symptom Fix
Missing .env variables RuntimeError: Pushover not configured Double‑check the keys and reload the script.
Using an outdated Pushover token 401 Unauthorized from Pushover Regenerate the token on the Pushover dashboard and update .env.
Forgetting to import agent_tools No tools appear in the LLM’s function list Ensure import agent_tools is executed before creating the agent.
Running the script without internet requests.exceptions.ConnectionError Verify network connectivity.
Using a model that doesn’t support function calling Invalid request from OpenAI Switch to gpt-3.5-turbo-1106 or newer.

Troubleshooting

  1. No push notification arrives
  2. Print the status code returned by send_push. Anything other than 200 indicates a problem.
  3. Verify that the device you registered with Pushover is online and that the app has notification permissions.

  4. Agent never calls a tool

  5. Ensure the system prompt clearly tells the model when to use each tool.
  6. Check that function_call="auto" is set; otherwise the model will never attempt a call.

  7. unknown_questions.log stays empty

  8. The LLM may be answering the question directly. Adjust the system prompt to be stricter about logging unknown queries.

Key Takeaways

  • One‑line HTTP POST is enough to deliver a reliable push notification.
  • Docstrings + signatures = LLM‑readable metadata; a tiny decorator can expose any function as a tool.
  • Function calling lets you keep the LLM “stateless” while still performing side‑effects (notifications, logging, database writes).
  • Separation of concerns: notification logic lives in push_notification.py, tool registration in tools.py, and business rules in agent.py. This makes the codebase easy to extend.

🧪 Try It Yourself

  1. Add a new tool that writes a CSV row to a spreadsheet whenever a user submits a contact form.
  2. Replace Pushover with another service (e.g., Telegram Bot API) – only push_notification.py needs to change.
  3. Experiment with prompts: ask the LLM to summarize the last three notifications and send the summary via email.

Happy coding! 🚀

Why This Matters

When you move from a prototype to a production‑ready AI assistant, reliable communication channels become essential. Push notifications are great for a quick “tap on the shoulder,” but they don’t leave a permanent record. Email, on the other hand, gives you an immutable trail, can be forwarded, archived, or acted upon later. By wiring SendGrid into our agent we achieve:

  • Auditability – every unknown query is logged in an email you can reference weeks later.
  • Scalability – SendGrid’s free tier handles 100 emails/day, more than enough for development and small‑scale deployments.
  • Professionalism – stakeholders receive nicely formatted HTML emails instead of cryptic logs.

What You’ll Build

A version‑3 agent that:

  1. Detects an unknown user query.
  2. Sends a push notification (as before).
  3. Emails the query details to a pre‑configured address via SendGrid.
  4. Stores the new Q&A pair for future training (simulated in‑memory for this chapter).

All code runs locally; you only need a free SendGrid account and a verified sender email.


Content

1️⃣ Set Up Environment Variables

Create a .env file (never commit real secrets). It holds:

# Save as:.env
SENDGRID_API_KEY=YOUR_SENDGRID_API_KEY
SENDGRID_SENDER=verified_sender@example.com # Must be verified in SendGrid
EMAIL_RECIPIENT=your_inbox@example.com # Where the agent emails go

💡 Tip – Use a separate “notification” email address (e.g., agent-notify@yourdomain.com) to keep inboxes tidy.

2️⃣ Install Dependencies

pip install python-dotenv requests gradio

3️⃣ Build the SendGrid Email Helper

# Save as: sendgrid_email.py
import os
import json
import base64
import requests
from dotenv import load_dotenv

load_dotenv() # Load.env variables into the environment

SENDGRID_API_KEY = os.getenv("SENDGRID_API_KEY")
SENDER_EMAIL = os.getenv("SENDGRID_SENDER")
RECIPIENT_EMAIL = os.getenv("EMAIL_RECIPIENT")

if not all([SENDGRID_API_KEY, SENDER_EMAIL, RECIPIENT_EMAIL]):
 raise EnvironmentError(
 "Missing SENDGRID_API_KEY, SENDGRID_SENDER, or EMAIL_RECIPIENT in.env"
 )

def send_email(subject: str, html_content: str) -> bool:
 """
 Sends an HTML email via SendGrid.
 Returns True on success, False otherwise.
 """
 url = "https://api.sendgrid.com/v3/mail/send"
 headers = {
 "Authorization": f"Bearer {SENDGRID_API_KEY}",
 "Content-Type": "application/json",
 }

 payload = {
 "personalizations": [
 {
 "to": [{"email": RECIPIENT_EMAIL}],
 "subject": subject,
 }
 ],
 "from": {"email": SENDER_EMAIL, "name": "AI Agent"},
 "content": [
 {
 "type": "text/html",
 "value": html_content,
 }
 ],
 }

 response = requests.post(url, headers=headers, data=json.dumps(payload))
 if response.status_code == 202:
 print("✅ Email sent successfully.")
 return True
 else:
 print(f"⚠️ Failed to send email: {response.status_code} – {response.text}")
 return False

# ✅ Verify: Run this snippet to test your SendGrid configuration.
if __name__ == "__main__":
 test_html = "<h1>Test Email</h1><p>This is a test from the AI agent.</p>"
 send_email("Agent Test Email", test_html)

Expected output (when the API key is valid):

✅ Email sent successfully.

4️⃣ Extend the Agent with a New Tool

# Save as: agent_v3.py
import os
import json
from typing import List, Dict, Callable
from sendgrid_email import send_email

# Simulated knowledge base (in‑memory)
KNOWLEDGE_BASE = {
 "what is your education background?": "I completed my master's in Business Analytics from ISC Bangalore, University of Cambridge."
}

# Simple function to detect unknown queries
def is_known(question: str) -> bool:
 return question.lower() in KNOWLEDGE_BASE

# Push notification placeholder (prints to console)
def push_notification(message: str):
 print(f"🔔 PUSH: {message}")

# New tool: email notification
def email_notification(question: str):
 subject = "🤖 New Unknown Query Detected"
 html = f"""
 <h2>New Query Received</h2>
 <p><strong>Question:</strong> {question}</p>
 <p>Timestamp: {json.dumps(os.times())}</p>
 """
 send_email(subject, html)

# Registry of tools the agent can call
TOOLS: Dict[str, Callable[[str], None]] = {
 "push": push_notification,
 "email": email_notification,
}

def answer_question(question: str) -> str:
 if is_known(question):
 return KNOWLEDGE_BASE[question.lower()]
 else:
 # Unknown → trigger all notification tools
 for name, tool in TOOLS.items():
 tool(question)
 # Simulate storing the unknown Q&A for later training
 KNOWLEDGE_BASE[question.lower()] = "🤖 (Answer pending – will be added later)"
 return "I don't know the answer yet, but I've logged your question for review."

# ✅ Verify: Quick sanity check
if __name__ == "__main__":
 print(answer_question("What is your favorite sushi?"))
 print(answer_question("What is your education background?"))

Expected console output (first run, assuming valid SendGrid config):

🔔 PUSH: New unknown query: What is your favorite sushi?
✅ Email sent successfully.
I don't know the answer yet, but I've logged your question for review.
I completed my master's in Business Analytics from ISC Bangalore, University of Cambridge.

5️⃣ Wire Everything into a Gradio Chat UI

# Save as: app.py
import gradio as gr
from agent_v3 import answer_question

def chat_interface(user_msg, chat_history):
 """
 Gradio callback: receives user message, returns updated chat history.
 """
 bot_reply = answer_question(user_msg)
 chat_history = chat_history + [(user_msg, bot_reply)]
 return "", chat_history

with gr.Blocks() as demo:
 gr.Markdown("# 🤖 AI Agent – Version 3 (Push + Email Notifications)")
 chatbot = gr.Chatbot()
 with gr.Row():
 txt = gr.Textbox(
 show_label=False,
 placeholder="Ask me anything.",
 container=False,
 )
 txt.submit(chat_interface, [txt, chatbot], [txt, chatbot])

if __name__ == "__main__":
 demo.launch()

Run the UI:

python app.py

Open the provided localhost URL, type an unknown question (e.g., “What’s your favorite pasta?”).

You should see:

  • The chat reply: “I don't know the answer yet, but I've logged your question for review.”
  • Console logs: a push notification line and the ✅ Email sent successfully message.

✅ Verify: Interact with the UI and confirm both console outputs appear.


📁 Project Files

File Purpose
.env Stores SendGrid API key, sender, and recipient emails (never commit).
sendgrid_email.py Thin wrapper around SendGrid’s v3 Mail Send API.
agent_v3.py Core agent logic, knowledge base, notification tools.
app.py Gradio front‑end that ties everything together.

🚀 What You Just Built

  • Hybrid notification system – instant push + permanent email.
  • Extensible tool registry – adding a new capability (e.g., Slack, SMS) is as easy as inserting a new entry in TOOLS.
  • Self‑learning loop – unknown queries are stored for future enrichment of the knowledge base.

Common Mistakes

Mistake Symptom Fix
Forgetting to verify the sender email in SendGrid 403 error “Permission denied” Verify the sender under Settings → Sender Authentication and use that exact address in .env.
Using a sandbox API key (read‑only) Email never sent, 401 response Generate a Full Access API key from the SendGrid dashboard.
Misspelling environment variable names EnvironmentError at import time Double‑check .env keys match the code (SENDGRID_API_KEY, SENDGRID_SENDER, EMAIL_RECIPIENT).
Running the Gradio app without python-dotenv installed Variables stay None → runtime crash pip install python-dotenv and ensure you call load_dotenv() before accessing os.getenv.

Troubleshooting

  1. No email arrives but console shows “✅ Email sent successfully.” Check the spam folder. Some providers initially flag SendGrid messages. Mark as “Not Spam” to whitelist.

  2. ⚠️ Failed to send email: 400 Inspect the response body – common causes are malformed JSON or missing required fields. Ensure subject and content are non‑empty strings.

  3. Push notifications not appearing The push_notification function currently just prints. Replace with a real push service (e.g., Pushover) if needed.

  4. Agent keeps replying “I don’t know…” even after you add an answer Remember the knowledge base lives only in memory. Restarting the script clears it. Persist to a file or DB for long‑term storage.


Key Takeaways

  • Email is a first‑class tool for logging and escalation; integrating it is straightforward with SendGrid’s REST API.
  • Keep secrets out of source control.env + python-dotenv is the simplest pattern.
  • A tool registry (TOOLS dict) makes the agent modular; adding new capabilities never touches the core answering logic.
  • Always verify each integration step (push, email, UI) before moving on – the ✅ Verify: markers help you stay disciplined.

🧪 Try It Yourself

  1. Swap the push notification for a real service (e.g., Pushover, Slack webhook).
  2. Persist the knowledge base to a JSON file (knowledge.json) and load it on startup.
  3. Add a “schedule email” tool that sends a daily digest of all unknown queries collected during the day.

Happy coding! 🚀

Why This Matters

When you automate lead capture, speed is everything. A prospect who fills out a form at 3 a.m. expects an immediate acknowledgment. By wiring a Large Language Model (LLM) agent to send a real‑time push notification and a rich HTML email, you:

  • Reduce response latency from hours to seconds.
  • Keep the prospect’s data consistent across channels (phone, email, CRM).
  • Demonstrate a production‑grade “agent‑as‑a‑service” that can be dropped into any Python codebase.

What You’ll Build

A self‑contained Python package that:

  1. Collects a visitor’s name, email, company, role, and notes.
  2. Calls an LLM (OpenAI’s gpt‑4o-mini) with a function‑calling schema that decides when to invoke:
  3. send_email_lead – builds a beautiful HTML email and sends it via SMTP.
  4. push_notification – fires a push notification to your phone (using Pushover).
  5. Runs an interactive loop that you can feed a raw visitor message into and watch the agent orchestrate the two tools automatically.

All code lives in three files, no external research required.


Content

1️⃣ email_utils.py – Send a beautiful HTML email

# Save as: email_utils.py
import os
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import Dict

SMTP_HOST = os.getenv("SMTP_HOST", "smtp.gmail.com")
SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
SMTP_USER = os.getenv("SMTP_USER") # your Gmail address
SMTP_PASS = os.getenv("SMTP_PASS") # App password or OAuth token

HTML_TEMPLATE = """
<!DOCTYPE html>
<html>
<head>
 <style>
 body {{ font-family: Arial, sans-serif; background:#f9f9f9; }}.card {{ background:#fff; padding:20px; border-radius:8px;
 box-shadow:0 2px 4px rgba(0,0,0,0.1); max-width:600px; margin:auto; }}
 h2 {{ color:#2c3e50; }}
 p {{ line-height:1.5; }}
 </style>
</head>
<body>
 <div class="card">
 <h2>New Lead from {name}</h2>
 <p><strong>Email:</strong> {email}</p>
 <p><strong>Company:</strong> {company}</p>
 <p><strong>Role:</strong> {role}</p>
 <p><strong>Notes:</strong> {notes}</p>
 </div>
</body>
</html>
"""

def send_email_lead(lead: Dict[str, str]) -> str:
 """
 Sends a formatted HTML email with lead details.
 Returns a short status string for the LLM.
 """
 # Build the message
 msg = MIMEMultipart("alternative")
 msg["Subject"] = f"🚀 New Lead: {lead['name']} – {lead['role']}"
 msg["From"] = SMTP_USER
 msg["To"] = SMTP_USER # send to yourself; change if you want a different inbox

 html_body = HTML_TEMPLATE.format(**lead)
 msg.attach(MIMEText(html_body, "html"))

 # Send via SMTP
 try:
 with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server:
 server.starttls()
 server.login(SMTP_USER, SMTP_PASS)
 server.send_message(msg)
 return "✅ Email sent successfully."
 except Exception as e:
 return f"❌ Failed to send email: {e}"

Expected output (when called from the agent):

✅ Email sent successfully.

✅ Verify: Run python -c "import email_utils; print(email_utils.send_email_lead({'name':'Test','email':'test@example.com','company':'Acme','role':'Engineer','notes':'Demo'}))" after setting the environment variables.


2️⃣ notify.py – Push a notification to your phone

# Save as: notify.py
import os
import requests
from typing import Dict

PUSHOVER_TOKEN = os.getenv("PUSHOVER_TOKEN") # App token from pushover.net
PUSHOVER_USER = os.getenv("PUSHOVER_USER") # Your user key

def push_notification(lead: Dict[str, str]) -> str:
 """
 Sends a Pushover notification with a short summary of the lead.
 Returns a status string for the LLM.
 """
 message = (
 f"New lead!\n"
 f"Name: {lead['name']}\n"
 f"Company: {lead['company']}\n"
 f"Role: {lead['role']}"
 )
 payload = {
 "token": PUSHOVER_TOKEN,
 "user": PUSHOVER_USER,
 "title": "🚀 Lead Capture",
 "message": message,
 "html": 1,
 }
 try:
 resp = requests.post("https://api.pushover.net/1/messages.json", data=payload, timeout=10)
 resp.raise_for_status()
 return "✅ Notification pushed."
 except Exception as e:
 return f"❌ Notification error: {e}"

Expected output (when called from the agent):

✅ Notification pushed.

✅ Verify: After setting PUSHOVER_TOKEN and PUSHOVER_USER, run

python -c "import notify; print(notify.push_notification({'name':'Test','company':'Acme','role':'Engineer'}))"

You should see a push on your device.


3️⃣ agent.py – The LLM‑driven orchestrator

# Save as: agent.py
import os
import json
import openai
from typing import Dict, Any, List

# ----------------------------------------------------------------------
# 1️⃣ Configuration
# ----------------------------------------------------------------------
openai.api_key = os.getenv("OPENAI_API_KEY")
MODEL = "gpt-4o-mini"

# ----------------------------------------------------------------------
# 2️⃣ Function schemas for OpenAI function calling
# ----------------------------------------------------------------------
def _function_schema(name: str, description: str, parameters: Dict[str, Any]) -> Dict[str, Any]:
 return {
 "name": name,
 "description": description,
 "parameters": {
 "type": "object",
 "properties": parameters,
 "required": list(parameters.keys()),
 },
 }

FUNCTIONS = [
 _function_schema(
 name="send_email_lead",
 description="Send a formatted HTML email containing lead information.",
 parameters={
 "name": {"type": "string", "description": "Visitor's full name"},
 "email": {"type": "string", "description": "Visitor's email address"},
 "company": {"type": "string", "description": "Visitor's company"},
 "role": {"type": "string", "description": "Position they are interested in"},
 "notes": {"type": "string", "description": "Any additional notes"},
 },
 ),
 _function_schema(
 name="push_notification",
 description="Push a short notification to the user's phone.",
 parameters={
 "name": {"type": "string", "description": "Visitor's full name"},
 "company": {"type": "string", "description": "Visitor's company"},
 "role": {"type": "string", "description": "Position they are interested in"},
 },
 ),
]

# ----------------------------------------------------------------------
# 3️⃣ Helper to call the real Python functions
# ----------------------------------------------------------------------
def _dispatch_function(name: str, arguments: Dict[str, Any]) -> str:
 if name == "send_email_lead":
 from email_utils import send_email_lead
 return send_email_lead(arguments)
 elif name == "push_notification":
 from notify import push_notification
 return push_notification(arguments)
 else:
 return f"❌ Unknown function: {name}"

# ----------------------------------------------------------------------
# 4️⃣ Core agent loop
# ----------------------------------------------------------------------
def run_agent(user_message: str) -> List[Dict[str, Any]]:
 """
 Takes a raw visitor message, lets the LLM decide which tool(s) to call,
 executes them, and returns a transcript of actions + final LLM reply.
 """
 messages = [
 {
 "role": "system",
 "content": (
 "You are a helpful lead‑capture assistant. "
 "When a visitor provides their name, email, company, role, and optional notes, "
 "you must call the appropriate functions to (1) push a notification and "
 "(2) send a nicely formatted HTML email. "
 "Always call BOTH functions, even if the visitor only gave partial data – "
 "fill missing fields with \"N/A\"."
 ),
 },
 {"role": "user", "content": user_message},
 ]

 # 1️⃣ Ask the model what to do
 response = openai.ChatCompletion.create(
 model=MODEL,
 messages=messages,
 functions=FUNCTIONS,
 function_call="auto",
 )
 choice = response["choices"][0]

 # 2️⃣ If a function call is suggested, execute it
 if "function_call" in choice["message"]:
 func_name = choice["message"]["function_call"]["name"]
 arguments = json.loads(choice["message"]["function_call"]["arguments"])
 # Fill missing keys with "N/A" (robustness)
 for key in FUNCTIONS[0]["parameters"]["properties"]: # email schema has superset
 arguments.setdefault(key, "N/A")
 # Dispatch
 result = _dispatch_function(func_name, arguments)

 # 3️⃣ Feed the result back to the model so it can craft a final reply
 messages.append(choice["message"]) # original function call
 messages.append(
 {
 "role": "function",
 "name": func_name,
 "content": result,
 }
 )
 final_resp = openai.ChatCompletion.create(
 model=MODEL,
 messages=messages,
 )
 final_msg = final_resp["choices"][0]["message"]["content"]
 return [
 {"action": func_name, "arguments": arguments, "result": result},
 {"assistant_reply": final_msg},
 ]
 else:
 # No function call – just return the LLM's answer
 return [{"assistant_reply": choice["message"]["content"]}]

# ----------------------------------------------------------------------
# 5️⃣ Demo entry point
# ----------------------------------------------------------------------
if __name__ == "__main__":
 demo = (
 "Hi, I'm Priya from Infosys. My email is abc@google.com. "
 "We are hiring for a JNI trainer role and would love to talk this week."
 )
 transcript = run_agent(demo)
 print(json.dumps(transcript, indent=2))

Sample run (python agent.py)

[
 {
 "action": "push_notification",
 "arguments": {
 "name": "Priya",
 "company": "Infosys",
 "role": "JNI trainer"
 },
 "result": "✅ Notification pushed."
 },
 {
 "assistant_reply": "✅ Notification pushed.\n✅ Email sent successfully.\n\nYour lead has been recorded and you’ll receive a notification on your phone as well as a detailed HTML email. I’ll keep an eye on any follow‑up messages."
 }
]

✅ Verify: 1. Populate environment variables (OPENAI_API_KEY, SMTP_USER, SMTP_PASS, PUSHOVER_TOKEN, PUSHOVER_USER). 2. Run python agent.py. 3. Confirm you receive a push on your phone and an email in your inbox.


📁 Project Files

File Purpose
email_utils.py Build & send the HTML lead email via SMTP.
notify.py Send a concise push notification using Pushover.
agent.py Orchestrates the LLM, decides which tool(s) to call, and returns a friendly reply.

All files live in the same directory for simplicity.


🚀 What You Just Built

  • Zero‑to‑hero lead capture – a single Python call (run_agent) turns a free‑form visitor message into two real‑world actions.
  • LLM‑driven decision making – the model decides when and how to invoke each tool, handling missing fields gracefully.
  • Production‑ready patterns – function‑calling schema, robust argument filling, and a clean separation of concerns (email vs. notification).

Common Mistakes

Mistake Why it Happens Fix
Forgot to set environment variables openai, SMTP, or Pushover credentials are missing. Export them in your shell or create a .env file and load with python-dotenv.
SMTP authentication error Gmail blocks “less secure apps”. Use an App Password (2‑FA enabled) or configure OAuth2.
Pushover rate‑limit Sending many notifications quickly. Add a short time.sleep(1) between calls if you batch leads.
LLM returns partial arguments Visitor omitted a field. The agent auto‑fills missing keys with "N/A" – keep that logic.
HTML email appears as plain text Recipient’s mail client blocks external CSS. The template uses inline CSS; most clients render it fine.

Troubleshooting

  1. No email arrives
  2. Verify SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS.
  3. Check the spam folder (especially the first run).
  4. Look at the console output of send_email_lead; it returns the exact error string.

  5. Push notification never shows

  6. Confirm PUSHOVER_TOKEN and PUSHOVER_USER are correct.
  7. Test the API directly with curl to rule out network issues.

  8. LLM refuses to call a function

  9. Ensure the system prompt explicitly demands a function call.
  10. If the model still returns plain text, increase temperature to 0 (deterministic) or add a few more examples in the system prompt.

  11. JSON decode error

  12. The LLM sometimes returns malformed JSON (e.g., stray newlines).
  13. Wrap the json.loads call in a try/except and fallback to json.loads(response_text.replace("\n", "")).

Key Takeaways

  • Function calling lets LLMs become actionable agents, not just text generators.
  • Keep tool contracts (function signatures) simple and explicit; the model loves clear schemas.
  • Separate infrastructure (SMTP, Pushover) from orchestration (LLM) for maintainability.
  • Always verify side‑effects (email sent, notification pushed) and surface the status back to the user—this closes the feedback loop.

🧪 Try It Yourself

  1. Swap the email provider – replace smtplib with SendGrid or Mailgun.
  2. Add a CRM hook – create a new function add_to_crm that POSTs to a mock endpoint, then extend FUNCTIONS and the dispatch logic.
  3. Batch processing – modify run_agent to accept a list of visitor messages and process them concurrently with asyncio.

Happy hacking! 🎉


What's Next

Continue to the next chapter to keep building.

Chapter 12

Giving Your Agent Real Tools: Calendar, Search, and CRM

Why This Matters


Before You Start This Chapter

  • Your venv is active (you see `(.

    What is a virtual environment (venv)? It is a separate, isolated Python world for your project. Without it, packages from different projects can conflict. With a venv, each project gets its own clean set of packages. Think of it like a separate browser profile for each website.

venv)in your prompt) - Previous chapter's project is working - Your.env` file has your API key

What is an API? An API (Application Programming Interface) is how one program talks to another. Think of it like a waiter in a restaurant — you (your code) tell the waiter (API) what you want, the waiter goes to the kitchen (the server), and brings back your food (the response). You never talk to the kitchen directly.

  • You are in the correct folder (run pwd to check)

Get Your OpenAI API Key

  1. Go to https://platform.openai.com/api-keys
  2. Log in (free account)
  3. Click "Create new secret key"
  4. Name it (e.g., "adhikk")
  5. Click "Create"
  6. Copy the key (starts with sk-) — you will not see it again
  7. Paste into your .env file

New accounts get $5 free credit — enough for this entire book.

Create & Activate Virtual Environment

uv venv

Activate (Windows PowerShell):

.venv\Scripts\Activate

Activate (Mac/Linux):

source .venv/bin/activate

How you'll know it worked: Your prompt shows (.venv) at the start.

Large language models (LLMs) are powerful text generators, but they become truly useful when you give them context and tools. By attaching real‑world capabilities—sending a push notification, emailing, or scheduling a meeting—you turn a plain LLM into an autonomous assistant that can act on behalf of a user. In this chapter you’ll learn how to:

  1. Wrap a Python function as a “tool” that an LLM can call.
  2. Generate a Google Calendar link that any user can click to add an event.
  3. Create a universal .ics calendar file that works with Google, Outlook, Apple Calendar, etc.

When you finish, you’ll have a reusable toolkit that any LLM (OpenAI, Anthropic, Llama‑2, …) can invoke to schedule meetings automatically.


What You’ll Build

A small Python package named meeting_toolkit containing:

File Purpose
google_link.py Build a URL that opens a pre‑filled Google Calendar event.
ics_generator.py Produce a standards‑compliant .ics file for any calendar app.
llm_tool_wrapper.py Demonstrate how to expose the two functions to an LLM via OpenAI’s function‑calling API.
demo.py End‑to‑end script that asks the LLM to schedule a meeting and then runs the appropriate tool.

You’ll be able to run demo.py and watch the LLM decide whether to return a Google link or an .ics file, based on a simple prompt.


Content

1. The “Tool” Concept

An LLM can only output text. To make it act, you expose Python functions as tools. The LLM receives a description of each tool (name, parameters, purpose) and can request the tool by name. Your wrapper receives the request, calls the real function, and feeds the result back to the model.

Google Calendar accepts a URL of the form:

https://www.google.com/calendar/render?action=TEMPLATE&text=TITLE&dates=START/END&details=DESCRIPTION&location=LOCATION
  • START and END must be in UTC and formatted as YYYYMMDDTHHMMSSZ.
  • Time‑zone handling is crucial; we’ll convert any naive or aware datetime to UTC.

3. Generating an .ics File

The iCalendar (.ics) format is a plain‑text specification understood by all major calendar apps. A minimal event looks like:

BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//YourCompany//MeetingToolkit//EN
BEGIN:VEVENT
UID:20240915T123000Z-123456@example.com
DTSTAMP:20240915T123000Z
DTSTART:20240920T090000Z
DTEND:20240920T100000Z
SUMMARY:Team Sync
DESCRIPTION:Discuss Q4 goals
LOCATION:Zoom
END:VEVENT
END:VCALENDAR

Our helper builds this string and writes it to a file.

4. Wiring It All Together with OpenAI’s Function‑Calling API

We’ll:

  1. Define the two tools in a JSON schema.
  2. Send a user request (“Schedule a 30‑minute meeting with Alice tomorrow at 10 am”) to the model.
  3. Let the model decide which tool to call.
  4. Execute the chosen function and return the result to the user.

💡 Tip – Keep the function schema minimal; the model works best when it sees only the fields it truly needs.


📁 Project Files

google_link.py

# Save as: google_link.py
import urllib.parse
from datetime import datetime, timezone

def _to_utc_iso(dt: datetime) -> str:
 """
 Convert a datetime (naive or aware) to UTC and format as YYYYMMDDTHHMMSSZ.
 """
 if dt.tzinfo is None:
 # Assume local time; convert to UTC
 dt = dt.astimezone()
 utc_dt = dt.astimezone(timezone.utc)
 return utc_dt.strftime("%Y%m%dT%H%M%SZ")

def generate_google_calendar_link(
 start: datetime,
 end: datetime,
 title: str,
 description: str = "",
 location: str = ""
) -> str:
 """
 Build a Google Calendar event link.

 Parameters
 ----------
 start, end : datetime
 Event start and end times (any timezone or naive).
 title : str
 Event title.
 description : str, optional
 Event description.
 location : str, optional
 Event location.

 Returns
 -------
 str
 URL that opens a pre‑filled Google Calendar event.
 """
 base = "https://www.google.com/calendar/render"
 params = {
 "action": "TEMPLATE",
 "text": title,
 "dates": f"{_to_utc_iso(start)}/{_to_utc_iso(end)}",
 "details": description,
 "location": location,
 "sf": "true",
 "output": "xml"
 }
 return f"{base}?{urllib.parse.urlencode(params)}"

# Example usage
if __name__ == "__main__":
 from datetime import timedelta
 now = datetime.now()
 start = now + timedelta(days=1, hours=10) # tomorrow 10 am local
 end = start + timedelta(minutes=30) # 30‑minute meeting
 link = generate_google_calendar_link(
 start=start,
 end=end,
 title="Project Sync",
 description="Discuss milestones with the team.",
 location="Zoom"
 )
 print("Google Calendar link:")
 print(link)

Expected output (example)

Google Calendar link:
https://www.google.com/calendar/render?action=TEMPLATE&text=Project+Sync&dates=20240916T043000Z/20240916T050000Z&details=Discuss+milestones+with+the+team.&location=Zoom&sf=true&output=xml

✅ Verify: Run python google_link.py and confirm the printed URL opens a Google Calendar event with the correct details.


ics_generator.py

# Save as: ics_generator.py
import uuid
from datetime import datetime, timezone

def _format_dt(dt: datetime) -> str:
 """
 Return a datetime in iCalendar UTC format: YYYYMMDDTHHMMSSZ
 """
 if dt.tzinfo is None:
 dt = dt.astimezone()
 return dt.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ")

def generate_ics_file(
 start: datetime,
 end: datetime,
 title: str,
 description: str = "",
 location: str = "",
 filename: str = "event.ics"
) -> str:
 """
 Write a minimal.ics file for a single event.

 Returns
 -------
 str
 Path to the generated file.
> **What is PATH?** PATH is a list of folders your computer checks when you type a command. If you type `python`, your computer looks in each PATH folder for a file called `python.exe`. If it finds it, it runs it. If not, you get "command not found." Installing Python with "Add to PATH" checked adds Python's folder to this list.


 """
 uid = f"{uuid.uuid4()}@meetingtoolkit"
 dtstamp = _format_dt(datetime.utcnow())
 dtstart = _format_dt(start)
 dtend = _format_dt(end)

 ics_content = "\r\n".join([
 "BEGIN:VCALENDAR",
 "VERSION:2.0",
 "PRODID:-//YourCompany//MeetingToolkit//EN",
 "BEGIN:VEVENT",
 f"UID:{uid}",
 f"DTSTAMP:{dtstamp}",
 f"DTSTART:{dtstart}",
 f"DTEND:{dtend}",
 f"SUMMARY:{title}",
 f"DESCRIPTION:{description}",
 f"LOCATION:{location}",
 "END:VEVENT",
 "END:VCALENDAR",
 ""
 ])

 with open(filename, "w", encoding="utf-8") as f:
 f.write(ics_content)

 return filename

# Example usage
if __name__ == "__main__":
 from datetime import timedelta
 now = datetime.now()
 start = now + timedelta(days=2, hours=14) # day after tomorrow 2 pm
 end = start + timedelta(minutes=45) # 45‑minute meeting
 path = generate_ics_file(
 start=start,
 end=end,
 title="Client Review",
 description="Quarterly review with the client.",
 location="Microsoft Teams",
 filename="client_review.ics"
 )
 print(f".ics file generated at: {path}")

Expected output (example) ```.ics file generated at: client_review.ics


✅ Verify: Run `python ics_generator.py` and open the resulting `.ics` file with your calendar app; the event should appear with the correct time, title, and location.

---

### `llm_tool_wrapper.py` 
```python
# Save as: llm_tool_wrapper.py
import json
import os
from typing import Any, Dict

import openai # pip install openai
from google_link import generate_google_calendar_link
from ics_generator import generate_ics_file
from datetime import datetime, timedelta

# ----------------------------------------------------------------------
# 1️⃣ Define the tool schemas that we will expose to the LLM
# ----------------------------------------------------------------------
TOOLS = [
 {
 "type": "function",
 "function": {
 "name": "create_google_calendar_link",
 "description": "Generate a Google Calendar link for a meeting.",
 "parameters": {
 "type": "object",
 "properties": {
 "start": {"type": "string", "description": "ISO‑8601 start datetime"},
 "end": {"type": "string", "description": "ISO‑8601 end datetime"},
 "title": {"type": "string"},
 "description": {"type": "string"},
 "location": {"type": "string"}
 },
 "required": ["start", "end", "title"]
 }
 }
 },
 {
 "type": "function",
 "function": {
 "name": "create_ics_file",
 "description": "Create an.ics file for a meeting that works with any calendar.",
 "parameters": {
 "type": "object",
 "properties": {
 "start": {"type": "string", "description": "ISO‑8601 start datetime"},
 "end": {"type": "string", "description": "ISO‑8601 end datetime"},
 "title": {"type": "string"},
 "description": {"type": "string"},
 "location": {"type": "string"},
 "filename": {"type": "string", "description": "Desired filename, e.g., meeting.ics"}
 },
 "required": ["start", "end", "title", "filename"]
 }
 }
 }
]

# ----------------------------------------------------------------------
# 2️⃣ Helper to parse ISO strings into datetime objects
# ----------------------------------------------------------------------
def _parse_iso(iso_str: str) -> datetime:
 """Parse ISO‑8601 string; assume local timezone if none provided."""
 dt = datetime.fromisoformat(iso_str)
 return dt

# ----------------------------------------------------------------------
# 3️⃣ Dispatcher – calls the real Python function based on the LLM request
# ----------------------------------------------------------------------
def call_tool(name: str, arguments: Dict[str, Any]) -> str:
 if name == "create_google_calendar_link":
 start = _parse_iso(arguments["start"])
 end = _parse_iso(arguments["end"])
 link = generate_google_calendar_link(
 start=start,
 end=end,
 title=arguments["title"],
 description=arguments.get("description", ""),
 location=arguments.get("location", "")
 )
 return json.dumps({"link": link})

 elif name == "create_ics_file":
 start = _parse_iso(arguments["start"])
 end = _parse_iso(arguments["end"])
 filename = arguments["filename"]
 path = generate_ics_file(
 start=start,
 end=end,
 title=arguments["title"],
 description=arguments.get("description", ""),
 location=arguments.get("location", ""),
 filename=filename
 )
 return json.dumps({"ics_path": os.path.abspath(path)})

 else:
 raise ValueError(f"Unknown tool: {name}")

# ----------------------------------------------------------------------
# 4️⃣ Main routine – ask the LLM to schedule a meeting and let it pick a tool
# ----------------------------------------------------------------------
def schedule_meeting(user_prompt: str, model: str = "gpt-4o-mini") -> None:
 """
 Sends `user_prompt` to the LLM, lets the model decide which tool to call,
 runs the tool, and prints the result.
 """
 client = openai.OpenAI() # reads OPENAI_API_KEY from env

 response = client.chat.completions.create(
 model=model,
 messages=[{"role": "user", "content": user_prompt}],
 tools=TOOLS,
 tool_choice="auto"
 )

 message = response.choices[0].message

 if message.tool_calls:
 # The model wants to invoke a tool
 tool_call = message.tool_calls[0]
 tool_name = tool_call.function.name
 arguments = json.loads(tool_call.function.arguments)

 print(f"🔧 LLM requested tool: {tool_name}")
 result_json = call_tool(tool_name, arguments)
 result = json.loads(result_json)

 if "link" in result:
 print("\n✅ Google Calendar link generated:")
 print(result["link"])
 elif "ics_path" in result:
 print("\n✅.ics file created at:")
 print(result["ics_path"])
 else:
 # Model answered without a tool – just echo the text
 print("🤖 LLM response:")
 print(message.content)

# ----------------------------------------------------------------------
# 5️⃣ Demo execution
# ----------------------------------------------------------------------
if __name__ == "__main__":
 demo_prompt = (
 "I need a 45‑minute meeting with Bob tomorrow at 3 pm PST. "
 "Please schedule it and give me a link I can share."
 )
 schedule_meeting(demo_prompt)

Expected output (example)

🔧 LLM requested tool: create_google_calendar_link

✅ Google Calendar link generated:
https://www.google.com/calendar/render?action=TEMPLATE&text=Meeting+with+Bob&dates=20240916T220000Z/20240916T224500Z&details=&location=&sf=true&output=xml

If the LLM decides the user might need a universal file, you’ll see a path to an .ics file instead.

✅ Verify: 1. Set OPENAI_API_KEY in your environment. 2. Run python llm_tool_wrapper.py. 3. Confirm the printed link opens a Google Calendar event, or the .ics file exists and can be imported.

⚠️ Warning – The model may sometimes pick the wrong tool (e.g., generate a link when you wanted an .ics). In production you’d add validation logic to re‑ask the model or fallback to a default.


🚀 What You Just Built

  • Two reusable utilities (google_link.py, ics_generator.py) that handle time‑zone conversion, UTC formatting, and output generation.
  • A function‑calling wrapper (llm_tool_wrapper.py) that lets any LLM act as a meeting scheduler, automatically choosing the best tool for the user’s context.
  • A runnable demo that showcases the full flow from natural‑language request → LLM decision → tool execution → user‑friendly result.

Common Mistakes

Mistake Why It Happens Fix
Passing a naive datetime (no tzinfo) directly to Google Calendar The URL expects UTC; naive objects are interpreted as local time, leading to wrong meeting times. Always run _to_utc_iso (or use the helper) which converts local time to UTC.
Forgetting to URL‑encode special characters in the title/description Spaces become %20, but & or + can break the query string. Use urllib.parse.urlencode (already done).
Generating an .ics file with Windows line endings (\r\n) missing Some calendar apps require CRLF. The generator joins lines with \r\n as per the spec.
The LLM returns invalid JSON in function.arguments Model hallucinations or missing quotes. Wrap the call in a try/except and, on failure, ask the model to retry.
Not setting OPENAI_API_KEY API client cannot authenticate. Export the key: export OPENAI_API_KEY=sk-… (Linux/macOS) or set in Windows env.

Troubleshooting

  1. Link opens a blank page
  2. Verify the dates parameter is in YYYYMMDDTHHMMSSZ format.
  3. Print the raw URL and compare with the example in the chapter.

  4. .ics file does not appear in Outlook

  5. Open the file in a text editor; ensure each line ends with \r\n.
  6. Check that DTSTART/DTEND are UTC (Z suffix).

  7. LLM refuses to call a tool

  8. Make sure the tools list is passed to client.chat.completions.create.
  9. Increase temperature to 0 for deterministic behavior during debugging.

  10. Time‑zone mismatch

  11. Print the intermediate UTC strings (_to_utc_iso) to confirm conversion.
  12. Remember: India = UTC+05:30, PST = UTC‑08:00.

Key Takeaways

  • Tools = Power: Exposing Python functions to an LLM lets you bridge the gap between text generation and real‑world actions.
  • UTC is the universal language of time; always convert before sending to external services.
  • Google Calendar links are quick for users already on Google, but .ics files guarantee cross‑platform compatibility.
  • A minimal function schema leads to more reliable model decisions.

🧪 Try It Yourself

  1. Add a new tool that sends an email (use smtplib or a transactional‑email API). Update TOOLS and call_tool accordingly.
  2. Change the demo prompt to request a meeting in a different time zone (e.g., “10 am CET”). Verify the generated link reflects the correct UTC conversion.
  3. Wrap the whole package into a small Flask API (/schedule) that accepts a JSON payload (prompt) and returns either a link or a downloadable .ics file.

Happy coding! 🎉

Why This Matters

Scheduling meetings is a core part of any professional workflow. When an AI assistant can suggest available slots, create a calendar invite without needing OAuth or a service‑account, and notify both participants, you instantly turn a conversational request into a real‑world action. Mastering this pattern gives you:

  • A reusable, production‑ready way to generate ICS files that any calendar understands.
  • A lightweight email notification pipeline that works out‑of‑the‑box.
  • A clear example of date‑time arithmetic, time‑zone handling, and unique slot IDs—skills you’ll reuse in countless projects.

What You’ll Build

A tiny Flask web service that:

  1. Computes the next N free weekday slots (11 am – 4 pm, half‑hour granularity) starting tomorrow.
  2. Exposes an endpoint (/slots) that returns those slots as JSON.
  3. Accepts a user’s slot choice (/book) and generates a downloadable .ics file containing the meeting details.
  4. Emails the meeting organizer and the HR requester a link to the .ics file (using a local SMTP server for demo purposes).

All of this runs with no external API keys—just Python’s standard library and a couple of tiny third‑party packages.


Content

1️⃣ Set Up the Project Structure

meeting‑scheduler/
├─ app.py # Flask app, routes, and main logic
├─ utils.py # Helper functions (slot generation, ics creation, email)
├─ templates/
│ └─ email_template.txt
└─ requirements.txt

2️⃣ Install Dependencies

pip install -r requirements.txt

requirements.txt

Flask==2.3.3
pytz==2024.1

💡 Tippytz is used for explicit time‑zone handling. If you’re on Python 3.9+ you could use zoneinfo instead; the code below works with both.

3️⃣ utils.py – Core Helpers

# Save as: utils.py
import uuid
import datetime as dt
import pytz
from email.message import EmailMessage
import smtplib
from pathlib import Path

# -------------------------------------------------
# Configuration (adjust for your environment)
# -------------------------------------------------
WORKING_HOURS = (11, 16) # 11 am to 4 pm (inclusive start, exclusive end)
SLOT_DURATION_MIN = 30 # half‑hour slots
TIMEZONE = pytz.timezone("Asia/Kolkata") # Indian Standard Time (UTC+5:30)
ICS_OUTPUT_DIR = Path("ics_files")
ICS_OUTPUT_DIR.mkdir(exist_ok=True)

SMTP_HOST = "localhost"
SMTP_PORT = 1025 # Use `python -m smtpd -c DebuggingServer -n localhost:1025` for local testing
FROM_EMAIL = "no-reply@example.com"

# -------------------------------------------------
# 1️⃣ Generate upcoming free slots
# -------------------------------------------------
def get_upcoming_slots(num_slots: int = 5) -> list[dict]:
 """
 Return a list of dictionaries, each containing:
 - id: unique UUID string
 - start: ISO‑8601 datetime string (local time)
 - end: ISO‑8601 datetime string (local time)
 Slots start from **tomorrow**, only on weekdays, within WORKING_HOURS.
 """
 now = dt.datetime.now(TIMEZONE)
 # Start from tomorrow at the beginning of the working day
 start_date = (now + dt.timedelta(days=1)).replace(hour=WORKING_HOURS[0], minute=0, second=0, microsecond=0)

 slots = []
 cursor = start_date

 while len(slots) < num_slots:
 # Skip weekends
 if cursor.weekday() >= 5: # 5 = Saturday, 6 = Sunday
 cursor += dt.timedelta(days=1)
 cursor = cursor.replace(hour=WORKING_HOURS[0], minute=0)
 continue

 slot_end = cursor + dt.timedelta(minutes=SLOT_DURATION_MIN)

 # Ensure we stay inside working hours
 if slot_end.hour > WORKING_HOURS[1] or (slot_end.hour == WORKING_HOURS[1] and slot_end.minute > 0):
 # Move to next day
 cursor += dt.timedelta(days=1)
 cursor = cursor.replace(hour=WORKING_HOURS[0], minute=0)
 continue

 slot = {
 "id": str(uuid.uuid4()),
 "start": cursor.isoformat(),
 "end": slot_end.isoformat(),
 }
 slots.append(slot)

 # Advance cursor to next slot
 cursor = slot_end

 return slots

# -------------------------------------------------
# 2️⃣ Generate an.ics file for a meeting
# -------------------------------------------------
def generate_ics(meeting_title: str,
 start_iso: str,
 end_iso: str,
 organizer_name: str,
 organizer_email: str,
 attendee_name: str,
 attendee_email: str) -> Path:
 """
 Creates an.ics file and returns its Path.
 """
 dtstamp = dt.datetime.now(TIMEZONE).strftime("%Y%m%dT%H%M%SZ")
 uid = str(uuid.uuid4())

 # Convert ISO strings to UTC for the.ics format
 start_dt = dt.datetime.fromisoformat(start_iso).astimezone(pytz.utc)
 end_dt = dt.datetime.fromisoformat(end_iso).astimezone(pytz.utc)

 start_str = start_dt.strftime("%Y%m%dT%H%M%SZ")
 end_str = end_dt.strftime("%Y%m%dT%H%M%SZ")

 ics_content = f"""BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Meeting Scheduler//EN
CALSCALE:GREGORIAN
METHOD:REQUEST
BEGIN:VEVENT
UID:{uid}
DTSTAMP:{dtstamp}
DTSTART:{start_str}
DTEND:{end_str}
SUMMARY:{meeting_title}
ORGANIZER;CN={organizer_name}:MAILTO:{organizer_email}
ATTENDEE;CN={attendee_name};RSVP=TRUE:MAILTO:{attendee_email}
END:VEVENT
END:VCALENDAR
"""

 filename = f"{uid}.ics"
 ics_path = ICS_OUTPUT_DIR / filename
 ics_path.write_text(ics_content, encoding="utf-8")
 return ics_path

# -------------------------------------------------
# 3️⃣ Send email with a link to the.ics file
# -------------------------------------------------
def send_email(to_email: str,
 subject: str,
 body: str,
 attachment_path: Path | None = None):
 """
 Sends a plain‑text email. If `attachment_path` is provided,
 the file is attached as a downloadable link (for demo we embed the path).
 """
 msg = EmailMessage()
 msg["From"] = FROM_EMAIL
 msg["To"] = to_email
 msg["Subject"] = subject
 msg.set_content(body)

 if attachment_path:
 # For a real production system you would attach the file.
 # Here we just mention the path so the user can click it in the browser.
 msg.add_attachment(
 attachment_path.read_bytes(),
 maintype="application",
 subtype="octet-stream",
 filename=attachment_path.name,
 )

 with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server:
 server.send_message(msg)

# -------------------------------------------------
# 4️⃣ Helper to format a human‑readable slot string
# -------------------------------------------------
def format_slot(slot: dict) -> str:
 start = dt.datetime.fromisoformat(slot["start"]).astimezone(TIMEZONE)
 end = dt.datetime.fromisoformat(slot["end"]).astimezone(TIMEZONE)
 return f"{start.strftime('%a %b %d, %I:%M %p')} – {end.strftime('%I:%M %p')}"

Expected output (when you call get_upcoming_slots() in a REPL):

>>> from utils import get_upcoming_slots, format_slot
>>> slots = get_upcoming_slots(3)
>>> [format_slot(s) for s in slots]
['Wed Aug 05, 11:00 AM – 11:30 AM',
 'Wed Aug 05, 11:30 AM – 12:00 PM',
 'Wed Aug 05, 12:00 PM – 12:30 PM']

✅ Verify: Run the snippet above; you should see three nicely formatted slot strings.


4️⃣ app.py – Flask API & Booking Flow

# Save as: app.py
from flask import Flask, jsonify, request, send_file, abort
from utils import (
 get_upcoming_slots,
 format_slot,
 generate_ics,
 send_email,
 TIMEZONE,
)

app = Flask(__name__)

# -------------------------------------------------
# Configuration (adjust as needed)
# -------------------------------------------------
MEETING_TITLE = "Intro Call with Ishant"
ORGANIZER_NAME = "Ishant"
ORGANIZER_EMAIL = "ishant@example.com"
HR_NAME = "HR Team"
HR_EMAIL = "hr@example.com"

# -------------------------------------------------
# Route: Show available slots (JSON)
# -------------------------------------------------
@app.route("/slots", methods=["GET"])
def slots():
 """
 Returns a JSON list of upcoming slots.
 Each slot contains: id, start, end, human_readable.
 """
 raw_slots = get_upcoming_slots(num_slots=5)
 for s in raw_slots:
 s["human"] = format_slot(s)
 return jsonify(raw_slots)

# -------------------------------------------------
# Route: Book a slot (POST)
# -------------------------------------------------
@app.route("/book", methods=["POST"])
def book():
 """
 Expected JSON payload:
 {
 "slot_id": "<uuid>",
 "attendee_name": "Alice HR",
 "attendee_email": "alice.hr@example.com"
 }
 """
 data = request.get_json()
 if not data:
 abort(400, description="Invalid JSON payload")

 slot_id = data.get("slot_id")
 attendee_name = data.get("attendee_name")
 attendee_email = data.get("attendee_email")

 # Find the slot by ID
 slots = get_upcoming_slots(num_slots=10) # generate a few more for safety
 slot = next((s for s in slots if s["id"] == slot_id), None)
 if not slot:
 abort(404, description="Slot not found")

 # Generate.ics file
 ics_path = generate_ics(
 meeting_title=MEETING_TITLE,
 start_iso=slot["start"],
 end_iso=slot["end"],
 organizer_name=ORGANIZER_NAME,
 organizer_email=ORGANIZER_EMAIL,
 attendee_name=attendee_name,
 attendee_email=attendee_email,
 )

 # Email both parties
 email_body = f"""Hello {attendee_name},

Your meeting "{MEETING_TITLE}" has been scheduled.

🗓️ When: {format_slot(slot)}
📍 Location: Virtual (link will be shared separately)

Please find the calendar invitation attached. Open it with Google Calendar, Outlook, or any calendar app.

Best,
{ORGANIZER_NAME}
"""
 send_email(
 to_email=attendee_email,
 subject=f"Meeting Invitation: {MEETING_TITLE}",
 body=email_body,
 attachment_path=ics_path,
 )

 # Also notify the organizer (optional)
 send_email(
 to_email=ORGANIZER_EMAIL,
 subject=f"New Meeting Booked: {MEETING_TITLE}",
 body=f"{attendee_name} ({attendee_email}) booked the slot {format_slot(slot)}.",
 attachment_path=ics_path,
 )

 # Return a download link for the.ics file
 return jsonify({
 "message": "Meeting booked successfully",
 "ics_download_url": f"/download/{ics_path.name}"
 })

# -------------------------------------------------
# Route: Download the.ics file
# -------------------------------------------------
@app.route("/download/<filename>", methods=["GET"])
def download(filename):
 """
 Serves the.ics file for the given filename.
 """
 ics_path = (ics_path := (Path("ics_files") / filename))
 if not ics_path.is_file():
 abort(404, description="ICS file not found")
 return send_file(str(ics_path), as_attachment=True, mimetype="text/calendar")

# -------------------------------------------------
# Run the app
# -------------------------------------------------
if __name__ == "__main__":
 # For demo purposes, run on localhost:5000
 app.run(debug=True)

Running the server

python app.py

You should see Flask start:

 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)

✅ Verify: Open http://127.0.0.1:5000/slots in a browser – you’ll get a JSON array of slots.


5️⃣ Testing the Full Flow (Local SMTP Server)

Start a debug SMTP server that prints emails to the console:

python -m smtpd -c DebuggingServer -n localhost:1025

In another terminal, run the Flask app (python app.py).

Now, simulate a booking with curl:

# 1️⃣ Get slots
curl http://127.0.0.1:5000/slots

Copy one of the "id" values from the JSON output, then:

# 2️⃣ Book the chosen slot
curl -X POST http://127.0.0.1:5000/book \
 -H "Content-Type: application/json" \
 -d '{
 "slot_id": "PUT_THE_ID_HERE",
 "attendee_name": "Alice HR",
 "attendee_email": "alice.hr@example.com"
 }'

Expected console output from the SMTP server (sample):

---------- MESSAGE FOLLOWS ----------
From: no-reply@example.com
To: alice.hr@example.com
Subject: Meeting Invitation: Intro Call with Ishant.
Content-Type: application/octet-stream; name="c3f9e2b4-.ics".
---------- END MESSAGE ----------

✅ Verify: You should see two email dumps (one to the attendee, one to the organizer) and a JSON response containing "ics_download_url".


📁 Project Files

File Purpose
requirements.txt Lists third‑party packages.
utils.py Date‑time helpers, slot generation,.ics creation, email sending.
app.py Flask web service exposing /slots, /book, and /download.
ics_files/ Auto‑created folder where generated .ics files are stored.

🚀 What You Just Built

  • Dynamic slot calculation – respects weekdays, working hours, and half‑hour granularity.
  • Zero‑API calendar invites – a plain .ics file works with Google Calendar, Outlook, Apple Calendar, etc.
  • Email notifications – both organizer and attendee receive the invite instantly.
  • A tiny HTTP API – ready to be called from any LLM‑powered chatbot or front‑end UI.

All of this runs locally with only a few lines of code, demonstrating the “keep it simple, stupid” (KISS) principle.


Common Mistakes

Mistake Why it Happens Fix
Using naive datetime objects Time‑zone offsets get lost, leading to wrong slot times. Always attach pytz.timezone (or zoneinfo) to your datetimes (now = datetime.now(TIMEZONE)).
Generating slots that cross midnight Forgetting to reset the cursor after reaching WORKING_HOURS[1]. After each slot, check if cursor.hour >= WORKING_HOURS[1] and roll over to the next day.
Sending the .ics as plain text Some email clients won’t recognise the attachment. Use EmailMessage.add_attachment with maintype="application" and subtype="octet-stream".
Hard‑coding file paths Breaks when the app is run from a different cwd. Use Path(__file__).parent / "ics_files" to build an absolute path.
Running Flask with debug=True in production Exposes the Werkzeug debugger. Switch to a proper WSGI server (Gunicorn, uWSGI) and set debug=False.

Troubleshooting

Symptom Likely Cause Solution
No slots returned System clock is set far in the future or timezone mis‑configured. Verify TIMEZONE and datetime.now(TIMEZONE).
ICS file fails to open Dates are not converted to UTC. Ensure start_dt and end_dt are .astimezone(pytz.utc) before formatting.
Emails not arriving SMTP server not reachable or wrong port. Run the debugging SMTP server (python -m smtpd …) or configure real credentials.
/download/<filename> returns 404 File was deleted or path is wrong. Check that ics_files/ contains the generated file; the server writes there automatically.
Flask returns 500 on /book Missing required JSON fields. Ensure the POST payload includes slot_id, attendee_name, and attendee_email.

Key Takeaways

  • ICS is universal – a single text file can schedule meetings across every major calendar platform.
  • Time‑zone aware code prevents subtle bugs; always work in a known TZ and convert to UTC for iCal.
  • Simple HTTP endpoints let any LLM (ChatGPT, Claude, etc.) interact with your scheduler without heavy SDKs.
  • Email + attachment is a reliable fallback when OAuth flows feel heavyweight for a prototype.

🧪 Try It Yourself

  1. Add a custom working‑hour window (e.g., 9 am – 6 pm) and verify slot generation.
  2. Expose a simple HTML page that fetches /slots via JavaScript and lets a user click a button to book a slot – no extra backend code needed!
  3. Replace the debug SMTP server with a real provider (SendGrid, Gmail SMTP) and send real emails.
  4. Persist booked slots (e.g., in a SQLite DB) to prevent double‑booking.

Happy coding! 🎉

Why This Matters

Scheduling meetings is a core feature of any productivity‑oriented AI assistant. If the assistant can suggest viable time slots and instantly create a calendar invite that works across Google, Outlook, Apple, or any iCal‑compatible client, the user experience becomes frictionless. In this chapter we turn the slot‑generation logic you already saw into real, usable tools that a language model can call.

What You'll Build

  1. Slot generator – returns a list of slot_iddatetime pairs for the next N days.
  2. Google Calendar link builder – a one‑click URL that adds an event to a user’s Google Calendar.
  3. Universal .ics generator – a plain‑text iCalendar file that any calendar app can import.
  4. Two LLM‑friendly tools
  5. offer_meeting_slots – prints the available slots for the model to show the user.
  6. book_meeting – validates the chosen slot, creates the appropriate invite, and returns it.

All code is stand‑alone, fully typed, and ready to be dropped into a project.


Content

1️⃣ Slot Generation (re‑usable core)

# Save as: calendar_utils.py
import datetime as dt
from datetime import datetime, date, time, timedelta, timezone
from typing import List, Tuple, Set

# ------------------------------------------------------------
# Helper: generate N upcoming slots respecting working hours
# ------------------------------------------------------------
def get_upcoming_slots(
 start_date: date,
 count: int,
 working_hours: Tuple[int, int] = (11, 17), # 11:00‑17:00 local time
 weekdays: Set[int] = {0, 1, 2, 3, 4}, # Monday‑Friday (0‑4)
 slot_duration_min: int = 30
) -> List[Tuple[str, datetime]]:
 """
 Returns a list like [('S1', datetime(.)), ('S2', datetime(.)),.]
 """
 slots: List[Tuple[str, datetime]] = []
 slot_id = 1
 cur_date = start_date

 while len(slots) < count:
 # Skip non‑working weekdays
 if cur_date.weekday() in weekdays:
 start_hour, end_hour = working_hours
 cur_time = time(start_hour, 0)
 while cur_time < time(end_hour, 0):
 slot_start = datetime.combine(cur_date, cur_time, tzinfo=timezone.utc)
 slots.append((f"S{slot_id}", slot_start))
 slot_id += 1
 if len(slots) >= count:
 break
 # advance by slot duration
 cur_time = (dt.datetime.combine(date.min, cur_time) +
 timedelta(minutes=slot_duration_min)).time()
 cur_date += timedelta(days=1)

 return slots

Expected output (demo):

>>> from calendar_utils import get_upcoming_slots
>>> today = date(2024, 8, 4) # pretend today is 4‑Aug‑2024 (Sunday)
>>> get_upcoming_slots(today, count=5)
[('S1', datetime.datetime(2024, 8, 5, 11, 0, tzinfo=datetime.timezone.utc)),
 ('S2', datetime.datetime(2024, 8, 5, 11, 30, tzinfo=datetime.timezone.utc)),
 ('S3', datetime.datetime(2024, 8, 5, 12, 0, tzinfo=datetime.timezone.utc)),
 ('S4', datetime.datetime(2024, 8, 5, 12, 30, tzinfo=datetime.timezone.utc)),
 ('S5', datetime.datetime(2024, 8, 5, 13, 0, tzinfo=datetime.timezone.utc))]

Verify: Run the snippet above; you should see five slots starting on the next Monday.


# Save as: calendar_utils.py (append to the same file)
def google_calendar_link(
 start: datetime,
 end: datetime,
 title: str,
 details: str = ""
) -> str:
 """
 Returns a URL that, when opened, creates a Google Calendar event.
 The link works without any authentication – the user just confirms.
 """
 fmt = "%Y%m%dT%H%M%SZ" # UTC format required by Google
 start_str = start.strftime(fmt)
 end_str = end.strftime(fmt)

 base = "https://www.google.com/calendar/render"
 params = (
 f"?action=TEMPLATE"
 f"&text={title}"
 f"&dates={start_str}/{end_str}"
 f"&details={details}"
 f"&sf=true&output=xml"
 )
 return base + params

Demo output:

>>> from calendar_utils import google_calendar_link
>>> start = dt.datetime(2024, 8, 5, 11, 0, tzinfo=dt.timezone.utc)
>>> end = start + dt.timedelta(minutes=30)
>>> google_calendar_link(start, end, "Intro call with Ishant", "Discuss project X")
'https://www.google.com/calendar/render?action=TEMPLATE&text=Intro+call+with+Ishant&dates=20240805T110000Z/20240805T113000Z&details=Discuss+project+X&sf=true&output=xml'

Verify: Paste the printed URL into a browser – Google Calendar should open with the event pre‑filled.


3️⃣ Universal .ics Generator

# Save as: calendar_utils.py (append)
def generate_ics(
 start: datetime,
 end: datetime,
 title: str,
 description: str = "",
 organizer_email: str = "no-reply@example.com"
) -> str:
 """
 Returns the raw text of an iCalendar (.ics) file.
 Any modern calendar client (Google, Outlook, Apple) can import it.
 """
 uid = f"{dt.datetime.utcnow().timestamp()}@example.com"
 dtstamp = dt.datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")
 dtstart = start.strftime("%Y%m%dT%H%M%SZ")
 dtend = end.strftime("%Y%m%dT%H%M%SZ")

 lines = [
 "BEGIN:VCALENDAR",
 "VERSION:2.0",
 "PRODID:-//YourCompany//MeetingScheduler//EN",
 "CALSCALE:GREGORIAN",
 "METHOD:REQUEST",
 "BEGIN:VEVENT",
 f"UID:{uid}",
 f"DTSTAMP:{dtstamp}",
 f"DTSTART:{dtstart}",
 f"DTEND:{dtend}",
 f"SUMMARY:{title}",
 f"DESCRIPTION:{description}",
 f"ORGANIZER;CN=Scheduler:MAILTO:{organizer_email}",
 "END:VEVENT",
 "END:VCALENDAR"
 ]
 return "\r\n".join(lines)

Demo output (first 10 lines shown):

>>> from calendar_utils import generate_ics
>>> ics_text = generate_ics(start, end, "Intro call with Ishant", "Discuss project X")
>>> print("\n".join(ics_text.splitlines()[:10]))
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//YourCompany//MeetingScheduler//EN
CALSCALE:GREGORIAN
METHOD:REQUEST
BEGIN:VEVENT
UID:1726425600.123456@example.com
DTSTAMP:20240915T123456Z
DTSTART:20240805T110000Z
DTEND:20240805T113000Z

Verify: Save the printed string to invite.ics and double‑click it – your default calendar app should open the event.


4️⃣ LLM‑Friendly Tools

4.1 Offer Meeting Slots

# Save as: tools.py
from calendar_utils import get_upcoming_slots
from typing import List, Tuple

def offer_meeting_slots(
 count: int = 5,
 working_hours: Tuple[int, int] = (11, 17),
 weekdays: Set[int] = {0, 1, 2, 3, 4}
) -> str:
 """
 Returns a human‑readable list of slots.
 The LLM can simply `return` this string to the user.
 """
 slots = get_upcoming_slots(date.today(), count, working_hours, weekdays)
 lines = ["Here are the next available slots (all times in UTC):"]
 for slot_id, start in slots:
 # format nicely: e.g. "S1 – 2024‑08‑05 11:00"
 lines.append(f"{slot_id} – {start.strftime('%Y-%m-%d %H:%M')}")
 return "\n".join(lines)

Demo output:

>>> from tools import offer_meeting_slots
>>> print(offer_meeting_slots())
Here are the next available slots (all times in UTC):
S1 – 2024-08-05 11:00
S2 – 2024-08-05 11:30
S3 – 2024-08-05 12:00
S4 – 2024-08-05 12:30
S5 – 2024-08-05 13:00

Verify: Run the snippet; you should see a clean list of slot IDs and timestamps.

4.2 Book Meeting

# Save as: tools.py (append)
def book_meeting(
 slot_id: str,
 visitor_name: str,
 visitor_email: str,
 topic: str,
 slots: List[Tuple[str, datetime]] = None,
 use_google_link: bool = True
) -> str:
 """
 Validates the chosen slot, then returns either:
 * a Google Calendar link (default) or
 * a full.ics file content.
 """
 if slots is None:
 # Pull fresh slots – we assume the same parameters as `offer_meeting_slots`
 slots = get_upcoming_slots(date.today(), count=10)

 # Find the slot
 match = next(((sid, start) for sid, start in slots if sid == slot_id), None)
 if not match:
 return f"❌ Slot `{slot_id}` not found. Please choose from the offered list."

 _, start = match
 end = start + dt.timedelta(minutes=30) # fixed 30‑min meetings

 if use_google_link:
 link = google_calendar_link(start, end, topic,
 f"Meeting with {visitor_name} ({visitor_email})")
 return f"✅ Your meeting is scheduled! Click to add to Google Calendar:\n{link}"
 else:
 ics = generate_ics(start, end, topic,
 f"Meeting with {visitor_name} ({visitor_email})",
 organizer_email="scheduler@example.com")
 return f"✅ Your meeting is scheduled! Here is the.ics content:\n\n{ics}"

Demo – successful booking (Google link):

>>> from tools import book_meeting, offer_meeting_slots, get_upcoming_slots
>>> slots = get_upcoming_slots(date.today(), count=5)
>>> print(book_meeting("S2", "Alice", "alice@example.com", "Project Kick‑off", slots))
✅ Your meeting is scheduled! Click to add to Google Calendar:
https://www.google.com/calendar/render?action=TEMPLATE&text=Project+Kick‑off&dates=20240805T113000Z/20240805T120000Z&details=Meeting+with+Alice+%28alice%40example.com%29&sf=true&output=xml

Demo – fallback to .ics:

>>> print(book_meeting("S3", "Bob", "bob@example.com", "Design Review", slots, use_google_link=False)[:200])
✅ Your meeting is scheduled! Here is the.ics content:

BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//YourCompany//MeetingScheduler//EN
CALSCALE:GREGORIAN
METHOD:REQUEST
BEGIN:VEVENT
UID:1726425600.987654@example.com
DTSTAMP:20240915T123456Z
DTSTART:20240805T120000Z
DTEND:20240805T123000Z
SUMMARY:Design Review
DESCRIPTION:Meeting with Bob (bob@example.com)
ORGANIZER;CN=Scheduler:MAILTO:scheduler@example.com
END:VEVENT
END:VCALENDAR

Verify: * For the Google link – open it in a browser and confirm the event appears. * For the .ics – copy the printed block into a file meeting.ics, open it with your calendar app, and verify the details.


📁 Project Files

File Purpose
calendar_utils.py Core helpers: slot generation, Google link, .ics builder.
tools.py LLM‑friendly wrappers (offer_meeting_slots, book_meeting).
demo.py Small script that ties everything together (see below).

demo.py

# Save as: demo.py
import sys
from datetime import date
from tools import offer_meeting_slots, book_meeting, get_upcoming_slots

def main():
 print("=== 📅 Meeting Scheduler Demo ===\n")
 # 1️⃣ Show available slots
 slots_text = offer_meeting_slots(count=5)
 print(slots_text)

 # 2️⃣ Simulate user picking a slot
 chosen = input("\nEnter the slot ID you want (e.g., S2): ").strip().upper()
 name = input("Your name: ").strip()
 email = input("Your email: ").strip()
 topic = input("Meeting topic: ").strip()

 # 3️⃣ Book the meeting (Google link preferred)
 slots = get_upcoming_slots(date.today(), count=10)
 confirmation = book_meeting(chosen, name, email, topic, slots)
 print("\n" + confirmation)

if __name__ == "__main__":
 # Running `python demo.py` will walk you through the flow.
 main()

Running the demo

$ python demo.py
=== 📅 Meeting Scheduler Demo ===

Here are the next available slots (all times in UTC):
S1 – 2024-08-05 11:00
S2 – 2024-08-05 11:30
S3 – 2024-08-05 12:00
S4 – 2024-08-05 12:30
S5 – 2024-08-05 13:00

Enter the slot ID you want (e.g., S2): S2
Your name: Alice
Your email: alice@example.com
Meeting topic: Project Kick‑off

✅ Your meeting is scheduled! Click to add to Google Calendar:
https://www.google.com/calendar/render?action=TEMPLATE&text=Project+Kick‑off&dates=20240805T113000Z/20240805T120000Z&details=Meeting+with+Alice+%28alice%40example.com%29&sf=true&output=xml

Verify: Follow the prompts, click the printed URL, and confirm the event appears in your Google Calendar.


🚀 What You Just Built

  • Dynamic slot list that respects working days/hours.
  • One‑click Google Calendar URL – no OAuth required.
  • Universal .ics generator for Outlook/Apple/any iCal client.
  • LLM‑ready tools (offer_meeting_slots, book_meeting) that can be registered as function calls in a LangChain/AutoGPT‑style agent.

Common Mistakes

Mistake Why it Happens Fix
Using local timezone instead of UTC Calendar URLs expect UTC (Z suffix). Always attach tzinfo=timezone.utc when building datetime objects.
Hard‑coding weekday set Forgetting to include Saturday when you need it. Parameterise weekdays and document the default ({0,1,2,3,4}).
Generating duplicate UIDs Using datetime.now() without seconds can clash. Use datetime.utcnow().timestamp() (includes fractional seconds) or a UUID.
Returning raw .ics string without line breaks Some clients need \r\n line endings. Join lines with "\r\n".join(lines) as shown.
Not validating the chosen slot Users may type a non‑existent ID. book_meeting already returns a friendly error; keep that guard.

Troubleshooting

  • Link opens a blank page – Ensure the URL is URL‑encoded (spaces become + or %20). Python’s urllib.parse.quote_plus can be used if you add special characters.
  • .ics file shows wrong times – Verify that both start and end are in UTC. If you need local time, convert with astimezone() before formatting.
  • Slot list stops early – Check that count is not larger than the number of possible slots within the date range you supplied. Increase the range or reduce count.

Key Takeaways

  1. Time‑zone handling is the single biggest source of bugs; always work in UTC internally and convert only for display.
  2. A single URL can add an event to Google Calendar – perfect for LLM‑driven assistants that cannot perform OAuth flows.
  3. The iCalendar format is tiny, plain‑text, and universally supported; generating it yourself removes any external dependency.
  4. By exposing pure functions (offer_meeting_slots, book_meeting) you give the LLM a clean contract to call – no need for complex HTTP servers in the prototype stage.

🧪 Try It Yourself

  1. Add a weekend slot – modify weekdays to {0,1,2,3,4,5} and rerun demo.py.
  2. Change slot length – pass slot_duration_min=45 to get_upcoming_slots and observe the new schedule.
  3. Export an .ics file – call book_meeting(., use_google_link=False) from the REPL, save the output to my_meeting.ics, and import it into Outlook.
  4. Integrate with LangChain – register offer_meeting_slots and book_meeting as tool objects and let the LLM decide when to call them.

Happy coding! 🎉

Why This Matters

Modern AI assistants are no longer just “chat‑bots.” To be professional they must:

  • Act on requests – send emails, create calendar events, notify teammates.
  • Remember context – keep a history of interactions and store unknown questions for later review.
  • Prioritize leads – automatically sort inbound inquiries (hot, warm, cold) so you only get the notifications that truly matter.

By wrapping a large language model (LLM) with a tool harness you give it the same powers a human assistant would have, while keeping the implementation lightweight and fully under your control.


What You’ll Build

In this chapter you will extend the LLM agent from the previous chapter with:

Feature Description
Email & Notification tools Send an email, push a Slack‑style notification, and record any “unknown” questions.
Scheduling tool Offer meeting slots, confirm a selection, and generate a Google‑Calendar‑compatible HTML invite.
Mini‑CRM Store every lead in a CSV file, classify the intent (hot / warm / cold), and automatically route notifications based on the classification.
Full conversation loop Simulate a real‑world dialogue (Priya from Infosys HR) and watch the agent orchestrate all tools end‑to‑end.

When you run the final script you will see:

✅ Email sent to priya@infosys.com
✅ Notification sent to Ishant about the meeting request
✅ Lead saved as hot in leads.csv
✅ Calendar invite generated and saved as invite.html

Content

1️⃣ Set up the environment

pip install openai python-dotenv pandas

Create a .env file in the project root with your OpenAI key:

OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

2️⃣ Define the tool harness

We’ll use OpenAI function calling to let the LLM invoke Python functions as tools. Each tool is a plain function that returns a JSON‑serialisable result.

# Save as: tools.py
import os
import json
import smtplib
import ssl
import pandas as pd
from email.message import EmailMessage
from datetime import datetime, timedelta
from pathlib import Path
from dotenv import load_dotenv

load_dotenv()

# ----------------------------------------------------------------------
# 1️⃣ Email tool
# ----------------------------------------------------------------------
def send_email(to_address: str, subject: str, body: str) -> dict:
 """Send a plain‑text email via Gmail SMTP."""
 sender = os.getenv("GMAIL_SENDER")
 password = os.getenv("GMAIL_PASSWORD")
 if not sender or not password:
 raise RuntimeError("GMAIL_SENDER / GMAIL_PASSWORD not set in.env")

 msg = EmailMessage()
 msg["From"] = sender
 msg["To"] = to_address
 msg["Subject"] = subject
 msg.set_content(body)

 context = ssl.create_default_context()
 with smtplib.SMTP_SSL("smtp.gmail.com", 465, context=context) as server:
 server.login(sender, password)
 server.send_message(msg)

 return {"status": "sent", "to": to_address, "subject": subject}

# ----------------------------------------------------------------------
# 2️⃣ Notification tool (simulated Slack webhook)
# ----------------------------------------------------------------------
def push_notification(channel: str, message: str) -> dict:
 """Pretend to push a notification – here we just log to console."""
 print(f"[🔔 Notification → {channel}] {message}")
 return {"status": "notified", "channel": channel, "message": message}

# ----------------------------------------------------------------------
# 3️⃣ Record unknown question
# ----------------------------------------------------------------------
UNKNOWN_Q_FILE = Path("unknown_questions.txt")
def record_unknown(question: str) -> dict:
 """Append an unanswered question to a plain‑text log."""
 UNKNOWN_Q_FILE.parent.mkdir(exist_ok=True)
 with UNKNOWN_Q_FILE.open("a", encoding="utf-8") as f:
 f.write(f"{datetime.utcnow().isoformat()} | {question}\n")
 return {"status": "recorded", "question": question}

# ----------------------------------------------------------------------
# 4️⃣ Scheduling tool
# ----------------------------------------------------------------------
def propose_meeting_slots() -> dict:
 """Return three dummy slots (UTC) for the next two days."""
 now = datetime.utcnow()
 slots = [
 (now + timedelta(days=1, hours=9)).strftime("%Y-%m-%d %H:%M UTC"),
 (now + timedelta(days=1, hours=14)).strftime("%Y-%m-%d %H:%M UTC"),
 (now + timedelta(days=2, hours=11)).strftime("%Y-%m-%d %H:%M UTC"),
 ]
 return {"slots": slots}

def confirm_meeting(slot: str, email: str, topic: str) -> dict:
 """Generate a simple Google‑Calendar HTML invite and "send" it."""
 invite_html = f"""
 <html>
 <body>
 <h2>Meeting Confirmation</h2>
 <p><strong>Topic:</strong> {topic}</p>
 <p><strong>When:</strong> {slot}</p>
 <p><strong>Join:</strong> <a href="https://meet.google.com/lookup/{slot.replace(' ', '').replace(':','')}">Google Meet Link</a></p>
 </body>
 </html>
 """
 invite_path = Path("invite.html")
 invite_path.write_text(invite_html, encoding="utf-8")
 # In a real system you would email the HTML; here we just log.
 print(f"[📅 Invite] Saved to {invite_path.resolve()}")
 return {"status": "confirmed", "slot": slot, "invite_path": str(invite_path)}

# ----------------------------------------------------------------------
# 5️⃣ Mini‑CRM (CSV‑backed)
# ----------------------------------------------------------------------
CRM_FILE = Path("leads.csv")
CRM_COLUMNS = ["timestamp", "name", "email", "intent", "status"]

def init_crm():
 """Create the CSV file with headers if it does not exist."""
 if not CRM_FILE.exists():
 pd.DataFrame(columns=CRM_COLUMNS).to_csv(CRM_FILE, index=False)

def add_lead(name: str, email: str, intent: str, status: str = "new") -> dict:
 """Append a lead to the CSV and return the row."""
 init_crm()
 row = {
 "timestamp": datetime.utcnow().isoformat(),
 "name": name,
 "email": email,
 "intent": intent,
 "status": status,
 }
 df = pd.read_csv(CRM_FILE)
 df = df.append(row, ignore_index=True)
 df.to_csv(CRM_FILE, index=False)
 return {"status": "saved", "lead": row}

def classify_intent(message: str) -> str:
 """Very naive rule‑based intent classifier."""
 lowered = message.lower()
 if "schedule" in lowered or "meeting" in lowered:
 return "hot"
 if "information" in lowered or "details" in lowered:
 return "warm"
 return "cold"

✅ Verify: Run python -c "import tools; print('tools loaded')" – you should see tools loaded without errors.


3️⃣ The Agent Orchestrator

# Save as: agent.py
import json
import openai
from datetime import datetime
from tools import (
 send_email,
 push_notification,
 record_unknown,
 propose_meeting_slots,
 confirm_meeting,
 add_lead,
 classify_intent,
)

openai.api_key = os.getenv("OPENAI_API_KEY")

# ----------------------------------------------------------------------
# Helper: map function name → actual Python callable
# ----------------------------------------------------------------------
FUNCTION_MAP = {
 "send_email": send_email,
 "push_notification": push_notification,
 "record_unknown": record_unknown,
 "propose_meeting_slots": propose_meeting_slots,
 "confirm_meeting": confirm_meeting,
 "add_lead": add_lead,
}

# ----------------------------------------------------------------------
# OpenAI function specifications (must match the signatures above)
# ----------------------------------------------------------------------
FUNCTION_DEFINITIONS = [
 {
 "name": "send_email",
 "description": "Send an email to a recipient.",
 "parameters": {
 "type": "object",
 "properties": {
 "to_address": {"type": "string", "description": "Recipient email"},
 "subject": {"type": "string"},
 "body": {"type": "string"},
 },
 "required": ["to_address", "subject", "body"],
 },
 },
 {
 "name": "push_notification",
 "description": "Push a short notification to a channel (e.g., Slack).",
 "parameters": {
 "type": "object",
 "properties": {
 "channel": {"type": "string"},
 "message": {"type": "string"},
 },
 "required": ["channel", "message"],
 },
 },
 {
 "name": "record_unknown",
 "description": "Log a question that the model cannot answer.",
 "parameters": {
 "type": "object",
 "properties": {"question": {"type": "string"}},
 "required": ["question"],
 },
 },
 {
 "name": "propose_meeting_slots",
 "description": "Return three possible meeting slots.",
 "parameters": {"type": "object", "properties": {}},
 },
 {
 "name": "confirm_meeting",
 "description": "Confirm a chosen slot and generate a calendar invite.",
 "parameters": {
 "type": "object",
 "properties": {
 "slot": {"type": "string"},
 "email": {"type": "string"},
 "topic": {"type": "string"},
 },
 "required": ["slot", "email", "topic"],
 },
 },
 {
 "name": "add_lead",
 "description": "Add a new lead to the CSV‑based CRM.",
 "parameters": {
 "type": "object",
 "properties": {
 "name": {"type": "string"},
 "email": {"type": "string"},
 "intent": {"type": "string"},
 "status": {"type": "string"},
 },
 "required": ["name", "email", "intent"],
 },
 },
]

# ----------------------------------------------------------------------
# Core loop – runs until the model says it’s done
# ----------------------------------------------------------------------
def run_conversation(user_input: str, history: list = None):
 if history is None:
 history = []

 messages = [{"role": "system", "content": (
 "You are a professional AI assistant for a consultant. "
 "You can call tools defined in the function list. "
 "When you need to call a tool, respond with a JSON function call. "
 "When you have a final answer for the user, respond with plain text."
 )}]
 # Append prior turns
 messages.extend(history)
 # Add the newest user turn
 messages.append({"role": "user", "content": user_input})

 while True:
 response = openai.ChatCompletion.create(
 model="gpt-4o-mini",
 messages=messages,
 functions=FUNCTION_DEFINITIONS,
 function_call="auto",
 )
 choice = response["choices"][0]["message"]

 # --------------------------------------------------------------
 # 1️⃣ Did the model want to call a function?
 # --------------------------------------------------------------
 if choice.get("function_call"):
 func_name = choice["function_call"]["name"]
 arguments = json.loads(choice["function_call"]["arguments"])
 print(f"[🤖] Calling tool: {func_name} with {arguments}")

 # Execute the real Python function
 result = FUNCTION_MAP[func_name](**arguments)

 # Append the function call + result to the message list
 messages.append({
 "role": "assistant",
 "content": None,
 "function_call": {
 "name": func_name,
 "arguments": json.dumps(arguments),
 },
 })
 messages.append({
 "role": "function",
 "name": func_name,
 "content": json.dumps(result),
 })
 # Loop again – the model now sees the result and can decide next step
 continue

 # --------------------------------------------------------------
 # 2️⃣ Plain text answer – we are done
 # --------------------------------------------------------------
 final_answer = choice["content"]
 print(f"\n🗨️ Assistant: {final_answer}\n")
 # Save this turn into history for possible future calls
 history.append({"role": "assistant", "content": final_answer})
 break

 return history

✅ Verify: bash python - <<'PY' from agent import run_conversation run_conversation("Hi, I am Priya from Infosys HR. Can we schedule a 30‑minute call this week?") PY You should see the assistant propose slots, push a notification, and finally ask for an email address.


4️⃣ Simulating the Full Interaction

# Save as: demo.py
from agent import run_conversation

# ----------------------------------------------------------------------
# Simulated dialogue with Priya
# ----------------------------------------------------------------------
history = []

# 1️⃣ Priya initiates the request
history = run_conversation(
 "Hi, I am Priya from Infosys HR. Can we schedule a 30‑minute call this week?",
 history,
)

# 2️⃣ Assistant proposes slots (function call already executed)
# Priya picks a slot and provides email + topic
history = run_conversation(
 "Slot 1 works for me. My email is priya.infosys@example.com and the topic is 'Hiring Strategy'.",
 history,
)

# 3️⃣ Assistant confirms and sends calendar invite
# (All tool calls happen inside the agent loop)

Expected console output (abridged for readability):

[🤖] Calling tool: propose_meeting_slots with {}
[🤖] Calling tool: push_notification with {'channel': 'ishant', 'message': 'Priya requested a 30‑minute call.'}
[🤖] Calling tool: add_lead with {'name': 'Priya', 'email': 'pri.@example.com', 'intent': 'hot','status': 'new'}

🗨️ Assistant: Hi Priya, I have noted your request for a 30‑minute call this week and notified Ishant. Here are three possible slots:
1. 2024‑09‑16 09:00 UTC
2. 2024‑09‑16 14:00 UTC
3. 2024‑09‑17 11:00 UTC
Please let me know which one works for you.

[🤖] Calling tool: confirm_meeting with {'slot': '2024-09-16 09:00 UTC', 'email': 'pri.@example.com', 'topic': 'Hiring Strategy'}
[📅 Invite] Saved to /full/path/to/invite.html

🗨️ Assistant: Your meeting is confirmed for 2024‑09‑16 09:00 UTC. I’ve sent a calendar invite to pri.@example.com. You can add it to your calendar using the link in the email.

You will also find two new files in the project root:

  • leads.csv – contains Priya’s lead classified as hot.
  • invite.html – a simple HTML calendar invite you can open in a browser.

📁 Project Files

File Purpose
tools.py All low‑level utilities (email, notification, scheduling, CRM).
agent.py Orchestrates the LLM, parses function calls, and updates conversation history.
demo.py End‑to‑end script that runs a realistic conversation.
.env Stores OPENAI_API_KEY, GMAIL_SENDER, GMAIL_PASSWORD.
leads.csv Auto‑generated CRM store (created on first run).
invite.html Calendar invite generated after meeting confirmation.
unknown_questions.txt Log of any question the model could not answer.

🚀 What You Just Built

  1. A tool‑rich LLM agent that can email, notify, record unknowns, schedule meetings, and store leads in a CSV‑based CRM.
  2. Intent‑driven lead classification – hot leads trigger immediate notifications, warm/cold leads are still stored but you can filter them later.
  3. A complete conversation loop that automatically decides which tool to call, passes results back to the model, and produces a polished final answer.

All of this runs on pure Python with no external SaaS CRM, making it perfect for prototypes, personal assistants, or small consulting businesses.


Common Mistakes

Mistake Why it Happens Fix
Forgetting to add function_call="auto" in the OpenAI request The model will never invoke tools. Ensure function_call="auto" (or "none" when you deliberately disable it).
Mismatched parameter names between FUNCTION_DEFINITIONS and the Python functions The JSON payload cannot be unpacked. Keep the exact same key names (to_address, subject, body, …).
CSV file locked by another process pandas tries to write while the file is open. Close any editors that have leads.csv open, or use a lock file.
Gmail SMTP blocked by “less secure apps” Gmail rejects the login. Enable App Passwords (if using 2‑FA) or use a dedicated service account.
The model classifies intent incorrectly Rule‑based classifier is too naive. Replace classify_intent with a small fine‑tuned classifier or a more sophisticated keyword map.

Troubleshooting

Symptom Likely Cause Quick Test
No email arrives SMTP credentials missing or wrong Run python -c "from tools import send_email; print(send_email('you@example.com','test','body'))"
unknown_questions.txt stays empty even though you expect entries The LLM never called record_unknown. Add a user message like “What is the meaning of life?” and watch the logs.
invite.html contains None instead of a link slot string format mismatch when calling confirm_meeting. Print the slot argument before calling confirm_meeting.
Leads are always classified as cold classify_intent logic too strict. Call classify_intent("I need a meeting") in a REPL.
The script crashes with KeyError: 'function_call' Using an older OpenAI model that doesn’t support function calling. Switch to gpt-4o-mini or newer.

Key Takeaways

  • Function calling turns an LLM into a real assistant that can act on the world.
  • A lightweight CSV CRM is enough for early‑stage projects and can be swapped for a full‑blown CRM later.
  • Intent classification (even a simple rule‑based one) lets you prioritize notifications and avoid inbox overload.
  • Keep the tool harness modular – each tool lives in tools.py and can be unit‑tested independently.

🧪 Try It Yourself

  1. Add a “cold‑lead” path – modify classify_intent to detect phrases like “just browsing” and store them with status="cold".
  2. Replace the CSV CRM with a tiny SQLite DB (sqlite3 module) and observe the performance gain.
  3. Integrate a real Slack webhook – replace push_notification with an HTTP POST to your workspace.
  4. Fine‑tune the intent classifier using a small labeled dataset and sklearn’s LogisticRegression.

Happy building! 🎉

Why This Matters

A lead‑management system is the backbone of any sales‑oriented product. When an LLM can read new leads, classify them (cold / warm / hot) and trigger a notification without any manual steps, you turn a noisy inbox into a high‑velocity pipeline. In this chapter you’ll see exactly how to:

  • Store leads in a simple CSV (or Google‑Sheet‑like) file.
  • Define a lead schema so the LLM knows which fields to extract.
  • Build Python tools that the LLM can call:
  • datetime.now() → real‑time timestamps.
  • surpar → cheap web‑search for “who won FIFA 2026?”‑style queries.
  • smtplib → email notifications for hot leads.
  • Wire those tools into an LLM agent (using LangChain) so the model can “think” and then act.

By the end you’ll have a fully functional, locally‑run CRM‑assistant that you can extend to any SaaS product.


What You’ll Build

A minimal yet production‑ready Leads CRM Agent that:

  1. Ingests a new raw conversation (plain text).
  2. Extracts the fields you care about (name, email, company, intent, etc.).
  3. Scores the lead (cold / warm / hot) based on intent keywords.
  4. Appends the structured lead to leads.csv.
  5. Sends an email notification only for hot leads.
  6. Can answer auxiliary queries (e.g., “What time is it?” or “Who won the 2022 FIFA World Cup?”) by invoking the appropriate tool.

Content

1️⃣ Set Up the Project

Create a fresh folder lead_crm/ and place the following files inside it.

lead_crm/
├─.env
├─ leads.csv
├─ lead_schema.py
├─ lead_tool.py
├─ agent.py
├─ main.py
└─ requirements.txt

2️⃣ Install Dependencies

pip install -r requirements.txt

requirements.txt

python-dotenv
pandas
openai
langchain
langchain-community
requests

✅ Verify: pip list should show the packages above without errors.


3️⃣ Environment Variables

.env (keep this file secret – never commit it)

OPENAI_API_KEY=sk-XXXXXXXXXXXXXXXXXXXXXXXX
SURPAR_API_KEY=YOUR_SURPAR_API_KEY
SMTP_SERVER=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your.email@gmail.com
SMTP_PASSWORD=your_app_password
NOTIFY_EMAIL=your.email@gmail.com

✅ Verify: Run python -c "from dotenv import load_dotenv; load_dotenv(); import os; print(os.getenv('OPENAI_API_KEY')[:5])" – it should print the first 5 characters of your key.


4️⃣ Define the Lead Schema

lead_schema.py

# Save as: lead_schema.py
from dataclasses import dataclass
from datetime import datetime
from typing import Literal

LeadScore = Literal["cold", "warm", "hot"]

@dataclass
class Lead:
 timestamp: datetime
 name: str
 email: str
 company: str
 intent: str
 score: LeadScore
 reason: str

Expected output – nothing is printed; the file simply defines the Lead class.

✅ Verify: python -c "from lead_schema import Lead; print(Lead)" → should show <class 'lead_schema.Lead'>.


5️⃣ CSV Helper & Notification Tool

lead_tool.py

# Save as: lead_tool.py
import os
import smtplib
import pandas as pd
from datetime import datetime
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from dotenv import load_dotenv
from lead_schema import Lead

load_dotenv() # Load.env variables

CSV_PATH = "leads.csv"

def init_csv() -> None:
 """Create leads.csv with a header if it does not exist."""
 if not os.path.exists(CSV_PATH):
 df = pd.DataFrame(columns=[
 "timestamp", "name", "email", "company",
 "intent", "score", "reason"
 ])
 df.to_csv(CSV_PATH, index=False)
 print(f"✅ Created {CSV_PATH}")

def append_lead(lead: Lead) -> None:
 """Append a Lead dataclass instance to the CSV."""
 df = pd.DataFrame([{
 "timestamp": lead.timestamp.isoformat(),
 "name": lead.name,
 "email": lead.email,
 "company": lead.company,
 "intent": lead.intent,
 "score": lead.score,
 "reason": lead.reason,
 }])
 df.to_csv(CSV_PATH, mode="a", header=False, index=False)
 print(f"✅ Lead for {lead.name} appended.")

def send_email_notification(lead: Lead) -> None:
 """Send an email only for hot leads."""
 if lead.score != "hot":
 return # No notification for cold/warm leads

 smtp_server = os.getenv("SMTP_SERVER")
 smtp_port = int(os.getenv("SMTP_PORT"))
 smtp_user = os.getenv("SMTP_USER")
 smtp_pass = os.getenv("SMTP_PASSWORD")
 notify_to = os.getenv("NOTIFY_EMAIL")

 subject = f"🔥 Hot Lead: {lead.name} from {lead.company}"
 body = f"""\
Hi Team,

A **hot** lead has just been captured!

Name: {lead.name}
Email: {lead.email}
Company: {lead.company}
Intent: {lead.intent}
Reason: {lead.reason}
Timestamp: {lead.timestamp.isoformat()}

Best,
Lead CRM Bot
"""

 msg = MIMEMultipart()
 msg["From"] = smtp_user
 msg["To"] = notify_to
 msg["Subject"] = subject
 msg.attach(MIMEText(body, "plain"))

 try:
 with smtplib.SMTP(smtp_server, smtp_port) as server:
 server.starttls()
 server.login(smtp_user, smtp_pass)
 server.send_message(msg)
 print("✅ Notification email sent.")
 except Exception as e:
 print(f"⚠️ Failed to send email: {e}")

def classify_intent(intent_text: str) -> tuple[LeadScore, str]:
 """Very simple rule‑based classifier."""
 lowered = intent_text.lower()
 if "hire" in lowered or "need a consultant" in lowered:
 return "hot", "Explicit purchase intent"
 if "interested" in lowered or "would like to know" in lowered:
 return "warm", "Shows interest but no firm offer"
 return "cold", "General curiosity"

Expected output (when you run python -c "import lead_tool; lead_tool.init_csv()"):

✅ Created leads.csv

✅ Verify: Run the snippet above; you should see the file created and the message printed.


6️⃣ Web‑Search Tool (Surpar)

surpar_tool.py

# Save as: surpar_tool.py
import os
import requests
from dotenv import load_dotenv

load_dotenv()
SURPAR_API_KEY = os.getenv("SURPAR_API_KEY")
BASE_URL = "https://api.surpar.com/search"

def web_search(query: str, num_results: int = 3) -> str:
 """Call Surpar's free web‑search API and return a short summary."""
 headers = {"Authorization": f"Bearer {SURPAR_API_KEY}"}
 payload = {"q": query, "num": num_results}
 try:
 resp = requests.get(BASE_URL, headers=headers, params=payload, timeout=10)
 resp.raise_for_status()
 data = resp.json()
 # Surpar returns a list of result dicts with `title` and `snippet`
 snippets = [f"{r['title']}: {r['snippet']}" for r in data.get("results", [])]
 return "\n".join(snippets) or "No results found."
 except Exception as e:
 return f"⚠️ Search failed: {e}"

Expected output (run a quick test):

python -c "from surpar_tool import web_search; print(web_search('Python official website'))"

Sample output (your results may differ):

Python.org: The official home of the Python Programming Language.

✅ Verify: You should receive at least one line of result text.


7️⃣ LLM Agent with Tool Access

agent.py

# Save as: agent.py
import os
from datetime import datetime
from typing import Any, Dict

import openai
from langchain.agents import AgentExecutor, tool
from langchain.tools import BaseTool
from langchain.prompts import ChatPromptTemplate, SystemMessagePromptTemplate, HumanMessagePromptTemplate
from langchain.schema import AIMessage, HumanMessage, SystemMessage
from langchain.chat_models import ChatOpenAI
from dotenv import load_dotenv

from lead_schema import Lead
from lead_tool import init_csv, append_lead, send_email_notification, classify_intent
from surpar_tool import web_search

load_dotenv()
openai.api_key = os.getenv("OPENAI_API_KEY")

# ---------- Custom LangChain Tools ----------
class GetCurrentTimeTool(BaseTool):
 name = "get_current_time"
 description = "Returns the current date and time as an ISO‑8601 string."

 def _run(self) -> str:
 return datetime.utcnow().isoformat() + "Z"

class WebSearchTool(BaseTool):
 name = "web_search"
 description = "Search the web for the given query and return a short summary."

 def _run(self, query: str) -> str:
 return web_search(query)

# Register the tools
tools = [GetCurrentTimeTool(), WebSearchTool()]

# ---------- Prompt that tells the model how to behave ----------
system_msg = SystemMessagePromptTemplate.from_template(
 """You are a sales‑assistant LLM. 
Your job is to:
1. Extract lead information from a raw conversation.
2. Classify the lead (cold / warm / hot) using the `classify_intent` rules.
3. Append the structured lead to the CSV.
4. If the lead is hot, send an email notification.

You may call the following tools when needed:
{tool_descriptions}
When you have finished, respond with ONLY a JSON object that matches the Lead dataclass schema."""
)

def build_agent() -> AgentExecutor:
 # Build a simple chat model
 llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)

 # Create a prompt that injects tool descriptions
 tool_desc = "\n".join([f"{t.name}: {t.description}" for t in tools])
 prompt = ChatPromptTemplate.from_messages([
 system_msg,
 HumanMessagePromptTemplate.from_template("{input}")
 ])

 # Define a custom tool that wraps our Python lead‑processing logic
 @tool
 def process_lead(lead_json: str) -> str:
 """Receive a JSON string that matches the Lead schema, store it, and notify if hot."""
 import json
 data = json.loads(lead_json)
 lead = Lead(
 timestamp=datetime.fromisoformat(data["timestamp"]),
 name=data["name"],
 email=data["email"],
 company=data["company"],
 intent=data["intent"],
 score=data["score"],
 reason=data["reason"],
 )
 append_lead(lead)
 send_email_notification(lead)
 return "Lead processed successfully."

 # Assemble the executor
 executor = AgentExecutor.from_agent_and_tools(
 agent=llm,
 tools=[process_lead] + tools,
 prompt=prompt,
 verbose=True,
 )
 return executor

# ---------- Helper to run the agent ----------
def run_agent(raw_conversation: str) -> Dict[str, Any]:
 """Runs the LLM agent on a raw conversation and returns the final response."""
 agent = build_agent()
 response = agent.invoke({"input": raw_conversation})
 return response

Explanation of key sections

Section Why it matters
GetCurrentTimeTool Shows how to expose real‑time data to the LLM.
WebSearchTool Demonstrates internet‑enabled queries via a hosted tool (Surpar).
process_lead The bridge that takes the LLM’s JSON output, persists it, and triggers notifications.
system_msg Gives the model a clear contract: extract → classify → store → notify.

✅ Verify: Run a quick sanity check:

python -c "from agent import run_agent; print(run_agent('John Doe contacted us on 2024‑09‑10. He works at Acme Corp and wants an AI consultant.'))"

You should see a verbose LangChain trace (because verbose=True) and a final JSON payload like:

{
 "timestamp": "2024-09-10T12:34:56Z",
 "name": "John Doe",
 "email": "",
 "company": "Acme Corp",
 "intent": "wants an AI consultant",
 "score": "hot",
 "reason": "Explicit purchase intent"
}

(Exact timestamps will differ.)


8️⃣ Demo Script

main.py

# Save as: main.py
from agent import run_agent
from lead_tool import init_csv

def demo():
 # Ensure the CSV exists
 init_csv()

 # Example raw conversation (could be a transcript, email body, etc.)
 raw = """
 Hi, I'm Ankit Sharma from ABC Technologies.
 We are looking to hire an AI consultant for a short‑term project.
 Could you share your rates?
 """

 print("🚀 Running the Lead CRM Agent.")
 result = run_agent(raw)
 print("\n=== Final Agent Output ===")
 print(result)

if __name__ == "__main__":
 demo()

Expected output (truncated for brevity):

🚀 Running the Lead CRM Agent.
✅ Lead for Ankit Sharma appended.
✅ Notification email sent.
=== Final Agent Output ===
{'output': 'Lead processed successfully.'}

✅ Verify: Execute python main.py. If you receive the “Lead processed successfully.” message and see a new row in leads.csv, everything is wired correctly.


📁 Project Files

File Purpose
.env Stores secrets (OpenAI, Surpar, SMTP).
leads.csv Persistent storage for extracted leads.
lead_schema.py Lead dataclass – the contract between LLM and Python.
lead_tool.py CSV handling, email notification, simple intent classifier.
surpar_tool.py Wrapper around Surpar’s free web‑search API.
agent.py LangChain agent with tool integration (datetime, web_search, process_lead).
main.py End‑to‑end demo script.
requirements.txt Pin required Python packages.

🚀 What You Just Built

  • A self‑contained CRM assistant that can read raw text, turn it into structured data, and act on it.
  • Tool‑augmented LLM: the model now has access to real‑time clock, web search, and custom Python logic.
  • Zero‑code onboarding for new leads – just drop a text snippet into main.py (or call run_agent from any service).

Common Mistakes

Mistake Symptom Fix
Forgetting to run init_csv() before the first lead FileNotFoundError when appending Call init_csv() at startup (see main.py).
Using a personal Gmail password instead of an App Password SMTP authentication error Generate an App Password in Google Account → “Security”.
Not setting SURPAR_API_KEY ⚠️ Search failed:. Sign up at https://surpar.com and copy the key into .env.
LLM returns a malformed JSON (missing a field) json.JSONDecodeError in process_lead Ensure the system prompt explicitly asks for exact JSON matching the Lead schema.
Running on a machine without internet Web‑search tool crashes The agent will still work for lead extraction; only web queries will fail.

Troubleshooting

  1. No email arrives
  2. Verify SMTP credentials (SMTP_USER, SMTP_PASSWORD).
  3. Check spam folder.
  4. Run python -c "import smtplib, os; print(os.getenv('SMTP_SERVER'))" to ensure the server address is correct.

  5. Surpar returns “Invalid API key”

  6. Double‑check the key in .env.
  7. Ensure there are no stray quotes or spaces.

  8. Agent hangs on tool call

  9. Increase the timeout in surpar_tool.py (timeout=20).
  10. Look at the LangChain verbose logs – they show which tool is being invoked.

  11. CSV rows are duplicated

  12. The agent may be invoked multiple times for the same conversation.
  13. Add an idempotency check (e.g., hash of raw_conversation) before append_lead.

Key Takeaways

  • Tool‑augmented LLMs turn a static language model into an interactive assistant.
  • A clear schema (Lead dataclass) is the contract that guarantees deterministic output.
  • Rule‑based classification is enough to start; you can later replace it with a fine‑tuned model.
  • Email notifications close the loop – the moment a hot lead appears, the sales team is alerted.
  • All of this runs locally with a single CSV file, yet can be swapped for a cloud database or Google Sheet with minimal changes.

🧪 Try It Yourself

  1. Add a new lead – edit raw in main.py with a different conversation (e.g., a cold inquiry).
  2. Change the classifier – modify classify_intent in lead_tool.py to use more keywords or a tiny ML model.
  3. Swap the storage – replace the CSV logic with a SQLite table (still using pandas).
  4. Expose more tools – implement a calendar_lookup tool that calls Google Calendar API and add it to tools.

Happy building! 🎉

Why This Matters

When you expose a conversational AI to the world, not every visitor is a qualified lead. Some people just say “hi”, others are curious students, and a few are genuine recruiters or clients. If you treat every interaction the same, you’ll drown in noise, waste resources, and miss the real opportunities.

A lead‑scoring CRM that automatically:

  1. Classifies intent (hot, warm, cold)
  2. Scores the lead accordingly
  3. Persists the data in a CSV (or Excel) file

gives you a clean, searchable record and lets you trigger downstream actions (e.g., Slack alerts, email notifications) only for the leads that matter.


What You’ll Build

You will create a small, self‑contained Python module that:

  • Accepts lead information (name, email, company, intent, reason)
  • Determines the lead score (hot, warm, cold) based on the intent
  • Writes the record to a CSV file, creating the file and header row on first run
  • Returns a summary that can be used by an LLM‑driven agent to decide whether to notify a human

All of this will be runnable out‑of‑the‑box—no extra research required.


Content

1️⃣ Define the data model

# Save as: lead_model.py
from dataclasses import dataclass

@dataclass
class Lead:
 """Simple container for a single lead."""
 name: str
 email: str
 company: str
 intent: str # Expected values: "hot", "warm", "cold"
 reason: str # One‑sentence explanation of the score

2️⃣ Lead‑scoring logic

# Save as: scorer.py
from typing import Tuple

def classify_intent(message: str) -> Tuple[str, str]:
 """
 Very naive intent classifier.
 Returns a tuple of (intent, reason).

 - "hot" → explicit hiring request or budget discussion
 - "warm" → genuine interest in product/services
 - "cold" → generic curiosity, greetings, or unrelated chatter
 """
 lowered = message.lower()
 if any(word in lowered for word in ["hire", "recruit", "budget", "position", "job opening"]):
 return "hot", "User explicitly mentions hiring needs."
 if any(word in lowered for word in ["interested", "learn more", "demo", "pricing", "features"]):
 return "warm", "User shows genuine interest in the offering."
 return "cold", "User is just saying hello or asking unrelated questions."

3️⃣ CSV persistence helper

# Save as: csv_logger.py
import csv
import os
from pathlib import Path
from lead_model import Lead

CSV_PATH = Path("leads.csv")
CSV_HEADERS = ["name", "email", "company", "intent", "reason"]

def _ensure_file():
 """Create the CSV file with a header row if it does not exist."""
 if not CSV_PATH.is_file():
 with CSV_PATH.open(mode="w", newline="", encoding="utf-8") as f:
 writer = csv.writer(f)
 writer.writerow(CSV_HEADERS)

def log_lead(lead: Lead) -> str:
 """
 Append a lead to `leads.csv` and return a short human‑readable summary.
 The function is safe to call concurrently from multiple processes
 because it opens the file in append mode each time.
 """
 _ensure_file()
 with CSV_PATH.open(mode="a", newline="", encoding="utf-8") as f:
 writer = csv.writer(f)
 writer.writerow([lead.name, lead.email, lead.company, lead.intent, lead.reason])

 # Return a summary that the LLM can embed in its final answer
 return f"✅ Logged {lead.intent.upper()} lead: {lead.name} ({lead.company})"

4️⃣ Glue it together – a tiny “agent” simulation

# Save as: main.py
import sys
from lead_model import Lead
from scorer import classify_intent
from csv_logger import log_lead

def process_message(name: str, email: str, company: str, message: str) -> str:
 """
 Simulates the LLM’s decision flow:
 1. Classify intent from the raw user message.
 2. Build a Lead object.
 3. Persist it.
 4. Return the LLM‑friendly summary.
 """
 intent, reason = classify_intent(message)
 lead = Lead(name=name, email=email, company=company, intent=intent, reason=reason)
 summary = log_lead(lead)
 return summary

if __name__ == "__main__":
 # Quick demo using command‑line arguments:
 # python main.py "Priya Sharma" "priya@infosys.com" "Infosys" "We are looking to hire 5 data engineers."
 if len(sys.argv) != 5:
 print("Usage: python main.py <name> <email> <company> <message>")
 sys.exit(1)

 _, name, email, company, message = sys.argv
 print(process_message(name, email, company, message))

Expected output (run the demo)

$ python main.py "Priya Sharma" "priya@infosys.com" "Infosys" "We are looking to hire 5 data engineers."
✅ Logged HOT lead: Priya Sharma (Infosys)

✅ Verify: After running the command above, a file named leads.csv should exist in the same directory with the following content:

name,email,company,intent,reason
Priya Sharma,priya@infosys.com,Infosys,hot,User explicitly mentions hiring needs.

📁 Project Files

File Purpose
lead_model.py @dataclass definition for a lead record
scorer.py Very simple intent‑to‑score classifier
csv_logger.py Handles CSV creation, header management, and appending
main.py Demonstrates end‑to‑end flow (can be used as a CLI)

All files are stand‑alone; just place them in the same folder and run python main.py ….


🚀 What You Just Built

  • A reusable Lead data class that cleanly represents a prospect.
  • A rule‑based intent classifier that maps raw user text to one of three scores (hot, warm, cold).
  • A robust CSV logger that creates the file on first use, writes a header, and appends new rows safely.
  • A single entry‑point (process_message) that an LLM can call as a tool function, receiving a concise summary for its final answer.

You now have a plug‑and‑play CRM that can be called from any LLM‑driven agent (OpenAI function calling, LangChain tools, etc.) without any external dependencies.


Common Mistakes

Mistake Why it Happens Fix
Forgetting to create the CSV header _ensure_file not called before the first write Ensure log_lead always calls _ensure_file (already done).
Using mode="w" instead of "a" Overwrites previous leads each run Keep the file open in append mode ("a").
Returning the raw Lead object from log_lead The LLM expects a string summary for its response log_lead now returns a concise human‑readable string.
Mis‑spelling intent values (Hot vs hot) Downstream logic may be case‑sensitive The classifier always returns lower‑case strings; store them unchanged.
Running the script from a different working directory leads.csv gets created in an unexpected location Use absolute paths (Path(__file__).parent / "leads.csv") if you need strict control.

Troubleshooting

Symptom Likely Cause Remedy
FileNotFoundError: [Errno 2] No such file or directory: 'leads.csv' The script is executed in a read‑only directory. Run the script in a writable folder or change CSV_PATH to a path you own.
All rows have cold intent even for hiring messages The classifier’s keyword list is too narrow. Extend classify_intent with more hiring‑related terms (e.g., "recruiting", "talent acquisition").
Duplicate header rows appear after the first run _ensure_file is called after the file already exists. Verify that _ensure_file checks is_file() before writing the header (already correct).
CSV file shows garbled characters Wrong file encoding. The logger uses utf-8; ensure your terminal/editor reads UTF‑8.

Key Takeaways

  • Separate concerns: data model (Lead), business logic (classify_intent), persistence (log_lead).
  • Make tools idempotent – the CSV helper safely creates the file once and only appends thereafter.
  • Return simple, LLM‑friendly strings from tool functions; the model can embed them directly into its answer.
  • Rule‑based classifiers are fine for demos; replace classify_intent with a fine‑tuned LLM or a vector‑search model for production.

🧪 Try It Yourself

  1. Add a new intent: Extend classify_intent to recognize a "very hot" scenario (e.g., "budget approved"). Return "hot" but change the reason accordingly.
  2. Export to Excel: Modify csv_logger.py to also write an leads.xlsx file using openpyxl.
  3. Integrate with a real LLM: Use OpenAI’s function‑calling API to call process_message directly from a chat completion request.

Happy coding! 🚀

Why This Matters

Deploying an LLM‑powered lead‑scoring agent as a live web service turns a prototype into a real‑world productivity tool. - 24 × 7 availability – prospects can interact with you even when you’re asleep. - Immediate notifications – hot leads trigger push alerts and emails instantly. - Audit trail – every conversation, score, and action is persisted in CSV for later analysis or CRM import.

By the end of this chapter you’ll have a public Gradio chat UI that calls your agent, routes to the correct tool, logs everything, and can be shared with a single URL.


What You’ll Build

A deployable Gradio web app (app.py) that:

  1. Loads the LLM agent with all custom tools (notify, email, log, schedule, share‑link, etc.).
  2. Exposes a chat interface (/chat) that streams the LLM’s responses.
  3. Shows a trace panel that visualises which tool(s) were invoked for each user turn.
  4. Persists the conversation and lead data to leads.csv.
  5. Sends a push notification via Pushover (or any webhook you prefer) for hot leads.

Content

1️⃣ Refactor Tools into a Single Module

All tool implementations live in tools.py. This keeps app.py tidy and makes unit‑testing easier.

# Save as: tools.py
import csv
import smtplib
import ssl
import requests
from datetime import datetime
from pathlib import Path
from typing import Dict, Any

# ------------------- Notification (Pushover) -------------------
PUSHOVER_USER = "YOUR_USER_KEY"
PUSHOVER_TOKEN = "YOUR_APP_TOKEN"

def notify(message: str) -> str:
 """Send a push notification via Pushover."""
 payload = {
 "token": PUSHOVER_TOKEN,
> **What is a token?** A token is how AI models count text. Think of it as a "word piece" — roughly 3-4 characters. "Hello" = 1 token. "Hello world" = 2 tokens. Models have a limit on how many tokens they can process at once (called the "context window").


 "user": PUSHOVER_USER,
 "message": message,
 }
 resp = requests.post("https://api.pushover.net/1/messages.json", data=payload)
 return "✅ Notification sent" if resp.status_code == 200 else f"❌ Failed ({resp.status_code})"

# ------------------- Email -------------------
SMTP_SERVER = "smtp.gmail.com"
SMTP_PORT = 465
SMTP_USER = "your.email@gmail.com"
SMTP_PASS = "your_app_password"

def send_email(to: str, subject: str, body: str) -> str:
 """Send a simple plaintext email."""
 msg = f"Subject: {subject}\n\n{body}"
 context = ssl.create_default_context()
 try:
 with smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, context=context) as server:
 server.login(SMTP_USER, SMTP_PASS)
 server.sendmail(SMTP_USER, to, msg)
 return "✅ Email sent"
 except Exception as e:
 return f"❌ Email error: {e}"

# ------------------- Lead Logging -------------------
LEADS_FILE = Path("leads.csv")
LEADS_FILE.touch(exist_ok=True) # ensure file exists

def log_lead(data: Dict[str, Any]) -> str:
 """Append a lead record to leads.csv."""
 fieldnames = ["timestamp", "name", "email", "company", "score", "notes"]
 write_header = not LEADS_FILE.stat().st_size
 with LEADS_FILE.open("a", newline="") as f:
 writer = csv.DictWriter(f, fieldnames=fieldnames)
 if write_header:
 writer.writeheader()
 row = {
 "timestamp": datetime.utcnow().isoformat(),
 "name": data.get("name", ""),
 "email": data.get("email", ""),
 "company": data.get("company", ""),
 "score": data.get("score", ""),
 "notes": data.get("notes", ""),
 }
 writer.writerow(row)
 return f"✅ Lead logged for {row['name']}"

# ------------------- Calendar Slot Offering -------------------
# For demo purposes we use a static list.
AVAILABLE_SLOTS = [
 "Wednesday 10:00‑10:30 AM",
 "Wednesday 02:00‑02:30 PM",
 "Thursday 11:00‑11:30 AM",
]

def offer_slots() -> str:
 """Return a formatted string of available meeting slots."""
 return "📅 Available slots:\n" + "\n".join(f"{i+1}. {s}" for i, s in enumerate(AVAILABLE_SLOTS))

def book_slot(choice: int) -> str:
 """Mark a slot as booked (naïve implementation)."""
 try:
 slot = AVAILABLE_SLOTS.pop(choice - 1)
 return f"✅ Slot booked: {slot}"
 except IndexError:
 return "❌ Invalid slot number"

# ------------------- Share Links -------------------
PORTFOLIO_LINKS = {
 "GitHub": "https://github.com/yourusername",
 "LinkedIn": "https://linkedin.com/in/yourusername",
 "Portfolio": "https://yourdomain.com",
}

def share_links() -> str:
 """Return a markdown list of personal links."""
 return "\n".join(f"- [{name}]({url})" for name, url in PORTFOLIO_LINKS.items())

# ------------------- Unknown Question Recorder -------------------
UNKNOWN_LOG = Path("unknown_questions.log")

def record_unknown(question: str) -> str:
 """Append an unanswered question to a log file."""
 with UNKNOWN_LOG.open("a") as f:
 f.write(f"{datetime.utcnow().isoformat()} | {question}\n")
 return "✅ Question recorded for later review"

# ------------------- Helper for Scoring -------------------
def score_lead(company: str) -> str:
 """Simple heuristic scoring based on company size keywords."""
 hot_keywords = ["Fortune", "Unicorn", "Series C", "Enterprise"]
 warm_keywords = ["Series A", "Series B", "Growth"]
 if any(k.lower() in company.lower() for k in hot_keywords):
 return "hot"
 if any(k.lower() in company.lower() for k in warm_keywords):
 return "warm"
 return "cold"

✅ Verify: Run python -c "import tools; print(tools.offer_slots())" – you should see a numbered list of slots.


2️⃣ The LLM Agent (agent.py)

We use OpenAI’s function‑calling capability. Each tool above is exposed as a function spec.

# Save as: agent.py
import json
import os
from typing import List, Dict, Any
import openai

# Load your OpenAI API key from environment
openai.api_key = os.getenv("OPENAI_API_KEY")

# Import tool implementations
import tools

# ------------------- Function Specs -------------------
def _function_specs() -> List[Dict[str, Any]]:
 """Return OpenAI function specifications for all tools."""
 return [
 {
 "name": "notify",
 "description": "Send a push notification to the owner for urgent hot leads.",
 "parameters": {
 "type": "object",
 "properties": {"message": {"type": "string", "description": "Notification text"}},
 "required": ["message"],
 },
 },
 {
 "name": "send_email",
 "description": "Email a lead with a custom subject and body.",
 "parameters": {
 "type": "object",
 "properties": {
 "to": {"type": "string", "description": "Recipient email address"},
 "subject": {"type": "string"},
 "body": {"type": "string"},
 },
 "required": ["to", "subject", "body"],
 },
 },
 {
 "name": "log_lead",
 "description": "Persist lead information to leads.csv.",
 "parameters": {
 "type": "object",
 "properties": {
 "name": {"type": "string"},
 "email": {"type": "string"},
 "company": {"type": "string"},
 "score": {"type": "string"},
 "notes": {"type": "string"},
 },
 "required": ["name", "email", "company", "score"],
 },
 },
 {
 "name": "offer_slots",
 "description": "Show the visitor available meeting slots.",
 "parameters": {"type": "object", "properties": {}},
 },
 {
 "name": "book_slot",
 "description": "Book a chosen meeting slot.",
 "parameters": {
 "type": "object",
 "properties": {"choice": {"type": "integer", "description": "1‑based slot index"}},
 "required": ["choice"],
 },
 },
 {
 "name": "share_links",
 "description": "Provide personal portfolio / GitHub / LinkedIn links.",
 "parameters": {"type": "object", "properties": {}},
 },
 {
 "name": "record_unknown",
 "description": "Log a question the agent cannot answer.",
 "parameters": {
 "type": "object",
 "properties": {"question": {"type": "string"}},
 "required": ["question"],
 },
 },
 {
 "name": "score_lead",
 "description": "Return a lead score (hot/warm/cold) based on company description.",
 "parameters": {
 "type": "object",
 "properties": {"company": {"type": "string"}},
 "required": ["company"],
 },
 },
 ]

# ------------------- Dispatch Table -------------------
_DISPATCH = {
 "notify": tools.notify,
 "send_email": tools.send_email,
 "log_lead": tools.log_lead,
 "offer_slots": tools.offer_slots,
 "book_slot": tools.book_slot,
 "share_links": tools.share_links,
 "record_unknown": tools.record_unknown,
 "score_lead": tools.score_lead,
}

# ------------------- Core Agent Logic -------------------
def run_agent(user_message: str, chat_history: List[Dict[str, str]]) -> Dict[str, Any]:
 """
 Sends the user message + history to OpenAI, handles any function calls,
 and returns a dict with:
 - "response": final text to show the user
 - "trace": list of (function_name, arguments, result) tuples
 - "updated_history": chat history ready for the next turn
 """
 messages = chat_history + [{"role": "user", "content": user_message}]
 response = openai.ChatCompletion.create(
 model="gpt-4o-mini",
 messages=messages,
 functions=_function_specs(),
 function_call="auto",
 )
 choice = response["choices"][0]["message"]
 trace = []

 # If the model decided to call a function, execute it
 if choice.get("function_call"):
 fn_name = choice["function_call"]["name"]
 fn_args = json.loads(choice["function_call"]["arguments"])
 result = _DISPATCH[fn_name](**fn_args)
 trace.append((fn_name, fn_args, result))

 # Append the function call + result to the conversation and ask the model to continue
 messages.append({
 "role": "assistant",
 "content": None,
 "function_call": {
 "name": fn_name,
 "arguments": json.dumps(fn_args),
 },
 })
 messages.append({"role": "function", "name": fn_name, "content": result})

 # Second pass – let the model produce a natural language reply
 second_resp = openai.ChatCompletion.create(
 model="gpt-4o-mini",
 messages=messages,
 )
 final_text = second_resp["choices"][0]["message"]["content"]
 else:
 final_text = choice["content"]
 # No function call – just a plain answer
 # Update history for next round
 updated_history = messages + [{"role": "assistant", "content": final_text}]
 return {
 "response": final_text,
 "trace": trace,
 "updated_history": updated_history,
 }

✅ Verify:

python - <<'PY'
import agent
res = agent.run_agent("My name is Priya from Infosys, please log me as a lead.", [])
print(res["response"])
print("Trace:", res["trace"])
PY

You should see a friendly acknowledgment and a trace entry like ('log_lead', {.}, '✅ Lead logged for Priya').


3️⃣ Gradio Front‑End (app.py)

The UI shows three panels:

  • Chat window – user ↔ LLM.
  • Trace log – which tool(s) fired for the last turn.
  • Leads CSV preview – quick glance at stored leads.
# Save as: app.py
import gradio as gr
import pandas as pd
import agent

# Global chat history (in‑memory; for production use a DB)
chat_history = []

def chat_fn(user_msg: str):
 global chat_history
 result = agent.run_agent(user_msg, chat_history)
 chat_history = result["updated_history"]
 # Build a readable trace string
 trace_str = "\n".join(
 f"🔧 **{fn}** called with {args} → {out}"
 for fn, args, out in result["trace"]
 ) or "✅ No tool invoked"
 # Load leads for preview
 try:
 leads_df = pd.read_csv("leads.csv")
 leads_html = leads_df.to_html(index=False)
 except Exception:
 leads_html = "<i>No leads logged yet.</i>"
 return result["response"], trace_str, leads_html

with gr.Blocks() as demo:
 gr.Markdown("# 🤖 Lead‑Scoring Agent – Live Demo")
 with gr.Row():
 with gr.Column(scale=2):
 chatbot = gr.Chatbot(label="Conversation")
 txt = gr.Textbox(label="Your message", placeholder="Say hello…")
 txt.submit(chat_fn, inputs=txt, outputs=[chatbot, "trace", "leads"])
 with gr.Column(scale=1):
 trace = gr.Markdown(label="Tool Trace")
 leads = gr.HTML(label="Leads CSV Preview")
 # Bind the function to the chatbot UI
 txt.submit(lambda msg, hist: (hist + [[msg, None]],), [txt, chatbot], chatbot)
 txt.submit(chat_fn, inputs=txt, outputs=[chatbot, trace, leads])

demo.launch(share=True)

✅ Verify:

python app.py

A browser window opens with a chat box. Type “Hi, I’m Priya from Infosys, can we schedule a call?” – you should see:

  • A friendly LLM reply offering slots.
  • The trace panel showing offer_slots was called.
  • The leads preview table now contains Priya’s record.

Expected console output (partial):

Running on local URL: http://127.0.0.1:7860
Running on public URL: https://xxxx.gradio.live

📁 Project Files

File Purpose
requirements.txt Pin dependencies (openai, gradio, pandas, requests)
tools.py All custom tool implementations (notify, email, logging, etc.)
agent.py LLM wrapper that defines function specs and dispatches calls
app.py Gradio UI that ties everything together
leads.csv Auto‑generated lead database (created on first run)
unknown_questions.log Persistent log of unanswered queries
# Save as: requirements.txt
openai>=1.0.0
gradio>=4.0
pandas>=2.0
requests>=2.31

✅ Verify: Run pip install -r requirements.txt in a fresh virtual environment – no errors should appear.


🚀 What You Just Built

Feature How It Works
Live chat Gradio forwards each user turn to agent.run_agent.
Tool routing OpenAI decides which function to call; the dispatcher executes the real Python code.
Hot‑lead notification notify() pushes a Pushover alert; you can replace it with Slack, Twilio, etc.
Email outreach send_email() uses Gmail SMTP – replace credentials for your own domain.
Lead persistence log_lead() writes a CSV row; the UI instantly reloads it.
Meeting scheduling offer_slots() + book_slot() simulate a calendar.
Traceability Every function call appears in the “Tool Trace” markdown panel.
Public share link demo.launch(share=True) gives a temporary HTTPS URL (valid for 72 h).

Common Mistakes

Mistake Why It Happens Fix
Missing OpenAI key openai.api_key is None. Export OPENAI_API_KEY in your shell (export OPENAI_API_KEY=sk-…).
Pushover credentials wrong Notification silently fails. Verify PUSHOVER_USER and PUSHOVER_TOKEN on the Pushover dashboard.
CSV locked on Windows log_lead tries to open leads.csv while Excel holds it. Close the Excel file or use a DB for production.
Function name typo Dispatch table key mismatch. Ensure the name in function_specs matches the key in _DISPATCH.
Slot list empty All slots booked, book_slot raises IndexError. Add a fallback message or replenish AVAILABLE_SLOTS.

⚠️ Never commit your real SMTP password or Pushover token to a public repo. Use environment variables or a secrets manager.


Troubleshooting

  1. No response from the LLM Check: openai.error.RateLimitError in console. Solution: Upgrade your plan or add exponential back‑off retry logic.

  2. CSV not updating Check: File permissions on leads.csv. Solution: chmod 664 leads.csv (Unix) or run the script with admin rights.

  3. Gradio UI freezes Check: Long‑running tool (e.g., network call) blocking the main thread. Solution: Wrap heavy calls in asyncio or run them in a separate thread pool.

  4. Notification never arrives Check: Network connectivity to api.pushover.net. Solution: Test with curl -X POST https://api.pushover.net/1/messages.json manually.

💡 Tip: Keep a debug.log file and write trace entries there for post‑mortem analysis.


Key Takeaways

  • Function calling lets an LLM act as a router for your business logic.
  • Keep tool code pure (no side‑effects beyond what the spec describes) – this makes debugging trivial.
  • A single Gradio app can expose a full‑stack AI agent with zero front‑end code.
  • Traceability is essential: always surface which tool was invoked; it builds trust and simplifies maintenance.

🧪 Try It Yourself

  1. Add a new tool – e.g., fetch_weather(city) that calls a free weather API.
  2. Extend tools.py with the function.
  3. Add a spec entry in _function_specs().
  4. Update _DISPATCH.
  5. Prompt the LLM: “What’s the weather in London?” – you should see the new tool fire in the trace panel.

  6. Persist to a real database – replace CSV logic with SQLite (sqlite3 module) and observe the UI changes.

  7. Deploy to a cloud platform (Render, Fly.io, or Railway).

  8. Create a Dockerfile that copies the repo, installs requirements.txt, and runs python app.py.
  9. Set environment variables (OPENAI_API_KEY, PUSHOVER_USER, PUSHOVER_TOKEN).

  10. Customize the UI – add your photo, a downloadable PDF resume, or a “Copy transcript” button using Gradio’s Button component.

Enjoy turning conversations into actionable leads, and remember: the agent is only as good as the tools you give it. Keep iterating, logging, and refining the prompts, and you’ll have a production‑ready AI sales assistant in minutes.

Why This Matters

A polished front‑end turns a functional prototype into a real product. By the end of this chapter you’ll have a Gradio UI that:

  • Accepts user messages and shows a full conversation history.
  • Lets users download a generated résumé as a PDF.
  • Displays image assets (e.g., a company logo or sample portfolio) directly in the interface.
  • Shares the app publicly with a single line of code (share=True).

All of this is built on top of the same agent you created earlier, so you can see how a powerful backend can be wrapped in a clean, user‑friendly front‑end.


What You’ll Build

A single‑file Gradio application (app.py) that:

  1. Initialises the agent with all its tools.
  2. Handles chat (msg, history) and returns a list of dictionaries for Gradio.
  3. Generates a résumé PDF on demand and provides a download button.
  4. Shows static images (logo, sample screenshots) in the UI.
  5. Runs locally and can be shared publicly with share=True.

Content

1️⃣ Install the required libraries

pip install gradio langchain openai fpdf

💡 Tip: Keep a requirements.txt (see below) so you can recreate the environment later.

2️⃣ Create the Gradio UI (app.py)

# Save as: app.py
import os
import json
import uuid
from typing import List, Tuple, Dict

import gradio as gr
from fpdf import FPDF
from langchain.agents import initialize_agent, AgentType
from langchain.tools import Tool
from langchain.llms import OpenAI

# ----------------------------------------------------------------------
# 1️⃣ Agent Setup – reuse the tools you built in earlier chapters
# ----------------------------------------------------------------------
def dummy_tool(input_text: str) -> str:
 """A placeholder tool that just echoes the input."""
 return f"Tool received: {input_text}"

tools = [
 Tool(
 name="EchoTool",
 func=dummy_tool,
 description="Echoes back whatever the user says."
 )
]

# Initialise the LLM and the agent
llm = OpenAI(temperature=0) # make sure you have OPENAI_API_KEY set
agent = initialize_agent(
 tools,
 llm,
 agent=AgentType.ZERO_SHOT_REACT_DESCRIPTION,
 verbose=True
)

# ----------------------------------------------------------------------
# 2️⃣ Helper: generate a simple résumé PDF
# ----------------------------------------------------------------------
def generate_resume(name: str, role: str, summary: str) -> str:
 """Creates a PDF résumé and returns the file path."""
 pdf = FPDF()
 pdf.add_page()
 pdf.set_font("Helvetica", "B", 24)
 pdf.cell(0, 10, txt=name, ln=1, align="C")
 pdf.set_font("Helvetica", "", 16)
 pdf.cell(0, 10, txt=role, ln=1, align="C")
 pdf.ln(10)
 pdf.set_font("Helvetica", "", 12)
 pdf.multi_cell(0, 10, txt=summary)
 filename = f"resume_{uuid.uuid4().hex[:8]}.pdf"
 pdf.output(filename)
 return filename

# ----------------------------------------------------------------------
# 3️⃣ Gradio chat handler
# ----------------------------------------------------------------------
def chat_handler(message: str, history: List[Tuple[str, str]]) -> Tuple[List[Dict], str]:
 """
 Takes the latest user message and the chat history,
 forwards the conversation to the agent, and returns:
 1. Updated history formatted for Gradio.
 2. Path to a generated résumé (or empty string if not generated).
 """
 # Convert Gradio history (list of tuples) into a single string for the agent
 conversation = "\n".join([f"Human: {h[0]}\nAI: {h[1]}" for h in history] + [f"Human: {message}"])

 # Let the agent process the latest message
 response = agent.run(conversation)

 # Simple heuristic: if the agent mentions "resume", generate one
 resume_path = ""
 if "resume" in response.lower():
 # In a real app you'd extract name/role/summary from the response.
 # Here we just use placeholders.
 resume_path = generate_resume(
 name="John Doe",
 role="Software Engineer",
 summary=response
 )
 response += f"\n\n[Download Résumé]({resume_path})"

 # Append the new turn to the history
 new_history = history + [(message, response)]

 # Gradio expects a list of dicts for the chatbot component
 formatted = [{"role": "user", "content": msg} if i % 2 == 0 else {"role": "assistant", "content": resp}
 for i, (msg, resp) in enumerate(new_history)]

 return formatted, resume_path

# ----------------------------------------------------------------------
# 4️⃣ Build the Gradio Interface
# ----------------------------------------------------------------------
with gr.Blocks() as demo:
 gr.Markdown("# 🤖 AI Agent Playground")
 with gr.Row():
 # Left column: chat
 with gr.Column(scale=2):
 chatbot = gr.Chatbot(label="Conversation", height=500)
 msg = gr.Textbox(label="Your Message", placeholder="Ask me anything.")
 send_btn = gr.Button("Send")
 # Right column: assets
 with gr.Column(scale=1):
 gr.Image("assets/logo.png", label="Company Logo", width=200)
 gr.Markdown("### Sample Portfolio")
 gr.Image("assets/sample.png", label="Sample Image", width=200)
 resume_download = gr.File(label="Download Résumé", visible=False)

 # ------------------------------------------------------------------
 # 5️⃣ Wire up interactions
 # ------------------------------------------------------------------
 def on_send(user_msg, chat_history):
 updated_history, resume_path = chat_handler(user_msg, chat_history)
 # If a résumé was generated, make the download component visible
 if resume_path:
 resume_download.update(value=resume_path, visible=True)
 else:
 resume_download.update(visible=False)
 return updated_history, ""

 send_btn.click(
 fn=on_send,
 inputs=[msg, chatbot],
 outputs=[chatbot, msg] # clear the textbox after sending
 )

 # Allow pressing Enter to submit
 msg.submit(
 fn=on_send,
 inputs=[msg, chatbot],
 outputs=[chatbot, msg]
 )

# ----------------------------------------------------------------------
# 6️⃣ Launch the app
# ----------------------------------------------------------------------
if __name__ == "__main__":
 # `share=True` creates a public URL that anyone on the same network (or the internet) can use.
 demo.launch(server_name="0.0.0.0", server_port=7860, share=True)

Expected output when you run python app.py

Running on local URL: http://127.0.0.1:7860/
Running on public URL: https://<random-subdomain>.gradio.live/

You’ll see a browser window open with the chat UI, a logo, and a sample image. Typing a message like “Create a résumé for a senior Python developer” will:

  1. Send the prompt to the agent.
  2. The agent replies with a short bio.
  3. The helper detects the word “résumé”, generates resume_*.pdf, and a Download Résumé button appears.

Verify: Open the public URL on another device (same Wi‑Fi) and confirm you can chat and download the PDF.


📁 Project Files

File Purpose
app.py Main Gradio UI and agent glue code (shown above).
requirements.txt Pin exact versions for reproducibility.
assets/logo.png Company logo displayed in the UI.
assets/sample.png Example image for the portfolio section.

requirements.txt

gradio>=4.0
langchain>=0.0.300
openai>=1.0
fpdf2>=2.7

🚀 What You Just Built

Feature How It Works
Chatbot gr.Chatbot receives a list of (user, assistant) tuples. The chat_handler stitches them into a single string for the LangChain agent.
Résumé Generation When the agent’s response contains the word “resume”, generate_resume creates a PDF with fpdf. The file path is fed to gr.File for download.
Static Assets gr.Image loads PNG files from the assets/ folder, making the UI look professional.
Public Sharing share=True spins up a temporary tunnel (*.gradio.live) so anyone on the same network (or the internet) can interact with your agent.

Common Mistakes

Mistake Why It Happens Fix
FileNotFoundError for images The relative path is wrong when you run the script from a different directory. Keep assets/ next to app.py or use os.path.join(os.path.dirname(__file__), "assets", "logo.png").
Resume never appears The heuristic ("resume" in response) is case‑sensitive or the agent never mentions the word. Use if "resume" in response.lower(): (already done) or improve the prompt to ask the agent explicitly.
Public URL not reachable Firewall blocks port 7860 or you’re behind a corporate proxy. Open the port in your firewall or run on a different port (server_port=7870).
PDF is corrupted fpdf was not installed correctly or the file was opened before it finished writing. Re‑install fpdf2 and ensure generate_resume returns after pdf.output.

Troubleshooting

  1. Gradio fails to launch (OSError: [Errno 98] Address already in use) Solution: Change the server_port to an unused number, e.g., 7861.

  2. OpenAI API errors (InvalidRequestError: No API key provided) Solution: Export your key before running: export OPENAI_API_KEY="sk-." (Linux/macOS) or set OPENAI_API_KEY=sk-. (Windows).

  3. PDF download button stays hidden Solution: Verify that resume_path is a non‑empty string. Add a print(resume_path) inside chat_handler to debug.

  4. Images appear broken Solution: Confirm the images are valid PNG/JPEG files and that the assets/ folder is correctly placed.


Key Takeaways

  • Separation of concerns – Keep the agent logic, PDF generation, and UI wiring in distinct functions.
  • Graceful UI feedback – Dynamically show/hide the download component based on backend results.
  • One‑line sharingshare=True turns a local script into a publicly accessible demo instantly.
  • Reproducibility – A requirements.txt and clear folder layout let anyone clone and run your project without extra research.

🧪 Try It Yourself

  1. Add a new tool that fetches the latest GitHub repo stats (use requests).
  2. Update the prompt in chat_handler to ask the agent to use the new tool when the user mentions “GitHub”.
  3. Display the fetched stats as a formatted table in the chat (use Markdown).

Hint: Create a github_stats_tool function, wrap it in a Tool, add it to the tools list, and restart the app.

Happy hacking! 🚀


What's Next

Continue to the next chapter to keep building.

Chapter 13

Shipping Your Agent to Production

Why This Matters


Before You Start This Chapter

  • Your venv is active (you see `(.

    What is a virtual environment (venv)? It is a separate, isolated Python world for your project. Without it, packages from different projects can conflict. With a venv, each project gets its own clean set of packages. Think of it like a separate browser profile for each website.

venv)in your prompt) - Previous chapter's project is working - Your.env` file has your API key

What is an API? An API (Application Programming Interface) is how one program talks to another. Think of it like a waiter in a restaurant — you (your code) tell the waiter (API) what you want, the waiter goes to the kitchen (the server), and brings back your food (the response). You never talk to the kitchen directly.

  • You are in the correct folder (run pwd to check)

Get Your OpenAI API Key

  1. Go to https://platform.openai.com/api-keys
  2. Log in (free account)
  3. Click "Create new secret key"
  4. Name it (e.g., "adhikk")
  5. Click "Create"
  6. Copy the key (starts with sk-) — you will not see it again
  7. Paste into your .env file

New accounts get $5 free credit — enough for this entire book.

Create & Activate Virtual Environment

uv venv

Activate (Windows PowerShell):

.venv\Scripts\Activate

Activate (Mac/Linux):

source .venv/bin/activate

How you'll know it worked: Your prompt shows (.venv) at the start.

Deploying an application is the bridge between code you wrote on your laptop and real users who can interact with it from anywhere. A digital personal‑assistant that can:

  • Book calendar slots
  • Send push notifications, email invites, and Gmail messages
  • Update a CRM and classify leads

is only useful when it lives on a server that never sleeps. In this chapter you will learn how to turn a Jupyter notebook prototype into a production‑ready project and publish it on Hugging Face Spaces (free) or Streamlit Cloud (free).


What You’ll Build

A minimal, fully functional personal‑assistant web app that:

  1. Accepts a user’s request via a simple UI (Gradio).
  2. Sends a mock notification (printed to console).
  3. Returns a confirmation message.

You will package the code, declare its dependencies, and push the whole folder to a Hugging Face Space so that anyone can access it via a permanent URL.


Content

Step Description
1️⃣ Organise the project folder like a real‑world Python package.
2️⃣ Write app.py – the entry point that launches the Gradio interface.
3️⃣ Create requirements.txt – a list of all Python packages needed.
4️⃣ Add a helpful README.md.
5️⃣ Push the repository to a new Hugging Face Space.
6️⃣ Verify that the app runs publicly.

Each step includes runnable code, expected output, and a ✅ Verify checkpoint.


📁 Project Files

Below is the exact file layout you will create. Create a new folder called personal_assistant/ and place the files exactly as shown.

personal_assistant/
├─ app.py
├─ requirements.txt
└─ README.md

1️⃣ app.py – Main Application

# Save as: app.py
import gradio as gr
import datetime
import os

def book_appointment(name: str, date: str, time: str) -> str:
 """
 Mock function that pretends to book an appointment.
 In a real system this would interact with Google Calendar,
 send emails, update a CRM, etc.
 """
 # Simulate a notification (in production you would push to a phone)
 notification = f"🔔 New appointment for {name} on {date} at {time}"
 print(notification) # <-- visible in server logs

 # Return a friendly confirmation for the UI
 return f"✅ Appointment booked for **{name}** on **{date}** at **{time}**."

# ----------------------------------------------------------------------
# Gradio UI definition
# ----------------------------------------------------------------------
with gr.Blocks() as demo:
 gr.Markdown("# 📅 Personal Assistant – Book an Appointment")
 with gr.Row():
 name_input = gr.Textbox(label="Your Name", placeholder="Jane Doe")
 date_input = gr.Textbox(label="Date (YYYY‑MM‑DD)", placeholder="2026‑10‑01")
 time_input = gr.Textbox(label="Time (HH:MM, 24‑h)", placeholder="14:30")
 submit_btn = gr.Button("Book")
 output = gr.Markdown()

 submit_btn.click(
 fn=book_appointment,
 inputs=[name_input, date_input, time_input],
 outputs=output,
 )

if __name__ == "__main__":
 # When run locally, launch the Gradio interface on port 7860
 demo.launch(server_name="0.0.0.0", server_port=7860, share=False)

Expected output when you run locally

$ python app.py
Running on local URL: http://127.0.0.1:7860/

A browser window opens showing the UI. After filling the fields and clicking Book, the page displays a confirmation message and the server console prints a mock notification.

Verify: Run python app.py and confirm the UI appears and the console prints the notification.


2️⃣ requirements.txt – Dependencies

# Save as: requirements.txt
gradio==4.19.2

💡 Tip: Keep the file minimal. Hugging Face will automatically create a virtual environment from this list.

Verify: Run pip install -r requirements.txt in a fresh virtual environment and ensure no errors.


3️⃣ README.md – Project Overview

# 📚 Personal Assistant – Hugging Face Space Demo

A tiny web app that lets a user book an appointment. 
It demonstrates the exact steps required to:

* Structure a Python project for deployment 
* Write a Gradio UI (`app.py`) 
* Declare dependencies (`requirements.txt`) 
* Deploy to **Hugging Face Spaces** (free)

## How to Run Locally

```bash
pip install -r requirements.txt
python app.py

Open http://127.0.0.1:7860 in your browser.

Deploying to Hugging Face Spaces

  1. Create a free Hugging Face account.
  2. Click New Space → Create a Space.
  3. Choose Gradio as the SDK, give it a name, and set Visibility to Public.
  4. Upload the three files (app.py, requirements.txt, README.md).
  5. Click Commit & Push – the Space will build automatically.

You will receive a permanent URL like https://your-username-<space-name>.hf.space.


✅ **Verify:** 
*Open the README in a markdown viewer; it should render correctly.*

---

## 🚀 What You Just Built 

| Component | Role |
|-----------|------|
| `app.py` | Entrypoint that launches a Gradio UI and contains the business logic (`book_appointment`). |
| `requirements.txt` | Informs the deployment platform which Python packages to install. |
| `README.md` | Provides documentation for users and future maintainers. |

When the Space finishes building, anyone can visit the URL, fill the form, and see a live confirmation. The server logs (accessible from the Space’s *Logs* tab) will show the mock notification, proving that background processing works.

---

## Common Mistakes 

| Mistake | Why It Happens | Fix |
|---------|----------------|-----|
| **Uploading the whole virtual environment (`venv/` or `env/`)** | The folder contains thousands of binary files that exceed the Space size limit. | Add a `.gitignore` that excludes `venv/`, `__pycache__/`, and any `.ipynb_checkpoints/`. |
| **Using a Jupyter notebook (`.ipynb`) as the entry point** | Hugging Face builds a Python package; notebooks are not executable as a web service. | Convert notebook cells into a plain `app.py` script (as shown). |
| **Missing `requirements.txt` or typo in a package name** | The build fails because the environment cannot be created. | Double‑check the file name and run `pip install -r requirements.txt` locally first. |
| **Choosing the wrong SDK (Streamlit vs Gradio) in the Space settings** | The platform tries to run the wrong command (`streamlit run …`). | When creating the Space, select **Gradio** as the SDK (or rewrite the UI in Streamlit). |
| **Running a paid‑only feature on a free plan** | Gradio Spaces with GPU or certain custom components need a paid tier. | Stick to CPU‑only, pure‑Python components for the free tier. |

---

## Troubleshooting 

| Symptom | Likely Cause | Remedy |
|---------|--------------|--------|
| **Build fails with `ImportError: No module named 'gradio'`** | `requirements.txt` not detected or syntax error. | Ensure the file is named exactly `requirements.txt` (all lower‑case) and contains a valid package line. |
| **Space shows a blank page** | `app.py` never called `demo.launch()` or the server crashed. | Check the **Logs** tab for a traceback; make sure `if __name__ == "__main__":` block is present. |
| **Console logs do not show the notification** | `print` statements are suppressed in the Gradio container. | Use `logging` instead of `print`, e.g., `import logging; logging.info(notification)`. |
| **Deployment takes > 10 minutes and then times out** | Large unnecessary files (e.g., a `data/` folder with GBs). | Add those directories to `.gitignore` before pushing. |
| **Space URL returns “404 – Not Found” after a successful build** | The Space was set to **Private**. | Change visibility to *Public* in the Space settings. |

---

## Key Takeaways 

* **Project structure matters** – a clean layout (`app.py`, `requirements.txt`, `README.md`) is the foundation of any deployable Python app. 
* **Never push virtual environments** – they bloat the repo and are unnecessary; the platform builds its own environment from `requirements.txt`. 
* **Gradio is a perfect match for quick demos** on Hugging Face Spaces; just remember to select the correct SDK when creating the Space. 
* **Logs are your friend** – use the Space’s *Logs* tab to debug runtime issues. 
* **Free tiers are generous** but have limits (CPU only, no GPU, limited storage). Design your demo accordingly.

---

## 🧪 Try It Yourself 

1. **Clone the repo** (or copy the three files) into a fresh folder on your machine. 
2. **Create a new virtual environment** and install dependencies:

 ```bash
 python -m venv venv
 source venv/bin/activate # Windows: venv\Scripts\activate
 pip install -r requirements.txt
 ```

3. **Run locally** and book an appointment. Verify the console prints the notification. 

4. **Deploy to Hugging Face Spaces**: 

 * Sign in at <https://huggingface.co/>. 
 * Click **New Space → Create a Space** → choose **Gradio**. 
 * Drag‑and‑drop the three files, commit, and wait for the build. 

5. **Share the URL** with a friend and ask them to book an appointment. 

💡 **Challenge**: Extend `book_appointment` to actually send an email using the `smtplib` library (you’ll need to add `email-validator` to `requirements.txt`). 

---

## Why This Matters 

When you move from a notebook‑style prototype to a production‑ready Python service, **modularity, reproducibility, and deployability** become non‑negotiable. By splitting your logic into separate modules (`app.py`, `tools.py`, `config.py`, …) you:

* Keep the codebase **readable** for teammates and future you. 
* Enable **unit testing** of each tool in isolation. 
* Make the service **portable** – you can run it locally, in a container, or on a cloud platform with a single command. 

In this chapter you’ll see exactly how to refactor the monolithic notebook into a clean, import‑friendly project that can be exposed publicly (e.g., via **ngrok**) while still protecting sensitive data with environment variables.

---

## What You’ll Build 

A **self‑contained Python package** that:

1. Loads configuration (API keys, bucket names) from a `.env` file. 
2. Houses all custom LLM‑aware tools in `tools.py`. 
3. Provides a thin `app.py` entry‑point that starts a FastAPI server exposing a single `/chat` endpoint. 
4. Uses **ngrok** to tunnel the local server to a public URL (one‑click for demos). 
5. Includes a `Dockerfile` and `requirements.txt` so the whole stack can be containerised and deployed anywhere.

When you finish, you’ll be able to run:

```bash
python -m uvicorn app:app --reload
ngrok http 8000

…and share the generated https://xxxx.ngrok.io/chat link with anyone.


Content

Step Description
1️⃣ Create a clean project layout my_agent/ folder with sub‑modules.
2️⃣ Add a .env file Store secrets safely; load with python‑dotenv.
3️⃣ Refactor tools into tools.py Pure functions that the LLM can call.
4️⃣ Build config.py Centralised access to env vars and bucket helpers.
5️⃣ Wire everything in app.py FastAPI routes, request validation, LLM orchestration.
6️⃣ Test locally Verify the endpoint returns the expected JSON.
7️⃣ Expose publicly with ngrok One‑liner to get a public HTTPS URL.
8️⃣ Containerise Dockerfile + docker build/run commands.

Each step includes runnable code, expected output, and a ✅ Verify checkpoint.


📁 Project Files

Below is the complete file tree. Create each file exactly as shown; the # Save as: comment tells you the filename.

my_agent/
├─.env
├─ requirements.txt
├─ Dockerfile
├─ config.py
├─ tools.py
└─ app.py

1️⃣ .env – Store secrets (never commit this file)

# Save as:.env
# -------------------------------------------------
# Replace the placeholder values with your own.
OPENAI_API_KEY=sk-XXXXXXXXXXXXXXXXXXXXXXXXXXXX
GOOGLE_CALENDAR_ID=your-calendar-id@group.calendar.google.com
BUCKET_NAME=my-public-leads-bucket
# -------------------------------------------------

💡 Tip: Add .env to .gitignore to avoid leaking credentials.

2️⃣ requirements.txt

# Save as: requirements.txt
fastapi==0.110.0
uvicorn[standard]==0.27.0
python-dotenv==1.0.0
openai==1.12.0
pydantic==2.6.1
google-api-python-client==2.115.0
google-auth-httplib2==0.2.0
google-auth-oauthlib==1.2.0
jinja2==3.1.3
httpx==0.27.0

3️⃣ config.py – Centralised configuration & bucket helper

# Save as: config.py
import os
from pathlib import Path
> **What is PATH?** PATH is a list of folders your computer checks when you type a command. If you type `python`, your computer looks in each PATH folder for a file called `python.exe`. If it finds it, it runs it. If not, you get "command not found." Installing Python with "Add to PATH" checked adds Python's folder to this list.


from dotenv import load_dotenv

# Load.env from the project root
BASE_DIR = Path(__file__).resolve().parent
load_dotenv(dotenv_path=BASE_DIR / ".env")

# ----------------------------------------------------------------------
# Public configuration (read‑only)
# ----------------------------------------------------------------------
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
GOOGLE_CALENDAR_ID = os.getenv("GOOGLE_CALENDAR_ID")
BUCKET_NAME = os.getenv("BUCKET_NAME")

if not all([OPENAI_API_KEY, GOOGLE_CALENDAR_ID, BUCKET_NAME]):
 raise EnvironmentError(
 "One or more required environment variables are missing. "
 "Check your.env file."
 )

# ----------------------------------------------------------------------
# Helper: simple CSV bucket reader (mocked for demo)
# ----------------------------------------------------------------------
import csv
from typing import List, Dict

def read_leads_csv() -> List[Dict[str, str]]:
 """
 Reads `leads.csv` from the bucket (simulated as a local file for now).
 Returns a list of dictionaries, each representing a lead.
 """
 csv_path = BASE_DIR / "leads.csv"
 if not csv_path.exists():
 # In a real deployment you would fetch from cloud storage.
 return []
 with open(csv_path, newline="", encoding="utf-8") as f:
 reader = csv.DictReader(f)
 return list(reader)

Expected output – No output; the module simply loads env vars. If any variable is missing, the script aborts with a clear error.

✅ Verify: Run python -c "import config; print('Config loaded')". You should see Config loaded.

4️⃣ tools.py – All LLM‑callable utilities

# Save as: tools.py
import json
import smtplib
from email.message import EmailMessage
from datetime import datetime, timedelta
from typing import List, Dict, Optional

import httpx
from jinja2 import Template

from config import OPENAI_API_KEY, GOOGLE_CALENDAR_ID, read_leads_csv

# ----------------------------------------------------------------------
# 1️⃣ Push notification (simulated via console print)
# ----------------------------------------------------------------------
def push_notification(reason: str, details: Optional[str] = None) -> str:
 """
 Simulate a push notification. In production you could integrate
 with Pushover, Firebase, or any webhook.
 """
 msg = f"[NOTIFY] {reason}"
 if details:
 msg += f" – {details}"
 print(msg) # <-- visible in server logs
 return msg

# ----------------------------------------------------------------------
# 2️⃣ Send email (SMTP – replace with your provider)
# ----------------------------------------------------------------------
def send_email(
 to_address: str,
 subject: str,
 html_body: str,
 attachments: Optional[List[bytes]] = None,
) -> str:
 """
 Sends an HTML email via SMTP. For demo we use localhost SMTP.
 """
 msg = EmailMessage()
 msg["Subject"] = subject
 msg["From"] = "no-reply@example.com"
 msg["To"] = to_address
 msg.add_alternative(html_body, subtype="html")

 if attachments:
 for i, data in enumerate(attachments):
 msg.add_attachment(
 data,
 maintype="application",
 subtype="octet-stream",
 filename=f"attachment_{i}.pdf",
 )

 # NOTE: Adjust host/port as needed.
 with smtplib.SMTP("localhost", 1025) as server:
 server.send_message(msg)

 return f"Email sent to {to_address}"

# ----------------------------------------------------------------------
# 3️⃣ Calendar helper – generate an iCal invite link
# ----------------------------------------------------------------------
def generate_ics_event(
 start: datetime,
 duration_minutes: int,
 summary: str,
 description: str,
 location: str = "Virtual",
) -> str:
 """
 Returns a minimal iCalendar (.ics) string that can be attached to an email.
 """
 end = start + timedelta(minutes=duration_minutes)
 ics_template = """BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//MyAgent//EN
BEGIN:VEVENT
UID:{{uid}}
DTSTAMP:{{dtstamp}}
DTSTART:{{dtstart}}
DTEND:{{dtend}}
SUMMARY:{{summary}}
DESCRIPTION:{{description}}
LOCATION:{{location}}
END:VEVENT
END:VCALENDAR"""
 uid = f"{datetime.utcnow().timestamp()}@myagent"
 rendered = Template(ics_template).render(
 uid=uid,
 dtstamp=datetime.utcnow().strftime("%Y%m%dT%H%M%SZ"),
 dtstart=start.strftime("%Y%m%dT%H%M%SZ"),
 dtend=end.strftime("%Y%m%dT%H%M%SZ"),
 summary=summary,
 description=description,
 location=location,
 )
 return rendered

# ----------------------------------------------------------------------
# 4️⃣ Upcoming slots (mocked static list)
# ----------------------------------------------------------------------
def get_upcoming_slots(days_ahead: int = 3) -> List[Dict[str, str]]:
 """
 Returns a list of available slots for the next `days_ahead` days.
 In a real app you would query Google Calendar API.
 """
 now = datetime.utcnow()
 slots = []
 for d in range(days_ahead):
 day = now + timedelta(days=d)
 for hour in (10, 14, 16): # 10am, 2pm, 4pm UTC
 slot = {
 "date": day.strftime("%Y-%m-%d"),
 "time": f"{hour:02d}:00 UTC",
 "iso": (day.replace(hour=hour, minute=0, second=0, microsecond=0)).isoformat(),
 }
 slots.append(slot)
 return slots

# ----------------------------------------------------------------------
# 5️⃣ Record unknown question (append to a local JSON file)
# ----------------------------------------------------------------------
def record_unknown_question(question: str) -> str:
 """
 Persists a question the LLM couldn't answer.
 """
 storage_path = "unknown_questions.json"
 try:
 with open(storage_path, "r", encoding="utf-8") as f:
 data = json.load(f)
 except FileNotFoundError:
 data = []

 data.append({"question": question, "timestamp": datetime.utcnow().isoformat()})
 with open(storage_path, "w", encoding="utf-8") as f:
 json.dump(data, f, indent=2)

 return "Question recorded for later review."

# ----------------------------------------------------------------------
# 6️⃣ Lead handling – send lead card via email + push
# ----------------------------------------------------------------------
def send_lead_card(lead: Dict[str, str]) -> str:
 """
 Sends a nicely formatted HTML email to the owner and pushes a notification.
 """
 html = f"""
 <h2>New Lead 🎉</h2>
 <p><strong>Name:</strong> {lead.get('name')}</p>
 <p><strong>Email:</strong> {lead.get('email')}</p>
 <p><strong>Company:</strong> {lead.get('company')}</p>
 """
 # Simulate email sending (replace with real SMTP in prod)
 send_email(
 to_address="owner@example.com",
 subject="🚀 New Lead Received",
 html_body=html,
 )
 push_notification("New lead received", lead.get("email"))
 return "Lead card dispatched."

# ----------------------------------------------------------------------
# 7️⃣ Simple wrapper for OpenAI ChatCompletion (v1 API)
# ----------------------------------------------------------------------
def call_openai(messages: List[Dict[str, str]]) -> str:
 """
 Sends a chat request to OpenAI and returns the assistant's reply.
 """
 headers = {
 "Authorization": f"Bearer {OPENAI_API_KEY}",
 "Content-Type": "application/json",
 }
 payload = {
 "model": "gpt-4o-mini",
 "messages": messages,
 "temperature": 0.2,
 }
 response = httpx.post(
 "https://api.openai.com/v1/chat/completions",
 headers=headers,
 json=payload,
 timeout=30,
 )
 response.raise_for_status()
 data = response.json()
 return data["choices"][0]["message"]["content"]

Expected output – No direct output; functions are ready for import.

✅ Verify: In a Python REPL run:

>>> from tools import get_upcoming_slots
>>> get_upcoming_slots(1)
[{'date': '2026-09-15', 'time': '10:00 UTC', 'iso': '2026-09-15T10:00:00'},.]

You should see a list of slot dictionaries.

5️⃣ app.py – FastAPI entry point

# Save as: app.py
import json
from typing import List, Dict, Any

from fastapi import FastAPI, HTTPException, Request
from pydantic import BaseModel, Field, EmailStr

from tools import (
 push_notification,
 send_email,
 generate_ics_event,
 get_upcoming_slots,
 record_unknown_question,
 send_lead_card,
 call_openai,
)
from config import read_leads_csv

app = FastAPI(
 title="Agentic Lead Assistant",
 description="A minimal FastAPI service exposing LLM‑driven tools.",
 version="0.1.0",
)

# ----------------------------------------------------------------------
# Request models
# ----------------------------------------------------------------------
class ChatMessage(BaseModel):
 role: str = Field(., description="One of'system', 'user', 'assistant'")
 content: str = Field(., description="Message content")

class ChatRequest(BaseModel):
 messages: List[ChatMessage] = Field(., description="Conversation history")

# ----------------------------------------------------------------------
# Helper: map tool name → callable
# ----------------------------------------------------------------------
TOOL_REGISTRY = {
 "push_notification": push_notification,
 "send_email": send_email,
 "generate_ics_event": generate_ics_event,
 "get_upcoming_slots": get_upcoming_slots,
 "record_unknown_question": record_unknown_question,
 "send_lead_card": send_lead_card,
}

# ----------------------------------------------------------------------
# Core endpoint
# ----------------------------------------------------------------------
@app.post("/chat")
async def chat_endpoint(payload: ChatRequest):
 """
 Accepts a list of messages, forwards them to OpenAI, and
 optionally executes a tool if the LLM decides to call one.
 """
 # 1️⃣ Convert Pydantic models to the dict format OpenAI expects
 messages = [msg.dict() for msg in payload.messages]

 # 2️⃣ Call OpenAI
 try:
 assistant_reply = call_openai(messages)
 except Exception as exc:
 raise HTTPException(status_code=502, detail=str(exc))

 # 3️⃣ Very naive tool detection – look for a JSON block like:
 # {"tool":"push_notification","args":{"reason":".","details":"."}}
 try:
 tool_payload = json.loads(assistant_reply)
 tool_name = tool_payload.get("tool")
 args = tool_payload.get("args", {})
 if tool_name in TOOL_REGISTRY:
 result = TOOL_REGISTRY[tool_name](**args)
 # Append tool result to the conversation for transparency
 messages.append({"role": "assistant", "content": f"Tool result: {result}"})
 # Re‑query LLM with the new context
 final_reply = call_openai(messages)
 return {"reply": final_reply, "tool_executed": tool_name, "tool_result": result}
 except json.JSONDecodeError:
 # No tool call – just return the raw reply
 pass

 return {"reply": assistant_reply, "tool_executed": None}

# ----------------------------------------------------------------------
# Simple health check
# ----------------------------------------------------------------------
@app.get("/health")
async def health_check():
 return {"status": "ok", "leads_loaded": len(read_leads_csv())}

Expected output – When you start the server and hit /health you should see:

{
 "status": "ok",
 "leads_loaded": 0
}

✅ Verify:

uvicorn app:app --reload &
curl -s http://127.0.0.1:8000/health

You should receive the JSON above.

6️⃣ Dockerfile – Containerise the service

# Save as: Dockerfile
# -------------------------------------------------
FROM python:3.12-slim

# Install OS dependencies (for SMTP testing we use netcat)
RUN apt-get update && apt-get install -y --no-install-recommends \
 netcat-openbsd && \
 rm -rf /var/lib/apt/lists/*

# Set workdir
WORKDIR /app

# Copy requirements first for layer caching
COPY requirements.txt.
RUN pip install --no-cache-dir -r requirements.txt

# Copy source code
COPY.

# Expose FastAPI default port
EXPOSE 8000

# Entrypoint
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
# -------------------------------------------------

Expected output – Build succeeds:

docker build -t my-agent.
# Output ends with: Successfully tagged my-agent:latest

✅ Verify: Run the container locally:

docker run -d -p 8000:8000 --name my-agent-container my-agent
curl -s http://localhost:8000/health

You should see the same JSON health response.


🚀 What You Just Built

Component Responsibility
.env Secure storage of API keys and bucket name.
config.py Loads env vars, provides a CSV‑reader stub for leads.
tools.py All business‑logic functions the LLM can invoke (push, email, calendar, lead handling, etc.).
app.py FastAPI server exposing /chat and /health. Handles naive tool‑call detection and re‑queries the LLM after tool execution.
Dockerfile One‑step container build for reproducible deployment.
requirements.txt Pin‑exact versions for deterministic installs.

Running uvicorn app:app starts a local HTTP server that can be tunneled with ngrok to obtain a public HTTPS endpoint, perfect for demos or integration with external chat widgets.


Common Mistakes

Mistake Why It Happens Fix
Hard‑coding secrets Copy‑pasting API keys into source files. Always read from environment variables (python-dotenv).
Import loops app.py imports tools.py which imports config.py that again imports something from app.py. Keep imports one‑directional; config should never import from app.
Tool detection too strict Expecting exact JSON format; any extra whitespace breaks parsing. Use json.loads inside a try/except and fallback to raw reply.
Running SMTP on localhost without a server send_email fails because no SMTP daemon is listening. For local testing, run python -m smtpd -c DebuggingServer -n localhost:1025 in another terminal.
ngrok not installed Trying ngrok http 8000 and getting “command not found”. Install via brew install ngrok (macOS) or download from https://ngrok.com/.
Docker container cannot find .env .env is not copied into the image. Add COPY.env. to Dockerfile only for development; for production inject env vars via docker run -e.

Troubleshooting

Symptom Likely Cause Quick Fix
ImportError: cannot import name 'push_notification' tools.py not in PYTHONPATH. Ensure you run the script from the project root (my_agent/).
401 Unauthorized from OpenAI Wrong or missing OPENAI_API_KEY. Verify .env entry, then source.env or restart the server.
ConnectionError when calling /chat from ngrok URL ngrok tunnel not running or port mismatch. Restart ngrok: ngrok http 8000. Confirm the public URL matches the local port.
Email never arrives SMTP server not reachable or using wrong port. Run a local debug SMTP (python -m smtpd -c DebuggingServer -n localhost:1025) and watch console logs.
JSONDecodeError in tool detection LLM returned plain text instead of JSON. Adjust system prompt (in your notebook) to ask the model to output JSON when calling a tool.
Container exits immediately Missing CMD or wrong entrypoint. Verify Dockerfile ends with CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"].

Key Takeaways

  • Modular code = maintainable codetools.py isolates side‑effects; config.py centralises secrets.
  • Environment variables are your friend – never commit secrets; use .env locally and real env vars in production.
  • FastAPI + ngrok = instant public demo – no need for complex cloud infra while you iterate.
  • Containerisation guarantees “it works on my machine” – Docker image captures OS, Python, and dependencies.
  • Simple tool‑calling protocol (JSON block) lets LLMs act on the real world while keeping the server in control.

🧪 Try It Yourself

  1. Add a new tool – create a function def fetch_weather(city: str) -> str: in tools.py that returns a dummy weather string. Register it in TOOL_REGISTRY.
  2. Update the system prompt (in your notebook that generates the initial messages) to tell the LLM: “When you need the weather, output JSON with tool: "fetch_weather" and args: {"city": "<city>"}.”
  3. Restart the server and send a POST request to /chat with a user message like “What’s the weather in London?”. Verify that the tool runs and the final reply includes the weather.
curl -X POST http://127.0.0.1:8000/chat \
 -H "Content-Type: application/json" \
 -d '{"messages":[{"role":"user","content":"What’s the weather in London?"}]}'

If everything is wired correctly, you’ll see a JSON response showing tool_executed: "fetch_weather" and the dummy weather string in tool_result. 🎉

Happy coding! 🚀

Why This Matters

Deploying a machine‑learning‑powered Streamlit app is the final step that turns a prototype into a usable product. Choosing a managed platform like Hugging Face Spaces gives you:

  • Zero‑ops – no Dockerfiles, no server provisioning.
  • Free tier for quick experiments (but a paid plan is required for CPU‑heavy workloads).
  • Built‑in secret management – your API keys never touch the public repository.

Understanding the deployment workflow lets you ship reliable, secure applications that anyone can run with a single click.


What You’ll Build

  • A complete Streamlit app (app.py) that:
  • Calls the OpenAI API to generate a personalized LinkedIn summary.
  • Sends the result via SendGrid email.
  • Uses environment variables for all secrets.
  • A requirements.txt that lists every Python dependency.
  • A README.md that explains the project and the deployment steps.
  • A step‑by‑step guide to push the files to a Hugging Face Space, add secrets, and verify the live service.

Content

1️⃣ Prepare the Project Files

File Purpose
app.py Main Streamlit app (runnable locally and on Spaces).
requirements.txt Exact Python packages needed.
README.md Project description + deployment instructions.
.env.example Template showing required secret names (never committed).

app.py

# Save as: app.py
import os
import streamlit as st
import openai
import sendgrid
from sendgrid.helpers.mail import Mail

# -------------------------------------------------
# Helper: Load secrets from environment variables
# -------------------------------------------------
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
SENDGRID_API_KEY = os.getenv("SENDGRID_API_KEY")
SENDER_EMAIL = os.getenv("SENDER_EMAIL")
RECIPIENT_EMAIL = os.getenv("RECIPIENT_EMAIL") # optional, defaults to sender

if not all([OPENAI_API_KEY, SENDGRID_API_KEY, SENDER_EMAIL]):
 st.error("❌ Missing required environment variables. Check your Secrets.")
 st.stop()

# -------------------------------------------------
# Streamlit UI
# -------------------------------------------------
st.title("🤖 LinkedIn Summary Generator")
st.caption("Generate a polished LinkedIn summary and email it to yourself.")

with st.form(key="summary_form"):
 name = st.text_input("Your full name", placeholder="Jane Doe")
 role = st.text_input("Current role / title", placeholder="Data Scientist")
 experience = st.text_area(
 "Brief experience (max 300 chars)",
 placeholder="Worked on predictive modeling, NLP, and data pipelines."
 )
 submit = st.form_submit_button("Generate Summary")

if submit:
 if not all([name.strip(), role.strip(), experience.strip()]):
 st.warning("⚠️ Please fill in all fields.")
 else:
 # -------------------------------------------------
 # Call OpenAI
 # -------------------------------------------------
 openai.api_key = OPENAI_API_KEY
 prompt = (
 f"Write a concise, professional LinkedIn summary for {name}, "
 f"a {role}. Highlight the following experience: {experience}"
 )
 try:
 response = openai.ChatCompletion.create(
 model="gpt-3.5-turbo",
 messages=[{"role": "user", "content": prompt}],
 max_tokens=200,
 temperature=0.7,
 )
 summary = response.choices[0].message.content.strip()
 st.success("✅ Summary generated!")
 st.markdown(f"### 📄 Your LinkedIn Summary\n{summary}")
 except Exception as e:
 st.error(f"❌ OpenAI request failed: {e}")
 st.stop()

 # -------------------------------------------------
 # Send email via SendGrid
 # -------------------------------------------------
 sg = sendgrid.SendGridAPIClient(api_key=SENDGRID_API_KEY)
 email = Mail(
 from_email=SENDER_EMAIL,
 to_emails=RECIPIENT_EMAIL or SENDER_EMAIL,
 subject="Your LinkedIn Summary",
 plain_text_content=summary,
 )
 try:
 sg.send(email)
 st.info("📧 Email sent successfully!")
 except Exception as e:
 st.error(f"❌ Failed to send email: {e}")

# -------------------------------------------------
# Footer
# -------------------------------------------------
st.caption("💡 Tip: Store your API keys as Secrets in Hugging Face Spaces – they are encrypted and never appear in the repo.")

Expected output when you run locally (streamlit run app.py):

✅ Summary generated!
📧 Email sent successfully!

The UI will display a form, the generated summary, and status messages.

✅ Verify: Run streamlit run app.py locally with a .env file (see .env.example) and confirm the UI works and you receive an email.


requirements.txt

# Save as: requirements.txt
streamlit==1.38.0
openai==1.30.5
sendgrid==6.11.0
python-dotenv==1.0.1 # optional, for local dev only

✅ Verify: In a fresh virtual environment, run pip install -r requirements.txt. No errors should appear.


README.md

# Save as: README.md
# LinkedIn Summary Generator (Streamlit)

A tiny Streamlit app that:

* Generates a professional LinkedIn summary using **OpenAI GPT‑3.5‑Turbo**. 
* Sends the result to your inbox via **SendGrid**. 

## Local Development

```bash
python -m venv.venv
source.venv/bin/activate # Windows:.venv\Scripts\activate
pip install -r requirements.txt
cp.env.example.env # Fill in your keys
streamlit run app.py

Deploy to Hugging Face Spaces

  1. Create a Space → Choose “Streamlit” as the SDK.
  2. Upload app.py, requirements.txt, README.md, and the me/ folder (contains your LinkedIn profile JSON).
  3. Add Secrets (Settings → Variables & SecretsNew secret):
Secret name Value (example)
OPENAI_API_KEY sk-.
SENDGRID_API_KEY SG.
SENDER_EMAIL you@example.com
RECIPIENT_EMAIL you@example.com (optional)
  1. Commit → The Space will automatically build and launch.

Visit the Space URL to interact with the live app.

License

MIT


✅ Verify: The README renders correctly on GitHub and in the Hugging Face Space UI.

---

#### `.env.example` (do **not** commit this file)

Save as:.env.example

OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx SENDGRID_API_KEY=SG.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx SENDER_EMAIL=you@example.com RECIPIENT_EMAIL=you@example.com # optional


---

### 2️⃣ Deploy to Hugging Face Spaces 

1. **Create a Hugging Face account** (or log in). 
2. Click **“New Space” → “Create new Space”**. 
 * Choose **“Public”** or **“Private”** (private requires a paid plan). 
 * Select **“Streamlit”** as the SDK. 
3. **Upload the project files**: drag‑and‑drop `app.py`, `requirements.txt`, `README.md`, and the `me/` folder into the file explorer. 
4. **Commit to `main`** – the UI will start a build. 

 ![Hugging Face build screen](https://huggingface.co/spaces/your-username/your-space-name/blob/main/.github/workflows/build.png)

5. **Add Secrets** 

 * Go to **Settings → Variables & Secrets → New secret**. 
 * Use **exact** names from the code (`OPENAI_API_KEY`, `SENDGRID_API_KEY`, `SENDER_EMAIL`, `RECIPIENT_EMAIL`). 
 * Paste the values **without quotes or extra spaces**. 

 ⚠️ **Never** commit these values to the repository; they are stored encrypted by Hugging Face.

6. **Wait for the build to finish** – you’ll see a green “Running” badge. 

7. Click **“Open App”** to test the live version. 

✅ Verify: The live app loads, you can fill the form, and you receive an email with the generated summary.

---

### 3️⃣ Verify the Deployment Programmatically 

You can ping the Space’s `/` endpoint to ensure it returns a `200 OK`. Create a tiny script:

```python
# Save as: verify_deployment.py
import requests

SPACE_URL = "https://your-username-your-space.hf.space"

def main():
 try:
 r = requests.get(SPACE_URL, timeout=10)
 if r.status_code == 200:
 print("✅ Space is up and reachable!")
 else:
 print(f"❌ Unexpected status code: {r.status_code}")
 except Exception as e:
 print(f"❌ Request failed: {e}")

if __name__ == "__main__":
 main()

Run:

python verify_deployment.py

Expected output

✅ Space is up and reachable!

✅ Verify: The script prints the success message.


📁 Project Files

linkedin-summary/
├─ app.py
├─ requirements.txt
├─ README.md
├─.env.example
└─ me/
 └─ profile.json # (your static LinkedIn data, optional)

🚀 What You Just Built

  • A self‑contained Streamlit app that talks to OpenAI and SendGrid.
  • A deployment pipeline that works on Hugging Face Spaces without Docker.
  • Secure secret handling – API keys never appear in source control.

You can now share the Space URL with anyone; they can generate a LinkedIn summary instantly.


Common Mistakes

Mistake Why it Happens Fix
Missing secret → app crashes with “Missing required environment variables.” Secrets not added or typo in secret name. Double‑check secret names in Settings → Variables & Secrets.
Incorrect requirements.txt version → build fails. Pinning an incompatible version (e.g., old Streamlit). Use the exact versions listed above; run pip freeze > requirements.txt after a successful local install.
Commiting .env → keys exposed publicly. Accidentally added .env to git. Add .env to .gitignore and remove it from the repo (git rm --cached.env).
Space stays in “Building” forever Missing requirements.txt or syntax error in app.py. Check the Build logs (Settings → Logs). Fix any import errors, then recommit.
Email never arrives SendGrid sandbox mode or wrong sender domain. Verify SendGrid account is in production mode and the SENDER_EMAIL is a verified sender.

Troubleshooting

  1. Inspect Build Logs
  2. Settings → LogsBuild – look for pip install errors or missing files.

  3. Runtime Errors

  4. Open the “Logs” tab while the app is running. Typical messages:

2024-09-15 12:34:56,789 ERROR openai.error.AuthenticationError: Incorrect API key provided.

  • Ensure the secret value is correct and has no stray whitespace.

  • Email not sent

  • Check SendGrid’s Event Webhook or the Dashboard → Activity for rejected messages.
  • Verify that the SENDER_EMAIL domain is authorized in SendGrid.

  • CORS / Rate‑limit

  • OpenAI may return 429 Too Many Requests. Add exponential back‑off or upgrade your OpenAI plan.

  • Local vs. Space environment differences

  • The Space runs on Python 3.10 by default. Ensure your local dev uses the same version to avoid subtle incompatibilities.

Key Takeaways

  • Secrets are never stored in the repo – always use the platform’s secret manager.
  • Hugging Face Spaces automatically builds a Docker‑like container from requirements.txt and your code; you only need to supply the files.
  • A minimal app.py plus a correct requirements.txt is enough to run a production‑grade Streamlit app.
  • Always verify each step (local run, build logs, live test, programmatic ping) to catch issues early.

🧪 Try It Yourself

  1. Fork this repository (or copy the files into a new folder).
  2. Replace the placeholder keys in .env.example with your own OpenAI and SendGrid credentials.
  3. Run the app locally and confirm you receive an email.
  4. Deploy to a new Hugging Face Space following the steps above.
  5. Share the Space URL with a colleague and ask them to generate a summary.

Challenge: Extend the app to let the user choose between “Professional” and “Creative” tones by adding a dropdown and adjusting the OpenAI prompt accordingly.

Happy building! 🚀

Why This Matters

Even a functional chatbot can feel “thin” if it forgets what the user just said. Persisting chat history and user‑specific memory turns a one‑off demo into a real‑world assistant that:

  • Remembers user preferences (e.g., “I prefer mornings for meetings”)
  • Avoids asking the same question repeatedly
  • Enables analytics (what topics are most common?)
  • Provides a smoother, more professional experience for anyone who clicks the public URL

What You’ll Build

In this final section you will extend the live booking assistant we deployed on Hugging Face Spaces with:

  1. Per‑user chat history stored in SQLite.
  2. LangChain memory integration so the agent can reference past turns.
  3. Basic API key protection for the public endpoint.
  4. Graceful error handling & logging for production stability.

When you’re done, the same public URL will now keep a conversation context for each user, making the bot feel truly intelligent.


Content

1️⃣ Add a SQLite‑backed Memory Layer

LangChain ships with a ConversationBufferMemory that can be backed by any storage. We’ll implement a tiny wrapper around SQLite that satisfies LangChain’s BaseChatMemory interface.

2️⃣ Wire the Memory into the Agent

The agent will receive a user_id (generated from the request’s API key) and will load the corresponding memory before processing the prompt.

3️⃣ Secure the Public Endpoint

We’ll protect the /chat route with a simple API‑key header. The key is stored in an environment variable (HF_API_KEY) – the same variable you already set for Hugging Face Spaces.

4️⃣ Add Logging & Error Handling

A small logger will write every request/response pair to a log file. Errors will be caught and returned as friendly JSON messages.


📁 Project Files

1️⃣ memory.py – SQLite‑backed LangChain Memory

# Save as: memory.py
import os
import sqlite3
from typing import List, Tuple, Optional
from langchain.schema import BaseMessage, HumanMessage, AIMessage
from langchain.memory import BaseChatMemory

DB_PATH = os.getenv("DB_PATH", "chat_history.db")

# ----------------------------------------------------------------------
# Initialise the SQLite DB (run once at import)
# ----------------------------------------------------------------------
def _init_db() -> None:
 conn = sqlite3.connect(DB_PATH)
 cur = conn.cursor()
 cur.execute(
 """
 CREATE TABLE IF NOT EXISTS messages (
 user_id TEXT NOT NULL,
 role TEXT NOT NULL,
 content TEXT NOT NULL,
 timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
 )
 """
 )
 conn.commit()
 conn.close()

_init_db()

# ----------------------------------------------------------------------
# Helper functions
# ----------------------------------------------------------------------
def _fetch_messages(user_id: str) -> List[Tuple[str, str]]:
 conn = sqlite3.connect(DB_PATH)
 cur = conn.cursor()
 cur.execute(
 "SELECT role, content FROM messages WHERE user_id = ? ORDER BY timestamp ASC",
 (user_id,),
 )
 rows = cur.fetchall()
 conn.close()
 return rows

def _store_message(user_id: str, role: str, content: str) -> None:
 conn = sqlite3.connect(DB_PATH)
 cur = conn.cursor()
 cur.execute(
 "INSERT INTO messages (user_id, role, content) VALUES (?, ?, ?)",
 (user_id, role, content),
 )
 conn.commit()
 conn.close()

# ----------------------------------------------------------------------
# LangChain Memory implementation
# ----------------------------------------------------------------------
class SQLiteChatMemory(BaseChatMemory):
 """A simple SQLite‑backed conversation buffer."""

 def __init__(self, user_id: str):
 super().__init__()
 self.user_id = user_id
 self.chat_memory = [] # type: List[BaseMessage]

 @property
 def buffer(self) -> str:
 """Return the full conversation as a single string."""
 return "\n".join(
 [f"{msg.type.capitalize()}: {msg.content}" for msg in self.chat_memory]
 )

 def load_memory_variables(self, inputs: dict) -> dict:
 """Load messages from SQLite into `self.chat_memory`."""
 rows = _fetch_messages(self.user_id)
 self.chat_memory = [
 HumanMessage(content=row[1]) if row[0] == "human" else AIMessage(content=row[1])
 for row in rows
 ]
 return {"history": self.buffer}

 def save_context(self, inputs: dict, outputs: dict) -> None:
 """Persist the latest turn to SQLite."""
 human = inputs.get("input")
 ai = outputs.get("output")
 if human:
 _store_message(self.user_id, "human", human)
 self.chat_memory.append(HumanMessage(content=human))
 if ai:
 _store_message(self.user_id, "ai", ai)
 self.chat_memory.append(AIMessage(content=ai))

 def clear(self) -> None:
 """Delete all history for this user."""
 conn = sqlite3.connect(DB_PATH)
 cur = conn.cursor()
 cur.execute("DELETE FROM messages WHERE user_id = ?", (self.user_id,))
 conn.commit()
 conn.close()
 self.chat_memory = []

✅ Verify: Run python -c "import memory; print('SQLite memory ready')" – you should see SQLite memory ready without errors.


2️⃣ agent.py – Agent with Integrated Memory

# Save as: agent.py
import os
from typing import List
from langchain.agents import initialize_agent, AgentType, Tool
from langchain.llms import OpenAI
from langchain.utilities import GoogleSearchAPIWrapper, EmailSender
from memory import SQLiteChatMemory

# ----------------------------------------------------------------------
# Tools (reuse the ones you built earlier)
# ----------------------------------------------------------------------
search = GoogleSearchAPIWrapper()
email_tool = EmailSender(
 smtp_server=os.getenv("SMTP_SERVER"),
 smtp_port=int(os.getenv("SMTP_PORT", "587")),
 username=os.getenv("SMTP_USER"),
 password=os.getenv("SMTP_PASS"),
)

tools: List[Tool] = [
 Tool(
 name="GoogleSearch",
 func=search.run,
 description="Useful for answering questions about current events."
 ),
 Tool(
 name="SendEmail",
 func=email_tool.run,
 description="Send an email. Input should be a JSON string with keys: to, subject, body."
 ),
]

# ----------------------------------------------------------------------
# Agent factory
# ----------------------------------------------------------------------
def get_agent(user_id: str):
 """Create a LangChain agent bound to a specific user's memory."""
 memory = SQLiteChatMemory(user_id=user_id)
 llm = OpenAI(temperature=0) # make sure OPENAI_API_KEY is set
 agent = initialize_agent(
 tools,
 llm,
 agent=AgentType.OPENAI_FUNCTIONS,
 verbose=False,
 memory=memory,
 )
 return agent

✅ Verify: In a REPL, execute:

from agent import get_agent
agent = get_agent("test_user")
print(agent.run("Hello!"))

You should receive a friendly response (e.g., “Hello! How can I help you today?”) and a new SQLite entry for test_user.


3️⃣ app.py – FastAPI entry point with API‑key protection & logging

# Save as: app.py
import os
import logging
from fastapi import FastAPI, Header, HTTPException, Request
from pydantic import BaseModel
from starlette.responses import JSONResponse
from agent import get_agent

# ----------------------------------------------------------------------
# Logging configuration
# ----------------------------------------------------------------------
logging.basicConfig(
 filename="app.log",
 level=logging.INFO,
 format="%(asctime)s %(levelname)s %(message)s",
)

logger = logging.getLogger(__name__)

# ----------------------------------------------------------------------
# FastAPI app
# ----------------------------------------------------------------------
app = FastAPI(title="Live Booking Assistant with Memory")

# ----------------------------------------------------------------------
# Request schema
# ----------------------------------------------------------------------
class ChatRequest(BaseModel):
 user_message: str

# ----------------------------------------------------------------------
# Helper: simple API‑key auth
# ----------------------------------------------------------------------
def _require_api_key(x_api_key: str = Header(.)):
 expected = os.getenv("HF_API_KEY")
 if not expected or x_api_key != expected:
 raise HTTPException(status_code=401, detail="Invalid API key")
 return x_api_key

# ----------------------------------------------------------------------
# Main chat endpoint
# ----------------------------------------------------------------------
@app.post("/chat")
async def chat_endpoint(
 request: Request,
 payload: ChatRequest,
 api_key: str = Header(., alias="x-api-key")
):
 # Authenticate
 _require_api_key(api_key)

 # Derive a deterministic user_id from the API key (simple for demo)
 user_id = api_key # In production you’d map API keys to real user IDs

 try:
 agent = get_agent(user_id)
 response = agent.run(payload.user_message)

 # Log the exchange
 logger.info(
 f"user_id={user_id} | prompt={payload.user_message!r} | response={response!r}"
 )

 return JSONResponse(content={"response": response})
 except Exception as exc:
 logger.exception("Unhandled exception in chat_endpoint")
 raise HTTPException(status_code=500, detail=str(exc))

# ----------------------------------------------------------------------
# Health check (useful for Spaces)
# ----------------------------------------------------------------------
@app.get("/health")
async def health():
 return {"status": "ok"}

✅ Verify: 1. Set environment variables (example for local testing):

export HF_API_KEY=demo_key
export OPENAI_API_KEY=sk-.
export SMTP_SERVER=smtp.gmail.com
export SMTP_PORT=587
export SMTP_USER=your.email@gmail.com
export SMTP_PASS=your_app_password
  1. Run the server:
uvicorn app:app --host 0.0.0.0 --port 8000
  1. Test with curl:
curl -X POST "http://127.0.0.1:8000/chat" \
 -H "Content-Type: application/json" \
 -H "x-api-key: demo_key" \
 -d '{"user_message":"Hi, I want to book a meeting tomorrow"}'

Expected JSON output (truncated for brevity):

{
 "response": "Sure! I have the following slots available tomorrow: 10 AM, 2 PM, 4 PM. Which works for you?"
}

Subsequent calls with the same x-api-key will retain the conversation context (e.g., the agent can ask “Would you like me to send a calendar invite?” without being prompted again).


🚀 What You Just Built

Feature How It Works
Per‑user chat history SQLite rows keyed by the API key; LangChain loads them into ConversationBufferMemory.
Memory‑aware agent The agent sees the full history variable on each turn, enabling follow‑up questions.
API‑key protection Simple header check (x-api-key) prevents strangers from abusing the endpoint.
Logging Every request/response pair is appended to app.log for audit & debugging.
Graceful errors Unexpected exceptions become 500 JSON responses; stack traces are captured in the log file.

Deploy the same app.py to Hugging Face Spaces (or any container‑based platform) – the only extra step is to add the new environment variables (DB_PATH, HF_API_KEY, etc.) in the Space’s settings.


Common Mistakes

Mistake Why It Happens Fix
Forgetting to call _init_db() SQLite file never created, leading to “no such table: messages”. Ensure memory.py is imported before any request (the module does it automatically).
Using the same API key for multiple users Conversation histories get mixed. In production, map each API key to a real user ID (e.g., via a DB lookup).
Hard‑coding credentials Secrets leak when the repo is public. Store everything in environment variables; never commit .env files.
Running the app without OPENAI_API_KEY LangChain raises an authentication error. Verify OPENAI_API_KEY is set in the deployment environment.
SQLite lock errors under heavy load Multiple concurrent writes block each other. For a production‑grade system, switch to PostgreSQL or another server‑side DB.

Troubleshooting

  1. No response from the agent Check: app.log for “Unhandled exception”. Most often it’s a missing OpenAI key or a malformed tool input.

  2. History not persisting Check: SELECT * FROM messages WHERE user_id = ? in the SQLite file (sqlite3 chat_history.db). If rows are missing, verify that save_context is being called (add a print inside the method).

  3. API‑key rejection Check: The header name is x-api-key (case‑insensitive). Ensure you’re not sending X-API-KEY without the hyphen.

  4. Email never arrives Check: SMTP credentials, and that the “from” address matches the authenticated user. Look at the SMTP server’s logs if possible.

  5. Space crashes on first request Check: The Space’s “Runtime” tab for missing environment variables. Add them under “Secrets & Variables”.


Key Takeaways

  • Memory = Personality – Adding a persistent conversation buffer makes a chatbot feel like a personal assistant.
  • Security first – Even a public demo should enforce a lightweight auth mechanism.
  • Observability matters – Logs and health checks are indispensable once the app is internet‑facing.
  • SQLite is great for prototypes but plan for a scalable DB when traffic grows.
  • Prompt engineering + tools = robustness – The agent can fall back to a web search tool when its knowledge base is stale.

🧪 Try It Yourself

  1. Add a “clear history” command – Extend app.py with a DELETE /history endpoint that calls memory.SQLiteChatMemory(user_id).clear().
  2. Persist user preferences – Store a JSON blob (e.g., preferred meeting length) in a separate users table and read it inside the agent’s prompt.
  3. Switch to PostgreSQL – Replace the SQLite helper functions with psycopg2 calls; observe how concurrency improves under load.
  4. Add rate limiting – Use slowapi or a simple in‑memory counter to prevent abuse of the public URL.

Happy coding! 🎉


What's Next

Continue to the next chapter to keep building.

Chapter 14

The Final Project: Putting It All Together

Why This Matters

Create Your .env File

Create a file called .env in your project folder:

echo. > .env        # Windows
touch .env          # Mac/Linux

Open it in Notepad/VS Code and add your keys:

OPENAI_API_KEY=sk-your_key_here

No spaces around the equals sign. No quotes. Never share this file.

Create & Activate Virtual Environment

uv venv

Activate (Windows PowerShell):

.venv\Scripts\Activate

Activate (Mac/Linux):

source .venv/bin/activate

How you'll know it worked: Your prompt shows (.venv) at the start.

Everything in this book built toward this. You now have every piece - environment setup, dependency management, API calls, tools, N8N workflows, and AI integration.

Set Up N8N (No-Code Automation)

N8N connects your agent to external services (email, calendar, etc.) without writing glue code.

  1. Go to https://n8n.io
  2. Click "Cloud Free Trial" (no credit card needed for 30 days)
  3. Create an account
  4. Once logged in, you will see a blank canvas — that is your workflow editor
  5. Click the "+" button to add your first node

For local setup (free forever):

npm install -g n8n
n8n start

Then open http://localhost:5678 in your browser.

Verify: You should see the N8N dashboard with a "Create Workflow" button.

What is an API? An API (Application Programming Interface) is how one program talks to another. Think of it like a waiter in a restaurant — you (your code) tell the waiter (API) what you want, the waiter goes to the kitchen (the server), and brings back your food (the response). You never talk to the kitchen directly.

This chapter puts them all together.

What You Will Build

A single python main.py that starts: 1. A local AI agent (Ollama)

Install Ollama (Local AI Models)

Ollama lets you run AI models on your own computer (no internet needed, no API costs).

  1. Go to https://ollama.com/download
  2. Download and install for your OS
  3. Open a new terminal and verify:

    ollama --version

  4. Pull a model (this downloads ~4.7 GB, takes 5-10 min):

    ollama pull llama3.1:8b

  5. Test it:

    ollama run llama3.1:8b "Say hello"

Expected output:

   Hello! How can I help you today?

You need at least 8 GB RAM to run llama3.1:8b. If you have less, use ollama pull llama3.2:3b instead (smaller, ~2 GB).

  1. Calendar booking tool
  2. Web search tool
  3. CRM / lead scoring tool
  4. A simple web UI (Streamlit)

Prerequisites

  • Everything from chapters 1-13 working
  • N8N running on port 5678
  • Ollama running with llama3.1:8b

Project Structure

my-ai-agent/
+-- main.py
+-- agent.py
+-- tools/
| +-- __init__.py
| +-- calendar.py
| +-- web_search.py
| +-- crm.py
+--.env
+-- requirements.txt

Step 1: Create the Project

mkdir my-ai-agent && cd my-ai-agent
uv venv
uv add streamlit requests python-dotenv

VERIFY: You should see packages installing.

Step 2: Create.env

OLLAMA_URL=http://localhost:11434
OLLAMA_MODEL=llama3.1:8b

Step 3: Create agent.py

import requests, os, re
from dotenv import load_dotenv

load_dotenv()
OLLAMA_URL = os.getenv('OLLAMA_URL', 'http://localhost:11434')
OLLAMA_MODEL = os.getenv('OLLAMA_MODEL', 'llama3.1:8b')

def ask_agent(question):
 resp = requests.post(
 f'{OLLAMA_URL}/api/chat',
 json={'model': OLLAMA_MODEL, 'messages': [{'role': 'user', 'content': question}],'stream': False},
 timeout=60
 )
 return resp.json()['message']['content']

def schedule_meeting(title, date, time_str):
 from datetime import datetime, timezone, timedelta
 dt = datetime.fromisoformat(f'{date}T{time_str}:00+00:00')
 fmt = '%Y%m%dT%H%M%SZ'
 start = dt.astimezone(timezone.utc).strftime(fmt)
 end = (dt + timedelta(minutes=30)).astimezone(timezone.utc).strftime(fmt)
 url = f'https://calendar.google.com/calendar/render?action=TEMPLATE&text={title.replace(chr(32), chr(43))}&dates={start}/{end}'
 return f'Meeting: {title}\nAdd to calendar: {url}'

def search_web(query):
 resp = requests.get(f'https://html.duckduckgo.com/html/?q={query}', headers={'User-Agent': 'Mozilla/5.0'}, timeout=10)
 results = re.findall(r'class="result__a"[^>]*>(.*?)</a>', resp.text)
 return '\n'.join(f'- {r}' for r in results[:3]) if results else f'No results for: {query}'

def score_lead(name, intent):
 score = 50
 if any(w in intent.lower() for w in ['buy', 'purchase', 'price']): score += 30
 if any(w in intent.lower() for w in ['urgent', 'asap']): score += 15
 if any(w in intent.lower() for w in ['maybe', 'later']): score -= 20
 return f'Lead: {name} | Score: {max(0, min(100, score))}/100'

def route(question):
 q = question.lower()
 if any(w in q for w in ['schedule', 'meeting', 'call']):
 return schedule_meeting(question, '2026-09-20', '15:00')
 elif any(w in q for w in ['search', 'find', 'look up']):
 return search_web(question)
 elif any(w in q for w in ['lead', 'prospect','score']):
 return score_lead('Unknown', question)
 else:
 return ask_agent(question)

Step 4: Create main.py

import streamlit as st
from agent import route

st.set_page_config(page_title='AI Agent')
st.title('Your AI Agent')
st.caption('Calendar | Web Search | CRM | Local LLM')

if 'messages' not in st.session_state:
 st.session_state.messages = []

for msg in st.session_state.messages:
 with st.chat_message(msg['role']):
 st.markdown(msg['content'])

if prompt := st.chat_input('Ask your agent.'):
 st.session_state.messages.append({'role': 'user', 'content': prompt})
 with st.chat_message('user'):
 st.markdown(prompt)
 with st.chat_message('assistant'):
 with st.spinner('Thinking.'):
 answer = route(prompt)
 st.markdown(answer)
 st.session_state.messages.append({'role': 'assistant', 'content': answer})

Step 5: Run It

streamlit run main.py

VERIFY: Browser opens at http://localhost:8501

Step 6: Test

Type This You Get
Schedule a meeting tomorrow at 3pm Calendar link
Find latest Python features Search results
Score lead: wants to buy, urgent Score: 95/100
What is a virtual environment? AI answer

Key Takeaways

  • A production agent = LLM + tools + routing + UI
  • Local LLMs are sufficient for most use cases
  • Streamlit gives you a web UI in 20 lines
  • The architecture scales: swap tools, add auth, deploy to a VPS

Try It Yourself

Add a 4th tool: an email sender using smtplib. When a meeting is scheduled, send a confirmation email.


Common Mistakes

Problem Cause Fix
command not found Tool not in PATH Close ALL terminals, open new one
> What is PATH? PATH is a list of folders your computer checks when you type a command. If you type python, your computer looks in each PATH folder for a file called python.exe. If it finds it, it runs it. If not, you get "command not found." Installing Python with "Add to PATH" checked adds Python's folder to this list.

| ModuleNotFoundError | Venv not activated | Run .venv\Scripts\Activate | | Permission denied | Wrong folder | Use cd to go to correct folder | | Nothing happens | Typo in command | Check spelling | | SyntaxError | Copied from wrong source | Use the code in THIS book only |


What's Next

Continue to the next chapter to keep building.

Appendix

Appendix

A. Quick Reference


Before You Start This Chapter

  • Your venv is active (you see `(.

    What is a virtual environment (venv)? It is a separate, isolated Python world for your project. Without it, packages from different projects can conflict. With a venv, each project gets its own clean set of packages. Think of it like a separate browser profile for each website.

venv)in your prompt) - Previous chapter's project is working - Your.env` file has your API key

Get Your OpenAI API Key (if you do not have one)

  1. Go to https://platform.openai.com/api-keys
  2. Log in (or create a free account)
  3. Click "Create new secret key"
  4. Name it: adhikk-book
  5. Click "Create"
  6. Copy the key immediately (starts with sk-) — you will NOT see it again
  7. Paste it into your .env file:

    OPENAI_API_KEY=sk-your_key_here

New accounts get $5 free credit — more than enough for this entire book.

What is an API? An API (Application Programming Interface) is how one program talks to another. Think of it like a waiter in a restaurant — you (your code) tell the waiter (API) what you want, the waiter goes to the kitchen (the server), and brings back your food (the response). You never talk to the kitchen directly.

  • You are in the correct folder (run pwd to check)

Create & Activate Virtual Environment

uv venv

Activate (Windows PowerShell):

.venv\Scripts\Activate

Activate (Mac/Linux):

source .venv/bin/activate

How you'll know it worked: Your prompt shows (.venv) at the start.

Command What It Does
uv venv Create virtual environment
uv add Add dependency
uv run <script> Run in venv
uv lock Lock versions
ollama pull Download model
ollama serve Start server
streamlit run Start web app

B. Glossary

Term Definition
Venv Isolated Python environment
Dependency Library your code needs
Lock file Pins exact dependency versions
Agent AI that takes actions
Tool calling LLM deciding to use a function
Workflow Sequence of connected tasks
Webhook URL receiving external data
Token Unit of text (~4 chars)
> What is a token? A token is how AI models count text. Think of it as a "word piece" — roughly 3-4 characters. "Hello" = 1 token. "Hello world" = 2 tokens. Models have a limit on how many tokens they can process at once (called the "context window").

| Context window | Max tokens LLM processes at once | | Temperature | Output randomness (0-1) |

C. Resources

Topic Link
Python docs docs.python.org
uv docs docs.astral.sh/uv
N8N docs docs.n8n.io
Ollama docs docs.ollama.com
Streamlit docs docs.streamlit.io

D. About the Author

Ishant is the founder of Adhikk — building AI agents, web applications, and automation systems for businesses. He writes books because tutorials teach you what to do; this one teaches you how to actually do it, with every file, every error, and every fix included.

Adhikk — More than enough.

adhikk.com


Common Mistakes

Problem Cause Fix
command not found Tool not in PATH Close ALL terminals, open new one
> What is PATH? PATH is a list of folders your computer checks when you type a command. If you type python, your computer looks in each PATH folder for a file called python.exe. If it finds it, it runs it. If not, you get "command not found." Installing Python with "Add to PATH" checked adds Python's folder to this list.

| ModuleNotFoundError | Venv not activated | Run .venv\Scripts\Activate | | Permission denied | Wrong folder | Use cd to go to correct folder | | Nothing happens | Typo in command | Check spelling | | SyntaxError | Copied from wrong source | Use the code in THIS book only |


What's Next

Continue to the next chapter to keep building.

What I Do

Services & Products

Pick what you need. Or take the bundle and save.

🤖

Website AI Assistant STARTER

Turn your passive website traffic into qualified leads 24/7. An AI agent that answers questions, qualifies visitors, books meetings, and captures leads while you sleep.

₹5,999 / one-time
Get Started
🌐

Website Development

No website yet? I build one. Responsive, fast, SEO-ready. Landing page, portfolio, or multi-page business site. Deployed and live.

₹12,999 / project
Get Started
BEST VALUE
🚀

Website + AI Agent BUNDLE

Complete digital presence. Professional website + AI assistant that converts visitors into leads. Save ₹4,000 vs buying separately.

₹17,998 ₹14,999 / project
Get the Bundle

Automation & Integration

Connect your tools end-to-end. N8N workflows, API integrations, email automation, CRM pipelines, calendar sync. Save 10+ hours per week.

₹24,999 / project
Get a Quote
🏢

Enterprise AI Ecosystem

Build an autonomous digital workforce for your entire company. Multi-agent systems, internal knowledge bases, automated workflows across sales, operations, support, and HR. Custom-built, deployed, and maintained.

₹1,49,999 / project
Book a Strategy Call
Simple Process

How It Works

Four steps. No back-and-forth. You get what you need, fast.

1
💬

Tell Me What You Need

Send a message. Describe your project, question, or idea. One line is enough.

10 min
2
📋

Get a Fixed Quote

Price + timeline in writing. No hourly billing. No surprises. You approve, then I start.

24 hrs
3

Pay & Build

UPI or card. I build in sprints. You get progress updates. Test it as it comes together.

3-14 days
4
🚀

Launch & Support

Deployed, documented, and live. You get full ownership. Support included.

30-90 days

Ready to Build?

Get the book, or hire me for a custom project. Either way, you end up with a working AI agent.

Get the BookStart a Project
Get in Touch

Let's Talk

Have a question? Want a custom quote? I reply within 24 hours.

Reach Out

For the book, hit "Unlock" above.
For custom work, send a message and I will get back to you.