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.
Unlock all 15 chapters with a one-time purchase.
After payment, you will receive an access code via email.
Already purchased? Enter your code:
Invalid code. Check your email.
Chapter 1
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.
sk-) — you will not see it again.env fileNew accounts get $5 free credit — enough for this entire book.
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.
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 calledpython.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.
Click "Install Now"
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.
By the end of this chapter you will have a runnable skeleton for a personal AI assistant that:
You won’t see the full‑blown functionality yet, but the project layout will be ready for the next three chapters.
| 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. |
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.
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.
.env# Save as:.env
OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
⚠️ Warning: Never commit your
.envfile 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
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'
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.'
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
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."
}
/chat. You now have a clean, testable code base that any junior developer can run locally and extend.
| 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. |
| 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. |
assistant.py handles LLM logic, tools/ holds side‑effects, and main.py is the HTTP façade. tools/notes.py with a function that returns a static note. Register it in tools/__init__.py under the key "notes". assistant.py to mention the new "notes" tool. “Create a note about my latest project”. 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!
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:
You’ll see how enterprises structure such systems, which will make the next chapters (adding advanced skills, scaling, monitoring) much easier.
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.
# 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.
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.
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".
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/"
}
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.
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.
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.
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.
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"
}
}
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."
}
}
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`
| 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.
| 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. |
openai.error.AuthenticationError – double‑check OPENAI_API_KEY. smtplib.SMTPAuthenticationError – verify email credentials and that “Allow less secure apps” is enabled (or use an app password). googleapiclient.errors.HttpError 403 – ensure the service account has Calendar scope and the calendar is shared with the account. requests.exceptions.ReadTimeout – increase the timeout in WebSearchTool or check your internet connection. python -c "from tools.crm import CRMTool; CRMTool()" once to create the DB, or delete crm.db and let the code recreate it./docs). You now possess the foundation for a real‑world AI assistant that can be expanded indefinitely.
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. CRMTool or create a new AuditTool that writes every request/response pair to a separate SQLite table. 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! 🎉
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:
S2) into a concrete date‑time..ics attachment).Getting this right dramatically reduces friction in the hiring pipeline and showcases the power of a fully‑automated, end‑to‑end assistant.
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!
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.
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.
We’ll send the email via Gmail API (same credentials as Calendar). The email contains:
.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.
schedule_meeting.pyNow 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.
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.jsonout of version control (.gitignore) to protect your client secret.
All of this runs from a single Python script with no manual copy‑pasting.
| 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). |
HttpError 403: Insufficient Permission
Solution: Re‑run the script after deleting token.json. The OAuth consent screen will ask for the new scopes.
AttributeError: module 'datetime' has no attribute 'fromisoformat' (Python < 3.7)
Solution: Upgrade to Python 3.7+ or use dateutil.parser.isoparse.
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.
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.
.ics file – it guarantees cross‑platform calendar compatibility.✅ Verify:) are invaluable during development and debugging.S5) for a Tuesday 2 PM meeting in slot_parser.py. <img> tag with a hosted image URL). timeZone to "America/New_York" and observe the shift in the calendar UI. Happy coding! 🎉
Chapter 2
(.venv) in your prompt)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)
sk-) — you will not see it again.env fileNew 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.
In this chapter you will:
venv module. 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.
| 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.
| 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
env_a virtual environment – isolated Python 3.11 with numpy==1.21.6. 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.
| 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. |
ImportError: No module named'some_pkg'. !pip install some_pkg==1.2.3
venv activation script not foundsource: No such file or directory. env_a. List files with ls env_a. If the folder is missing, recreate it (python -m venv env_a).uv command not recognizeduv: command not found. uv (see below) and ensure ~/.local/bin (or the appropriate location) is on your PATH.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.
python
import sys, platform
print("Python:", sys.version)
print("OS :", platform.system())
Verify the output matches the runtime shown in the UI.
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.
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.
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.
By the end of this chapter you will have a complete, version‑controlled Python project that:
venv). requirements.txt file. ✅ 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.
# From your terminal (any OS)
mkdir my_data_project
cd my_data_project
git init
💡 Initialising early gives you a clean history from day one.
# macOS / Linux
python3 -m venv.venv
# Windows (PowerShell)
python -m venv.venv
# macOS / Linux
source.venv/bin/activate
# Windows (PowerShell).\.venv\Scripts\Activate.ps1
You should now see (.venv) prefixed to your prompt.
pip install pandas==2.2.1 rich==13.7.0
pip freeze > requirements.txt
requirements.txt now contains a pinned list of all packages required to run the project.
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.
git add.
git commit -m "Initial project skeleton with venv and demo script"
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.
| 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.
python -m venv.venv && source.venv/bin/activate && pip install -r requirements.txt, and run with python demo.py. requirements.txt). | 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. |
| 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. |
requirements.txt guarantees reproducibility. demo.py) is a reliable sanity check that the environment works.weather_analysis. requests==2.31.0 and matplotlib==3.8.2. fetch_plot.py that: matplotlib. 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! 🚀
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.
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.
A tiny data‑analysis project that:
uv‑managed virtual environment. pandas and jupyter inside that environment. requirements.txt and a uv.lock file for reproducibility. analysis.py) that loads a CSV, prints the first rows, and saves a summary. 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.
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
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.
# Add pandas and jupyter as runtime dependencies
uv add pandas jupyter
uv will:
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.
# 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"
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
requirements.txtEven 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.
explore.ipynb)Tip: VS Code will automatically detect the
.ipynbfile 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.
uv (step 1). 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
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
uv. uv.lock) guaranteeing identical builds. 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.
| 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. |
| 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. |
uv replaces venv, pip, and pip‑freeze with a single, fast tool. uv.lock under version control; generate requirements.txt only when needed for external tools. experiment. uv add matplotlib. analysis.py to plot a histogram of the score column and save it as score_hist.png. 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! 🚀
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:
uv.lock) that pins every transitive dependency. 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.
A small data‑science utility called sales_insight.py that:
All dependencies will be managed with UV, and the project will be ready for version control.
Open a terminal inside your project folder (e.g., ~/projects/sales_insight) and run:
uv init
💡 Tip –
uv initcreates a minimalpyproject.toml, auv.lockplaceholder, and a hidden.venvdirectory (the virtual environment).
✅ Verify: You should see a new pyproject.toml file and a .venv/ folder.
uv add pandas scikit-learn
UV will:
uv.lock. pyproject.toml under [project] → dependencies.✅ Verify: Open pyproject.toml; you’ll see something like:
[project]
dependencies = [
"pandas",
"scikit-learn",
]
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.
Create sales_insight.py (full file shown below). It will:
sales.csv (you’ll create a tiny sample later). Revenue from Advertising. # 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.
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.
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
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"
uv sync, and you’re good to go.| 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. |
| 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. |
uv.lock) is the single source of truth for reproducibility. uv add for new packages and uv sync to materialise the environment. uv run (or activate the .venv) to guarantee the correct interpreter and packages are used. matplotlib – to visualise the data: bash
uv add matplotlib
uv sync
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() ```
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!
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.
A minimal, ready‑to‑run OpenAI client that:
.env file. uv.lock. 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.
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)
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"
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.
.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.
.gitignore# Save as:.gitignore
# Python artifacts
__pycache__/
*.py[cod]
*.pyo
*.pyd
*.egg-info/
dist/
build/
# Virtual environment.venv/
venv/
# Secrets.env
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}")
🤖 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.
openai_demo/
├─.gitignore
├─.env.example
├─ main.py
├─ pyproject.toml
└─ uv.lock ← generated by `uv lock`
uv.lock) guaranteeing reproducible installs. .env and .gitignore. All of this is ready for version control, CI pipelines, or deployment to a serverless platform.
| 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) |
ImportError: No module named 'dotenv'
Run uv sync again; ensure python-dotenv is listed under [dependency-groups].
openai.error.AuthenticationError
.env matches the one in your OpenAI dashboard. Ensure there are no stray spaces or newline characters.
uv lock hangs or fails
Upgrade uv (uv self update) to the latest stable version.
Script runs but returns an empty string
max_retries=3 in the ChatCompletion.create call. uv.lock) are the single source of truth for dependencies. uv. ✅ Verify:) before moving on; it saves debugging time later."gpt-3.5-turbo" to "gpt-4" (if you have access) and observe the difference. messages to include a system prompt that sets a tone, e.g., {"role": "system", "content": "You are a friendly tutor."}. 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
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.
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).
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
adhikk-booksk-) — you will NOT see it againPaste 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.
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.
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.
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 are sequences of characters used to represent text. In Python, strings are enclosed in quotes.
greeting = "Hello, World!"
print(greeting)
Output:
Hello, World!
Lists are ordered collections of values. In Python, lists are enclosed in square brackets.
fruits = ["Apple", "Banana", "Cherry"]
print(fruits[0])
Output:
Apple
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 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 can be sliced to extract a subset of elements.
fruits = ["Apple", "Banana", "Cherry"]
print(fruits[1:3]) # ["Banana", "Cherry"]
Lists can be iterated over using a for loop.
fruits = ["Apple", "Banana", "Cherry"]
for fruit in fruits:
print(fruit)
Output:
Apple
Banana
Cherry
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 can be sliced to extract a subset of elements.
fruits = ["Apple", "Banana", "Cherry"]
print(fruits[1:3]) # ["Banana", "Cherry"]
Lists can be iterated over using a for loop.
fruits = ["Apple", "Banana", "Cherry"]
for fruit in fruits:
print(fruit)
Output:
Apple
Banana
Cherry
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 can be sliced to extract a subset of elements.
fruits = ["Apple", "Banana", "Cherry"]
print(fruits[1:3]) # ["Banana", "Cherry"]
Lists can be iterated over using a for loop.
fruits = ["Apple", "Banana", "Cherry"]
for fruit in fruits:
print(fruit)
Output:
Apple
Banana
Cherry
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)
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.
Try cleaning a dataset using the clean_data function. Experiment with different cleaning techniques and see how they affect the output.
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.
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.
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.
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.
F-strings are commonly used for formatting strings in Python. They are particularly useful when you need to embed expressions inside a string.
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.
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.
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:
strip_string.pycapitalize_title.pyreplace_words.pyYou've built three functions:
strip_string(): removes leading and trailing whitespace from a string.capitalize_title(): capitalizes the first letter of a string.replace_words(): replaces a word with a new word in a string.strip() instead of lstrip() or rstrip()).strip_string() function doesn't work as expected, check if the input string contains only whitespace characters.capitalize_title() function doesn't work as expected, check if the input string is empty or contains only whitespace characters.strip() method to remove leading and trailing whitespace from a string.capitalize() method to capitalize the first letter of a string.replace() method to replace a word with a new word in a string.strip_string() function to remove the whitespace.capitalize_title() function.replace_words() function.Continue to the next chapter to keep building.
Chapter 4
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.
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.
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.
Let’s look at how we translate a simple task into something a computer can execute.
"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.
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.
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").
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.
range() FunctionIn 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."
Let’s build the "1 to 10" example properly. We will create a file, write the code, and run it.
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.
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)
In your terminal, navigate to the folder where you saved first_script.py and run:
python first_script.py
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.
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)
start.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).
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".
You might be thinking: "This is just printing numbers. How does this help me build AI apps?"
Here is the connection:
for loop to send each review to the AI model.range(1, 10) instead of range(1, 11)), your AI app will fail silently.The syntax is simple. The power is in the application.
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}")
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.
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).
:# ❌ 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.
# ❌ 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.
range(1, 10) when you want 10As discussed, range stops before the end number. Always add 1 to your desired end number if you want to include it.
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 calledpython.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 ().
range works internally. You just need to know how to call it.range(start, stop) is exclusive. range(1, 11) gives you 1 to 10.first_script.py to print numbers from 5 to 15. What should the range() call look like?range(start, stop, step)).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.
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.
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.
You asked: "Which language do almost all AI research papers ship their code in?"
Direct Answer: Python.
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.
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 |
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.
You do not need to install Python, Jupyter, or any libraries on your local machine right now. We will use Google Colab (Colaboratory).
Chapter2_AI_Environment.✅ Verify: You should see a blank notebook with a cell that says # Welcome to Colab.
Let’s write some code to ensure our environment is working.
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.
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 (likepip 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.
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.
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).
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.orgis 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.
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'].
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
!pip install.! in Colab:pip install requests!pip install requestsWhy: Without !, Python tries to run pip as a Python command, which fails.
Hardcoding API Keys:
API_KEY = "sk-123456" in your code.Why: If you share your code, your key is exposed. In the next chapter, we will show you how to use environment variables securely.
Ignoring Status Codes:
if response.status_code == 200.| 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. |
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)."max_tokens": 100 to the payload. Run it again. Does the response include this field?https://httpbin.org/get (instead of /post). Run it. What happens? (Hint: The method is wrong).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.
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.
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.
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.
Before writing code, understand what happens when you call an API.
An API call is an HTTP Request. It has four main parts:
GET: Retrieve data (e.g., "Give me the weather for New York").POST: Send data to the server (e.g., "Create a new user").PUT: Update existing data.DELETE: Remove data.https://api.openweathermap.org/data/2.5/weather).Authorization: Bearer <token>, Content-Type: application/json).POST/PUT requests).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.
We will use OpenWeatherMap, a free API for weather data.
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.
# 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.
In production, APIs fail. Networks drop, servers go down, or you might make a typo in a parameter. Your code must handle these gracefully.
# 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.
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.
# 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.
In real projects, you will interact with multiple endpoints of the same API. Instead of repeating URL construction and header setup, create a class.
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.
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
None.requests.Session() to improve performance by reusing TCP connections.API_KEY = "abc123"import os; API_KEY = os.environ.get("API_KEY")response.json() will always work.response.status_code or use response.raise_for_status().requests.get(url) can hang forever if the server is slow.timeout=10 (or similar).data= for JSON:requests.post(url, data=json.dumps(payload))requests.post(url, json=payload)| 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). |
requests.Session() for better performance.get_air_quality(city) to the WeatherClient class. The endpoint is air_pollution._get method to retry the request 3 times if it fails with a 500 error.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()
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!
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.
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.
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.
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.
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.
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.
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)
File > Open Folder.ai-agent-fundamentals folder..venv folder. If not, press Ctrl+Shift+P (or Cmd+Shift+P on Mac) and type:
Python: Select Interpreter.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').
notebooks folder.New File.01_variables.ipynb.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).
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
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.
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.
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.
python and python3Symptom: Confusing errors on Mac/Linux.
Fix: Be consistent. If you created the venv with python3, use python3 for activation and kernel installation.
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
Cause: Security restrictions.
Fix: Run chmod +x.venv/bin/activate and then try activating again.
Cause: Kernel not registered.
Fix: Run python -m ipykernel install --user --name myenv --display-name "Python (myenv)" again while the venv is active.
requirements.txt is your friend. It documents your dependencies and makes setup easy for others.Shift + Enter runs the current cell and moves to the next. Use it to iterate quickly.02_data_types.ipynb.python
name = "Priya" # String
age = 25 # Integer
height = 5.6 # Float
is_student = True # Boolean
skills = ["Python", "AI", "Data Science"] # Listtype():
python
print(f"{name} is a {type(name)}")
print(f"{age} is a {type(age)}")
#. and so onage 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.
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.
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.
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.
# starts a comment.# on that line is ignored by the Python interpreter.# 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
A variable is a named container for a value. You create a variable using the = (assignment) operator.
variable_name = value
_).Temperature and temperature are different).if, else, for, etc.).# 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.
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. |
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.
Strings are immutable (cannot be changed after creation). However, Python provides methods that return new strings.
| 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 |
# 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().
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.
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
# 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.
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.
Create a folder named chapter4_data_types and add the following files:
comments_demo.pyvariables_demo.pytypes_demo.pystring_methods_demo.pyprompt_validator.pyprompt_cleaner.pyYou have built the foundation of data handling for LLM applications:
This is the first step in building a robust AI agent. Without clean data, your LLM will produce inconsistent or incorrect results.
Forgetting Indentation:
python
if True:
print("Hello") # ❌ IndentationError
Fix: Always indent the code block under if, elif, else, for, while, def, class.
Using = instead of == in Conditions:
python
if x = 5: # ❌ SyntaxError
Fix: Use == for comparison, = for assignment.
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:].
Ignoring Whitespace:
python
if user_input == "hello": # ❌ Fails if user_input is " hello "
Fix: Always use .strip() before comparing strings.
| 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. |
user_prompt, not x)..strip(), .lower(), .split(), and .join().if/elif/else) allows you to validate and process data.prompt_cleaner.py:If it doesn't, return an error: "Prompt must contain at least one letter."
Create a New Function:
count_words(prompt: str) -> int that returns the number of words in the prompt.Use .split() and len().
Challenge:
clean_prompt to remove all punctuation marks (e.g., !, ?, ,, .) from the prompt.Hint: Use a loop and str.replace() or a list of punctuation marks.
Debugging Exercise:
prompt_validator.py (e.g., change if not prompt.strip(): to if prompt.strip():).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.
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.
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).
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.
# 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
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.
You have a list of skills: Excel, Python, Communication, Marketing. You want to store them in one variable and process them later.
A list is an ordered, mutable collection of items. It is defined using square brackets []. Items are separated by commas.
# 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)
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".
for LoopsYou want to process every item in the list. For example, you want to convert all skill names to lowercase for database storage.
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.
# 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}")
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.
Lists are mutable, meaning you can change them after creation. Here are the most common methods you will use in AI agent development.
# 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.")
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.
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.")
--- 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.
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.
f prefix:
```python
# Wrong
print("Hello {name}") # Prints literal {name}# Correct print(f"Hello {name}") # Prints Hello Ishant ```
# Safe: Create a new list new_list = [item for item in my_list if item != "bad"] ```
== 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 ```
python
skills = ["A", "B"]
print(skills[2]) # IndexError: list index out of range| 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. |
f"." with {variable} placeholders.[] to create them.list[0] is the first item, list[-1] is the last.for item in list: is the most common way to process list items.append() adds to the end, remove() deletes the first occurrence, index() finds the position, len() gets the count."I know {language}." for each language in the list.append().remove().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".
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.
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.
# 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.
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.
# 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.
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?
| 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. |
# 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.
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.
# 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.
.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.
# 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.
basic_dict.py - Basic dictionary creation and access.nested_dict.py - Accessing lists and dicts inside dicts.ai_message_format.py - Understanding the standard AI message structure.message_builder.py - Functions to dynamically build conversation history.safe_access.py - Using .get() to avoid crashes.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.
profile[0] (Dictionaries are not ordered by index in the same way lists are).✅ profile["name"]
Forgetting the Colon:
{"name" "Priya"}✅ {"name": "Priya"}
Mixing Up Lists and Dicts in AI Format:
{"role": "user", "content": "Hi"} (This is just one message, not a conversation).✅ [{"role": "user", "content": "Hi"}] (This is a list containing one message).
Hardcoding Messages:
add_user_message() to keep your code clean and scalable.| 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: value pairs.role and content keys..get() for safe access to avoid crashes.title, author, year, genres (list).add_review(messages, review_text) that appends a user message with the review text.Next Chapter: We will connect this structured data to an actual AI API and send our first request.
Chapter 5
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.
pwd to check)sk-) — you will not see it again.env fileNew accounts get $5 free credit — enough for this entire book.
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.
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 calledpython.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.
Click "Install Now"
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.
By the end of this chapter you will have a single, runnable script that:
greet, summarise_profile, add_numbers). final_score). level). All of this will run out‑of‑the‑box after installing the dependencies listed in requirements.txt.
# 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.
# 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).
# 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.
# 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.
| 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
pandasandmatplotlibwithout specifying a version if you prefer the latest releases.
final_score) and categorical labeling (level) using apply + lambda. All of this is ready to be extended—add more columns, visualise with matplotlib, or plug into an AI‑agent pipeline.
| 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. |
ModuleNotFoundError: No module named 'pandas' Run pip install -r requirements.txt inside your activated virtual environment.
SettingWithCopyWarning when adding a column after filtering
Use df = df.copy() after the filter, or assign with .loc[:, "col"] =..
CSV file is empty
Verify that the filter condition actually matches rows (df["final_score"] >= 75).
Unexpected data types (e.g., strings instead of numbers)
int or float). Use pd.to_numeric if needed. apply is a concise way to create derived columns. Master these fundamentals, and you’ll be ready to build more sophisticated AI agents, data pipelines, and production services.
students with a new column attendance (percentage). final_score to also factor in attendance (e.g., + 0.2 * attendance). "expert" if final_score >= 95. 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! 🚀
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.
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.
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) |
# 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
.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.
requirements.txt# Save as: requirements.txt
openai>=1.0.0
python-dotenv>=1.0.0
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
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
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"
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)
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}")
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.
| 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. |
| 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. |
| 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. |
ask_llm) gives you the freedom to swap providers without touching the rest of your code.max_tokens caps spend and forces the model to be concise.python-dotenv) for API keys..env file in the project root:dotenv
OPENAI_API_KEY=sk-.
GEMINI_API_KEY=AIza.
bash
pip install openai google-generativeai python-dotenv
bash
python demo.py
functools.lru_cache) to avoid duplicate calls. 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! 🎉
Continue to the next chapter to keep building.
Chapter 6
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).
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.
pwd to check)sk-) — you will not see it again.env fileNew 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.
In this chapter you will create a minimal, production‑ready chat agent that:
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").
Counts tokens with the tiktoken library and truncates older messages when needed.
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.
| 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. |
"My name is Ishant." → 5 tokens. 💡 Tip: Use the tiktoken library to count tokens precisely; this avoids “Token limit exceeded” errors at runtime.
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.
We’ll write two files:
utils.py – token counting & truncation helpers. 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.
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.
MODEL_NAME for any OpenAI‑compatible model, adjust MAX_TOKENS, and you have a ready‑to‑go chat agent.| 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). |
| 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. |
num_tokens_from_messages, trim_messages) make token‑aware chat agents trivial to build. temperature=0 for deterministic answers, temperature=1.2 for creative responses. Observe the difference. Happy hacking! 🚀
Large Language Models (LLMs) are brilliant at generating text, but on their own they lack:
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.
In this chapter you will create a minimal AI agent that can:
get_current_date() – returns today’s date. search_policy(query) – looks up a short HR policy JSON file. All code runs locally with the openai Python SDK – no additional services required.
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
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-."
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.
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.
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.
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.'
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).
| 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. |
You now have a complete, runnable AI agent that:
reset). function_call. All of this is achieved with just 150 lines of Python and the OpenAI SDK – no external orchestration frameworks required.
| 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. |
| 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.” |
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.
tools.py. get_function_definitions(). Extend _dispatch_function_call to call it.
Persist memory to disk so the conversation survives a program restart.
self.memory.history to a JSON file after each turn. Load it back in ConversationMemory.__init__ if the file exists.
Replace the static JSON policy file with a simple vector store (e.g., faiss + sentence‑transformers).
search_policy receives a query, embed it, retrieve the most similar policy snippet, and return that. Happy building! 🚀
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:
In this chapter you’ll build a minimal, fully‑runnable AI agent that can:
💡 Tip – Even though we’ll mock Google Calendar, the same pattern works with the real API (just replace the mock with the official client).
A single‑file Python program (agent.py) that:
find_free_slot and create_event function. me and Priya). 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) |
A simple dictionary stores events per user.
Each event is a tuple: (start_datetime, end_datetime, title).
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
The script:
stdin. 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
🤖 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_endif 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.
| 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.
| 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. |
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.
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.
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.
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.
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")
]
python
query = "Find a suitable 1 hour slot tomorrow for me, Priya, and Alex and schedule a meeting titled 'Design Review'."
Run the script and observe how the agent now picks a later slot (e.g., 11 AM‑12 PM).
Experiment with different durations (duration_minutes) and dates to see the loop adapt.
Happy building! 🎉
Continue to the next chapter to keep building.
Chapter 7
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.
pwd to check)sk-) — you will not see it again.env fileNew 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:
Understanding this workflow lets you build assistants that go beyond chat—think travel planners, code generators, or automated help desks.
A Travel Planner Agent that can:
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.
| 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. |
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.
# 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.
# 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}]
# 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
function_call, we invoke the corresponding Python function. function message, letting the model see the observation. ✅ 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.
# 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).
| 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.
| 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. |
| 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. |
search_restaurants(city) that returns a random restaurant list. TOOL_DEFINITIONS in agent.py with the new function spec. 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! 🚀
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, …).
A single‑page Streamlit app that lets a user:
.txt file). All the heavy lifting (LLM, function calling, tool implementation) lives in pure Python—no extra research required.
┌─────────────────────┐
│ 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)
| 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. |
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
**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.
| 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:
| 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. |
| 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. |
extract_email(resume_text) that returns the first email address found. FUNCTIONS in agent.py with the new schema. Bonus: Replace gpt-3.5-turbo-1106 with gpt-4o-mini for cheaper, faster responses (adjust the model name in agent.py).
Happy hacking! 🚀
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.
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:
gpt-3.5-turbo model. All secrets (your OpenAI API key) are stored securely in Render’s environment variables, never hard‑coded.
mkdir gpt‑proxy‑render
cd gpt‑proxy‑render
git init
# 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.
# 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.
# 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.
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).
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.
gpt-proxy-render). render.yaml and pre‑fill the form. OPENAI_API_KEY Render will build the Docker image, start a container, and give you a live URL like https://gpt-proxy.onrender.com.
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.
| 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
https://<your‑app>.onrender.com/chat) reachable from anywhere. 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.
| 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. |
requirements.txt or missing Dockerfile. Ensure the base image (python:3.12-slim) can resolve pip packages (no network restrictions).
Endpoint returns 502
OPENAI_API_KEY is correct and not expired. Look at Service Logs for OpenAI error messages (Rate limit exceeded, Invalid API key).
Slow responses (>5 s)
Enable OpenAI’s streaming if you need incremental responses (outside this chapter’s scope).
CORS errors when calling from a browser
python
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # tighten in production
allow_methods=["*"],
allow_headers=["*"],
)
You now have a complete, production‑ready pipeline from code to live service.
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").
Implement basic rate limiting (e.g., 5 requests per minute per IP) using the slowapi package.
/chat via JavaScript and host it on the same Render service (static files). Happy hacking! 🚀
Continue to the next chapter to keep building.
Chapter 8
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.
pwd to check)sk-) — you will not see it again.env fileNew accounts get $5 free credit — enough for this entire book.
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:
.env file. A tiny command‑line utility called ai_assistant.py that:
.env file. ask_llm() that accepts a user prompt and an optional system prompt. 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.
ai_project/
├─.env.example
├─ requirements.txt
├─ llm_helper.py
└─ ai_assistant.py
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.
.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
cp.env.example.env ✅ Verify: cat.env should show the key you just added (but keep this file private).
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?
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).
| 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. |
You now have a reusable, secure, and extensible Python utility that:
.env file. ask_llm). From here you can:
| 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).
| 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. |
.env file and python-dotenv. ask_llm) makes your code DRY and future‑proof for other LLM providers."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."
Add a conversation history: modify llm_helper.py to accept a history: List[Dict] argument and prepend it to messages.
Swap models: try "gpt-4" (if you have access) and compare answer quality.
Build a tiny web UI with Flask that calls ask_llm behind a /chat endpoint.
Happy coding! 🎉
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:
python main.py.A Resume Q&A Agent that:
resume.txt. history) and sends the full history to OpenAI on every turn. search_resume) that the model can call to fetch the most relevant résumé snippet. 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
resume.txt (sample résumé)Save as:
resume.txt```text Maya Rao Junior AI DeveloperSkills: - 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 ```
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é.
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
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()
📝 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.
| 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. |
history you give the model a “memory”. search_resume, keeping the user experience seamless. tools.py and adding its schema to function_schema.| 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. |
| 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). |
calculate_years_experience(start_year: int) -> str. tools.py. function_schema. Update handle_response to route the call.
Swap the résumé – replace resume.txt with your own CV and ask domain‑specific questions.
Implement a simple truncation – modify main.py to keep only the last 8 messages in history before each API call.
Happy coding! 🎉
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:
A stand‑alone CLI tool (resume_qa.py) that:
You can later replace the hard‑coded résumé with a file read or a database query – the core logic stays the same.
# Install the official OpenAI Python client
pip install --upgrade openai
# 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)
🔹 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).
| 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 |
You now have a reliable résumé‑aware chatbot that:
You can extend this pattern to:
| 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 warning – gpt-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).
| 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.
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.” resume.txt file and modify the script to read it at runtime. Happy coding! 🎉
Continue to the next chapter to keep building.
Chapter 9
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.
pwd to check)sk-) — you will not see it again.env fileNew accounts get $5 free credit — enough for this entire book.
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.
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:
A minimal, zero‑research Python project that:
.env file. run_agent function that:gpt‑4o-mini (or any model you configure). 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").
Returns the final text.
You’ll end the chapter with a runnable script that prints a streamed answer to the console.
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
| 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. |
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.
.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
.envfile 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).
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.
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.
| 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.
| 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). |
uv not installed – Install it with pip install uv or follow the official docs. 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}")
.env files protect secrets and make local testing painless. agent.py to recognize a special command like "/time" and return the current system time before answering. self.model to gpt-4o and observe any quality differences. StreamingAgent instances with asyncio.gather to answer two prompts concurrently. Happy coding! 🎉
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:
You’ll finish with a Study Coach that can answer course‑related questions and look up the current fee from a simulated database.
A runnable Python project that:
@tool decorator to expose functions to the LLM. Agent class that:get_course_info. All code is self‑contained—just install the dependencies and run python main.py.
study_coach/
├─ tools.py # Decorator & registry
├─ agent.py # Agent implementation
└─ main.py # Demo script
pip install openai==1.30.0 tqdm
💡 Tip: Keep your OpenAI API key in an environment variable
OPENAI_API_KEYfor safety.
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.
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.
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.
| 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. |
@tool decorator – turn any pure Python function into a callable LLM tool with zero boilerplate. All of this is achieved with under 150 lines of code and no external orchestration.
| 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. |
| 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. |
COURSE_CATALOG (e.g., "react": {"price": 3000, "duration_hours": 10, "level": "beginner"}) and ask the coach about it. courses.db with a courses table. get_course_info to execute a SQL query instead of a dict lookup. 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! 🎉
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:
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:
By the end you’ll have a reusable framework you can drop into any Python project and instantly start debugging AI‑driven workflows.
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.
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
Agentinstance sees the same set of tools.
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.
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
runloop 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.
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.
# 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
MotivationAgentinheritsAgent, it can be registered as a tool just like any function.
# 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.
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"],
)
# 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
# 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"],
)
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.
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.
| 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. |
| 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. |
llm_msg["function_call"] inside the loop to see what the model sent. 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.
Agent.run in a thin function to let one LLM orchestrate another. weather_tools.py that returns a fake temperature, register it with @tool, and add it to a new WeatherAgent. PlannerAgent → SchedulerAgent → NotifierAgent. Verify the trace shows three nested tool calls. run_demo.py to write each JSON trace to logs/<timestamp>.json. Load one later and pretty‑print it. Happy hacking! 🎉
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:
All three specialists are exposed as tools to the manager, keeping the user interaction clean and consistent.
A runnable Python project that:
planner, translator, motivator) as LangChain tools. pip install langchain openai
⚠️ You need an OpenAI API key. Set it as an environment variable:
export OPENAI_API_KEY="sk-."
| 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. |
Uses LangChain’s AgentExecutor with a custom prompt that can invoke the three tools.
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.
# 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
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
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
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
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()
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.
| 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). |
No output from Translator
Check that the OpenAI request succeeded (no rate‑limit error).
Tip: Add print(response) inside TranslatorTool._run to debug.
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.
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.
BaseTool to expose it to a higher‑level manager. summarizer) only requires a new tool class and a line in the tools list—no changes to the manager’s core code.summarizer_agent.py that shortens a paragraph. study_manager.py. Update the manager prompt to allow a summarize action.
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.
Switch to Async
Implement _arun for each tool and use await manager_executor.ainvoke(query) in an async main().
Happy building! 🚀
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:
CheerUpTool). ResourceFetcher and QnAResponder. 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.
A stand‑alone CLI application called study_manager.py that:
CheerUpTool, ResourceFetcher, QnAResponder). StudyAgent that can orchestrate calls to any of those tools. 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:.
# 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.
# 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.
# 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.
# 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.
| 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.
You now have a complete, runnable AI assistant that:
QnAResponder. 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.
| 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.
“No module named ‘resource_fetcher’”
Check that resource_fetcher.py exists in the same directory and that the filename matches the import case.
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.
OpenAI API errors in QnAResponder
Make sure you have set the OPENAI_API_KEY environment variable.
bash
export OPENAI_API_KEY="sk-."
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.
_select_tool) can be swapped for a more sophisticated LLM‑driven router later. 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.
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”.
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.
Dockerize the whole application. Write a Dockerfile that copies the source, installs dependencies, and sets ENTRYPOINT ["python", "study_manager.py"].
Write unit tests for StudyAgent._select_tool using pytest. Verify that each keyword classifies correctly.
Happy coding! 🎉
Continue to the next chapter to keep building.
Chapter 10
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.
pwd to check)sk-) — you will not see it again.env fileNew accounts get $5 free credit — enough for this entire book.
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:
gpt‑3.5‑turbo). All of this is built without any hidden steps—just copy the files, run the commands, and you have a functional AI assistant.
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.
# 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.
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.
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).
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.
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.
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.
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.
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.
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.txtopenai==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()
| 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. |
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.
| 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. |
python -c "import plyer; print(plyer.notification.notify.__doc__)" – if it raises an ImportError, reinstall plyer. On macOS, ensure the script has permission to post notifications (System Settings → Notifications).
OpenAI returns a 401 error
Double‑check the API key, and make sure you haven’t exceeded the free‑tier quota.
SerpAPI returns “quota exceeded”
Verify your account’s remaining credits; you can also lower num_results to 1.
SQLite DB is empty after a session
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.
PDF parsing returns an empty string
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”.
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.
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! 🎉
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:
chat function that any front‑end can call. ⚠️ 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.
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.
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.
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:
Running on local URL: http://127.0.0.1:7860
Running on public URL: https://xxxxxx.gradio.live
A browser window (or you can copy the public URL) showing a chat UI with:
Title “Chat with Ishant”
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.
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.
| 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.
| 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. |
“Invalid request error – model not found” Check your OpenAI dashboard for the exact model name you have access to.
“Rate limit reached”
Add a time.sleep(1) between calls or upgrade your quota.
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.
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.
agent.py vs. app.py) makes the codebase maintainable and easy to extend (e.g., add authentication, logging, or a different front‑end). 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.
Enable streaming responses – replace openai.ChatCompletion.create with openai.ChatCompletion.create(., stream=True) and feed tokens to Gradio in real time.
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! 🎉
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:
A minimal, end‑to‑end Python project that:
.env file. Notifier class. You’ll be able to run the script, type a few example messages, and see a real push notification appear on your phone.
pip install openai python-dotenv requests
💡 Tip: Use a virtual environment (
python -m venv.venv) to keep dependencies isolated.
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).
# 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.
# 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.
# 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.
your-project/
│
├─.env # ← your PushOver & OpenAI credentials
├─ notifier.py # ← PushOver wrapper
├─ agent.py # ← LLM prompt & conversation manager
└─ main.py # ← CLI entry point
| 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.
| 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. |
.env. bash
python -c "from notifier import Notifier; Notifier().send('Test','Hello')" Check Push Over dashboard → “Messages” for any error codes.
LLM response cannot be parsed
content before json.loads to see what the model returned. If the model adds extra text, tighten the system prompt or set response_format (OpenAI API v1.0+ supports JSON schema).
OpenAI authentication error
OPENAI_API_KEY is correct and has enough quota. openai api models.list from the terminal to confirm connectivity.Assistant class lets the model maintain context across multiple turns. Notifier with a simple SMTP email sender and watch the same JSON drive an email instead of a phone ping. assistant.history to a JSON file after each turn and reload it on startup, so the agent remembers past conversations across runs.Happy hacking! 🚀
Modern applications rarely live in isolation. They need to talk to other services, fetch data, and push updates. Understanding how to:
is essential for building production‑grade systems that can evolve independently.
A tiny, self‑contained Order Service that:
/order/<order_id> – returns an estimated delivery time. /send_message – accepts a JSON payload and pretends to forward it to a registered device. All code runs locally; no extra research required.
| 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. |
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.
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.
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.
requirements.txtFlask==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.
| 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:
| 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). |
| 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. |
python command, yet scales to production with minimal changes.orders (e.g., "order-003": {"items": ["phone"], "destination": "Chicago"}) and query it with the client. openai.ChatCompletion.create with a local mock function that returns "1 business day" – see how the service behaves offline. registered_devices to a tiny SQLite DB using sqlite3 (bonus exercise). Happy coding! 🚀
Continue to the next chapter to keep building.
Chapter 11
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.
pwd to check)sk-) — you will not see it again.env fileNew accounts get $5 free credit — enough for this entire book.
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.
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 calledpython.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.
Click "Install Now"
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:
All the code is ready‑to‑run; you only need a Pushover account and an OpenAI API key.
| 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.
bash
python -m venv.venv
source.venv/bin/activate # Windows:.venv\Scripts\activate
bash
pip install python-dotenv requests openai
.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.
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.
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.
# 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."
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.
my_notification_project/
│
├─.env # ← your secrets (do NOT commit)
├─ push_notification.py
├─ tools.py
├─ agent_tools.py
├─ agent.py
└─ unknown_questions.log # ← created automatically
send_push). @llm_tool) that registers any function as an LLM‑callable tool. notify_me (real‑time alerts) and record_unknown_question (persistent log). | 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. |
send_push. Anything other than 200 indicates a problem. Verify that the device you registered with Pushover is online and that the app has notification permissions.
Agent never calls a tool
Check that function_call="auto" is set; otherwise the model will never attempt a call.
unknown_questions.log stays empty
push_notification.py, tool registration in tools.py, and business rules in agent.py. This makes the codebase easy to extend.push_notification.py needs to change. Happy coding! 🚀
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:
A version‑3 agent that:
All code runs locally; you only need a free SendGrid account and a verified sender email.
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.
pip install python-dotenv requests gradio
# 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.
# 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.
# 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:
✅ Verify: Interact with the UI and confirm both console outputs appear.
| 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. |
TOOLS. | 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. |
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.
⚠️ 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.
Push notifications not appearing
The push_notification function currently just prints. Replace with a real push service (e.g., Pushover) if needed.
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.
.env + python-dotenv is the simplest pattern. TOOLS dict) makes the agent modular; adding new capabilities never touches the core answering logic. ✅ Verify: markers help you stay disciplined.knowledge.json) and load it on startup. Happy coding! 🚀
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:
A self‑contained Python package that:
gpt‑4o-mini) with a function‑calling schema that decides when to invoke:send_email_lead – builds a beautiful HTML email and sends it via SMTP. push_notification – fires a push notification to your phone (using Pushover). All code lives in three files, no external research required.
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.
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.
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.
| 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.
run_agent) turns a free‑form visitor message into two real‑world actions. | 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. |
SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS. Look at the console output of send_email_lead; it returns the exact error string.
Push notification never shows
PUSHOVER_TOKEN and PUSHOVER_USER are correct. Test the API directly with curl to rule out network issues.
LLM refuses to call a function
If the model still returns plain text, increase temperature to 0 (deterministic) or add a few more examples in the system prompt.
JSON decode error
json.loads call in a try/except and fallback to json.loads(response_text.replace("\n", "")). smtplib with SendGrid or Mailgun. add_to_crm that POSTs to a mock endpoint, then extend FUNCTIONS and the dispatch logic. run_agent to accept a list of visitor messages and process them concurrently with asyncio. Happy hacking! 🎉
Continue to the next chapter to keep building.
Chapter 12
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.
pwd to check)sk-) — you will not see it again.env fileNew accounts get $5 free credit — enough for this entire book.
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:
.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.
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.
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. datetime to UTC..ics FileThe 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.
We’ll:
💡 Tip – Keep the function schema minimal; the model works best when it sees only the fields it truly needs.
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.
google_link.py, ics_generator.py) that handle time‑zone conversion, UTC formatting, and output generation. llm_tool_wrapper.py) that lets any LLM act as a meeting scheduler, automatically choosing the best tool for the user’s context. | 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. |
dates parameter is in YYYYMMDDTHHMMSSZ format. Print the raw URL and compare with the example in the chapter.
.ics file does not appear in Outlook
\r\n. Check that DTSTART/DTEND are UTC (Z suffix).
LLM refuses to call a tool
tools list is passed to client.chat.completions.create. Increase temperature to 0 for deterministic behavior during debugging.
Time‑zone mismatch
_to_utc_iso) to confirm conversion. .ics files guarantee cross‑platform compatibility. smtplib or a transactional‑email API). Update TOOLS and call_tool accordingly. /schedule) that accepts a JSON payload (prompt) and returns either a link or a downloadable .ics file. Happy coding! 🎉
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 tiny Flask web service that:
/slots) that returns those slots as JSON. /book) and generates a downloadable .ics file containing the meeting details. .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.
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
pip install -r requirements.txt
requirements.txt
Flask==2.3.3
pytz==2024.1
💡 Tip –
pytzis used for explicit time‑zone handling. If you’re on Python 3.9+ you could usezoneinfoinstead; the code below works with both.
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.
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.
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".
| 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. |
.ics file works with Google Calendar, Outlook, Apple Calendar, etc. All of this runs locally with only a few lines of code, demonstrating the “keep it simple, stupid” (KISS) principle.
| 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. |
| 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. |
/slots via JavaScript and lets a user click a button to book a slot – no extra backend code needed! Happy coding! 🎉
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.
slot_id → datetime pairs for the next N days. .ics generator – a plain‑text iCalendar file that any calendar app can import. offer_meeting_slots – prints the available slots for the model to show the user. 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.
# 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.
.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.
# 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.
# 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.
| 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.
.ics generator for Outlook/Apple/any iCal client. offer_meeting_slots, book_meeting) that can be registered as function calls in a LangChain/AutoGPT‑style agent.| 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. |
+ 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.count is not larger than the number of possible slots within the date range you supplied. Increase the range or reduce count.offer_meeting_slots, book_meeting) you give the LLM a clean contract to call – no need for complex HTTP servers in the prototype stage.weekdays to {0,1,2,3,4,5} and rerun demo.py. slot_duration_min=45 to get_upcoming_slots and observe the new schedule. .ics file – call book_meeting(., use_google_link=False) from the REPL, save the output to my_meeting.ics, and import it into Outlook. offer_meeting_slots and book_meeting as tool objects and let the LLM decide when to call them.Happy coding! 🎉
Modern AI assistants are no longer just “chat‑bots.” To be professional they must:
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.
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
pip install openai python-dotenv pandas
Create a .env file in the project root with your OpenAI key:
OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
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 seetools loadedwithout errors.
# 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?") PYYou should see the assistant propose slots, push a notification, and finally ask for an email address.
# 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.| 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. |
All of this runs on pure Python with no external SaaS CRM, making it perfect for prototypes, personal assistants, or small consulting businesses.
| 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. |
| 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. |
tools.py and can be unit‑tested independently. classify_intent to detect phrases like “just browsing” and store them with status="cold". sqlite3 module) and observe the performance gain. push_notification with an HTTP POST to your workspace. sklearn’s LogisticRegression. Happy building! 🎉
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:
datetime.now() → real‑time timestamps. surpar → cheap web‑search for “who won FIFA 2026?”‑style queries. smtplib → email notifications for hot leads. By the end you’ll have a fully functional, locally‑run CRM‑assistant that you can extend to any SaaS product.
A minimal yet production‑ready Leads CRM Agent that:
leads.csv. 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
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.
.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.
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'>.
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.
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.
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.)
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.
| 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. |
main.py (or call run_agent from any service). | 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. |
SMTP_USER, SMTP_PASSWORD). Run python -c "import smtplib, os; print(os.getenv('SMTP_SERVER'))" to ensure the server address is correct.
Surpar returns “Invalid API key”
.env. Ensure there are no stray quotes or spaces.
Agent hangs on tool call
surpar_tool.py (timeout=20). Look at the LangChain verbose logs – they show which tool is being invoked.
CSV rows are duplicated
raw_conversation) before append_lead. Lead dataclass) is the contract that guarantees deterministic output. raw in main.py with a different conversation (e.g., a cold inquiry). classify_intent in lead_tool.py to use more keywords or a tiny ML model. calendar_lookup tool that calls Google Calendar API and add it to tools. Happy building! 🎉
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:
hot, warm, cold) gives you a clean, searchable record and lets you trigger downstream actions (e.g., Slack alerts, email notifications) only for the leads that matter.
You will create a small, self‑contained Python module that:
name, email, company, intent, reason) hot, warm, cold) based on the intent All of this will be runnable out‑of‑the‑box—no extra research required.
# 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
# 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."
# 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})"
# 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))
$ 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.
| 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 ….
hot, warm, cold). 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.
| 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. |
| 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. |
Lead), business logic (classify_intent), persistence (log_lead). classify_intent with a fine‑tuned LLM or a vector‑search model for production. classify_intent to recognize a "very hot" scenario (e.g., "budget approved"). Return "hot" but change the reason accordingly. csv_logger.py to also write an leads.xlsx file using openpyxl. process_message directly from a chat completion request. Happy coding! 🚀
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.
A deployable Gradio web app (app.py) that:
/chat) that streams the LLM’s responses. leads.csv. 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.
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').
app.py)The UI shows three panels:
# 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:
offer_slots was called. Expected console output (partial):
Running on local URL: http://127.0.0.1:7860
Running on public URL: https://xxxx.gradio.live
| 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.
| 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). |
| 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.
No response from the LLM
Check: openai.error.RateLimitError in console.
Solution: Upgrade your plan or add exponential back‑off retry logic.
CSV not updating
Check: File permissions on leads.csv.
Solution: chmod 664 leads.csv (Unix) or run the script with admin rights.
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.
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.
fetch_weather(city) that calls a free weather API. tools.py with the function. _function_specs(). _DISPATCH. Prompt the LLM: “What’s the weather in London?” – you should see the new tool fire in the trace panel.
Persist to a real database – replace CSV logic with SQLite (sqlite3 module) and observe the UI changes.
Deploy to a cloud platform (Render, Fly.io, or Railway).
Dockerfile that copies the repo, installs requirements.txt, and runs python app.py. Set environment variables (OPENAI_API_KEY, PUSHOVER_USER, PUSHOVER_TOKEN).
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.
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:
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.
A single‑file Gradio application (app.py) that:
msg, history) and returns a list of dictionaries for Gradio. share=True.pip install gradio langchain openai fpdf
💡 Tip: Keep a
requirements.txt(see below) so you can recreate the environment later.
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:
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.
| 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
| 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. |
| 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. |
Gradio fails to launch (OSError: [Errno 98] Address already in use)
Solution: Change the server_port to an unused number, e.g., 7861.
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).
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.
Images appear broken
Solution: Confirm the images are valid PNG/JPEG files and that the assets/ folder is correctly placed.
share=True turns a local script into a publicly accessible demo instantly. requirements.txt and clear folder layout let anyone clone and run your project without extra research. requests). chat_handler to ask the agent to use the new tool when the user mentions “GitHub”. Hint: Create a github_stats_tool function, wrap it in a Tool, add it to the tools list, and restart the app.
Happy hacking! 🚀
Continue to the next chapter to keep building.
Chapter 13
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.
pwd to check)sk-) — you will not see it again.env fileNew accounts get $5 free credit — enough for this entire book.
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:
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).
A minimal, fully functional personal‑assistant web app that:
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.
| 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.
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
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.
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.
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.
app.py, requirements.txt, README.md). 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.
| 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.
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
.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
.envto.gitignoreto avoid leaking credentials.
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
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.
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.
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.
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.
| 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.
| 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. |
| 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"]. |
tools.py isolates side‑effects; config.py centralises secrets. .env locally and real env vars in production. def fetch_weather(city: str) -> str: in tools.py that returns a dummy weather string. Register it in TOOL_REGISTRY. tool: "fetch_weather" and args: {"city": "<city>"}.” /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! 🚀
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:
Understanding the deployment workflow lets you ship reliable, secure applications that anyone can run with a single click.
app.py) that:requirements.txt that lists every Python dependency. README.md that explains the project and the deployment steps. | 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
app.py, requirements.txt, README.md, and the me/ folder (contains your LinkedIn profile JSON). | Secret name | Value (example) |
|---|---|
OPENAI_API_KEY |
sk-. |
SENDGRID_API_KEY |
SG. |
SENDER_EMAIL |
you@example.com |
RECIPIENT_EMAIL |
you@example.com (optional) |
Visit the Space URL to interact with the live app.
MIT
✅ Verify: The README renders correctly on GitHub and in the Hugging Face Space UI.
---
#### `.env.example` (do **not** commit this file)
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.

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.
linkedin-summary/
├─ app.py
├─ requirements.txt
├─ README.md
├─.env.example
└─ me/
└─ profile.json # (your static LinkedIn data, optional)
You can now share the Space URL with anyone; they can generate a LinkedIn summary instantly.
| 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. |
Settings → Logs → Build – look for pip install errors or missing files.
Runtime Errors
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
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
requirements.txt and your code; you only need to supply the files. app.py plus a correct requirements.txt is enough to run a production‑grade Streamlit app. .env.example with your own OpenAI and SendGrid credentials. 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! 🚀
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:
In this final section you will extend the live booking assistant we deployed on Hugging Face Spaces with:
When you’re done, the same public URL will now keep a conversation context for each user, making the bot feel truly intelligent.
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.
The agent will receive a user_id (generated from the request’s API key) and will load the corresponding memory before processing the prompt.
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.
A small logger will write every request/response pair to a log file. Errors will be caught and returned as friendly JSON messages.
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.
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.
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
uvicorn app:app --host 0.0.0.0 --port 8000
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).
| 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.
| 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. |
No response from the agent
Check: app.log for “Unhandled exception”. Most often it’s a missing OpenAI key or a malformed tool input.
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).
API‑key rejection
Check: The header name is x-api-key (case‑insensitive). Ensure you’re not sending X-API-KEY without the hyphen.
Email never arrives Check: SMTP credentials, and that the “from” address matches the authenticated user. Look at the SMTP server’s logs if possible.
Space crashes on first request Check: The Space’s “Runtime” tab for missing environment variables. Add them under “Secrets & Variables”.
app.py with a DELETE /history endpoint that calls memory.SQLiteChatMemory(user_id).clear(). users table and read it inside the agent’s prompt. psycopg2 calls; observe how concurrency improves under load. slowapi or a simple in‑memory counter to prevent abuse of the public URL. Happy coding! 🎉
Continue to the next chapter to keep building.
Chapter 14
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.
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.
N8N connects your agent to external services (email, calendar, etc.) without writing glue code.
For local setup (free forever):
npm install -g n8n n8n startThen 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.
A single python main.py that starts:
1. A local AI agent (Ollama)
Ollama lets you run AI models on your own computer (no internet needed, no API costs).
Open a new terminal and verify:
ollama --version
Pull a model (this downloads ~4.7 GB, takes 5-10 min):
ollama pull llama3.1:8b
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:3binstead (smaller, ~2 GB).
my-ai-agent/
+-- main.py
+-- agent.py
+-- tools/
| +-- __init__.py
| +-- calendar.py
| +-- web_search.py
| +-- crm.py
+--.env
+-- requirements.txt
mkdir my-ai-agent && cd my-ai-agent
uv venv
uv add streamlit requests python-dotenv
VERIFY: You should see packages installing.
OLLAMA_URL=http://localhost:11434
OLLAMA_MODEL=llama3.1:8b
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)
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})
streamlit run main.py
VERIFY: Browser opens at http://localhost:8501
| 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 |
Add a 4th tool: an email sender using smtplib. When a meeting is scheduled, send a confirmation email.
| 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 |
Continue to the next chapter to keep building.
Appendix
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
adhikk-booksk-) — you will NOT see it againPaste 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.
pwd to check)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 |
| 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) |
| 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 |
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.
| 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 |
Continue to the next chapter to keep building.
Pick what you need. Or take the bundle and save.
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.
No website yet? I build one. Responsive, fast, SEO-ready. Landing page, portfolio, or multi-page business site. Deployed and live.
Complete digital presence. Professional website + AI assistant that converts visitors into leads. Save ₹4,000 vs buying separately.
Connect your tools end-to-end. N8N workflows, API integrations, email automation, CRM pipelines, calendar sync. Save 10+ hours per week.
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.
Four steps. No back-and-forth. You get what you need, fast.
Send a message. Describe your project, question, or idea. One line is enough.
Price + timeline in writing. No hourly billing. No surprises. You approve, then I start.
UPI or card. I build in sprints. You get progress updates. Test it as it comes together.
Deployed, documented, and live. You get full ownership. Support included.
Have a question? Want a custom quote? I reply within 24 hours.
For the book, hit "Unlock" above.
For custom work, send a message and I will get back to you.