Complete Roadmap to Become an Agentic AI Developer
A 14-phase, example-driven path from Python fundamentals to production multi-agent systems. Written for developers who already program. Phase 1 is a full Python reference; every later phase gives you the concepts, tools, and code to move forward.
Overview Map
Fourteen phases, roughly in order. The first four are classic backend skills that every agentic system rests on; phases 5–11 are the AI-specific core; the last three take you to production. Click any card to jump in.
1. Variables
Python variables are names bound to objects. You don't declare a type — a name simply points at a value, and it can be rebound to a value of any type later. There is no let, var, or type keyword.
x = 10 # x points at an int object
print(x)
x = "now text" # same name, now bound to a str — perfectly legal
print(x)
a = b = c = 0 # chained assignment
x, y = 1, 2 # tuple unpacking
x, y = y, x # swap without a temp variable
print(a, b, c, x, y) # 0 0 0 2 1
Naming rules and conventions
Names are case-sensitive, must start with a letter or underscore, and can contain letters, digits, and underscores. Convention (PEP 8) is snake_case for variables and functions, UPPER_CASE for constants, and PascalCase for classes.
id(x) returns an object's identity (its memory address in CPython), and type(x) returns its type.References, not copies
Assignment binds a name to an object; it does not copy. This matters for mutable objects.
a = [1, 2, 3]
b = a # b and a point at the SAME list
b.append(4)
print(a) # [1, 2, 3, 4] — a changed too!
import copy
c = a.copy() # shallow copy — independent top-level list
d = copy.deepcopy(a) # recursively copies nested objects too
print(c, d) # [1, 2, 3, 4] [1, 2, 3, 4] — independent copies
Scope: LEGB
Name lookup follows Local → Enclosing → Global → Built-in. Use global to rebind a module-level name inside a function, and nonlocal to rebind a name in an enclosing function.
count = 0
def bump():
global count
count += 1 # without 'global', this would raise UnboundLocalError
bump(); bump()
print(count) # 2
2. Data Types
Python is dynamically typed (types are checked at runtime) but strongly typed (it won't silently coerce "3" + 4). The core built-in types:
| Category | Types | Mutable? | Example |
|---|---|---|---|
| Numeric | int, float, complex | No | 42, 3.14, 2+3j |
| Boolean | bool | No | True, False |
| Text | str | No | "hello" |
| Sequence | list, tuple, range | list only | [1,2], (1,2) |
| Mapping | dict | Yes | {"a": 1} |
| Set | set, frozenset | set only | {1, 2} |
| Binary | bytes, bytearray | bytearray | b"abc" |
| None | NoneType | — | None |
Numbers
n = 10 # int — unbounded precision, no overflow
big = 2 ** 1000 # still an exact int
f = 3.14 # float — 64-bit IEEE 754
underscored = 1_000_000 # underscores for readability
hexv, octv, binv = 0xFF, 0o17, 0b1010
print(hexv, octv, binv) # 255 15 10
print(0.1 + 0.2) # 0.30000000000000004 — float rounding!
from decimal import Decimal
print(Decimal("0.1") + Decimal("0.2")) # exact 0.3
Type conversion & checking
print(int("42")) # 42
print(float(3)) # 3.0
print(str(3.14)) # "3.14"
print(bool(0)) # False — 0, "", [], {}, None are all falsy
print(list("abc")) # ['a', 'b', 'c']
x = 42
print(type(x) is int) # True — exact type check
print(isinstance(x, (int, float))) # True — respects inheritance
bool is a subclass of int, so True == 1 and isinstance(True, int) are both True.Type hints
Hints are optional annotations — Python does not enforce them at runtime, but tools like mypy and IDEs use them.
def greet(name: str, times: int = 1) -> str:
return ("Hi " + name + " ") * times
nums: list[int] = [1, 2, 3]
print(greet("Ada", 2)) # Hi Ada Hi Ada
print(nums) # hints aren't enforced at runtime
3. Operators
Arithmetic
print(7 / 2) # 3.5 true division — always float
print(7 // 2) # 3 floor division
print(7 % 2) # 1 modulo (remainder)
print(2 ** 10) # 1024 exponent
print(-7 // 2) # -4 floors toward negative infinity
Comparison, boolean, identity, membership
x, a, b = 5, True, False
print(1 < x <= 10) # True — chained comparison
print(a and b, a or b, not a) # False True False
print(x is None) # False — use 'is' for None, not ==
print("a" in ["a", "b"]) # True — membership
and/or return an operand, not a bool. name or "guest" yields "guest" when name is empty.Assignment, walrus, bitwise
x = 0
x += 1 # augmented: also -= *= /= //= **= %=
print(x) # 1
data = list(range(20))
if (n := len(data)) > 10: # walrus := assigns AND returns (3.8+)
print(n) # 20
print(5 & 3, 5 | 3, 5 ^ 3, ~5, 1 << 4) # 1 7 6 -6 16
4. Conditions
Indentation defines blocks — there are no braces. The standard PEP 8 indent is 4 spaces.
score = 85
if score >= 90:
grade = "A"
elif score >= 80:
grade = "B"
else:
grade = "F"
print(grade) # B
# Ternary (conditional expression)
label = "pass" if score >= 60 else "fail"
print(label) # pass
Truthiness
Any object can be tested. Falsy values: False, None, 0, 0.0, "", [], {}, (), set(). Everything else is truthy.
items = [1, 2, 3]
if items: # idiomatic — don't write 'if len(items) > 0'
print("has", len(items), "items")
match / case (structural pattern matching, 3.10+)
def describe(point):
match point:
case (0, 0):
return "origin"
case (x, 0):
return f"on x-axis at {x}"
case (x, y) if x == y:
return "on diagonal"
case _:
return "somewhere else"
print(describe((0, 0))) # origin
print(describe((5, 0))) # on x-axis at 5
print(describe((3, 3))) # on diagonal
5. Loops
for — iterates over any iterable
for item in ["a", "b", "c"]:
print(item)
for i in range(0, 10, 2): # start, stop (exclusive), step
print(i, end=" ") # 0 2 4 6 8
print()
items = ["x", "y", "z"]
for i, val in enumerate(items, start=1): # index + value
print(i, val)
names, ages = ["Ada", "Bob"], [36, 40]
for name, age in zip(names, ages): # parallel iteration
print(name, age)
for x in items) over for i in range(len(items)). Reach for enumerate when you need the index.while, break, continue, else
# break / continue
for n in range(10):
if n == 3:
continue # skip 3
if n == 6:
break # stop at 6
print(n, end=" ") # 0 1 2 4 5
print()
# loop-else runs ONLY if the loop finished without hitting break
x = 13
for n in range(2, x):
if x % n == 0:
break
else:
print(x, "is prime!") # no divisor found
6. Functions
def area(width, height=1): # height has a default
"""Docstring: returns rectangle area."""
return width * height
print(area(3)) # positional → 3
print(area(3, height=4)) # keyword argument → 12
*args and **kwargs
def demo(*args, **kwargs):
print(args) # tuple of extra positional args
print(kwargs) # dict of extra keyword args
demo(1, 2, x=10) # args=(1, 2) kwargs={'x': 10}
nums = [1, 2, 3]
print(*nums) # unpacking: same as print(1, 2, 3)
Positional-only and keyword-only parameters
def f(pos_only, /, normal, *, kw_only):
return pos_only, normal, kw_only
# / marks end of positional-only; * marks start of keyword-only
print(f(1, 2, kw_only=3)) # (1, 2, 3)
def bad(item, bucket=[]): # SHARED across all calls!
bucket.append(item); return bucket
print(bad(1)) # [1]
print(bad(2)) # [1, 2] — not what you want!
def good(item, bucket=None):
if bucket is None: bucket = []
bucket.append(item); return bucket
print(good(1)) # [1]
print(good(2)) # [2] — fresh list each callReturning multiple values
def min_max(xs):
return min(xs), max(xs) # actually returns a tuple
lo, hi = min_max([3, 1, 9]) # unpacked
print(lo, hi) # 1 9
Functions are first-class objects: assign them to variables, pass them as arguments, and return them. This underpins decorators, map/filter, and callbacks.
7. Strings
Strings are immutable sequences of Unicode characters. Any "modifying" method returns a new string.
s = "Hello, World"
print(s[0]) # 'H' indexing
print(s[-1]) # 'd' negative index from the end
print(s[0:5]) # 'Hello' slicing [start:stop:step]
print(s[::-1]) # 'dlroW ,olleH' reverse
print(len(s)) # 12
f-strings (formatted string literals, 3.6+)
name, pi = "Ada", 3.14159
print(f"Hi {name}, pi is {pi:.2f}") # Hi Ada, pi is 3.14
print(f"{name=}") # name='Ada' self-documenting (3.8+)
print(f"{42:>8}") # right-align in width 8
print(f"{1234567:,}") # 1,234,567 thousands separator
Common methods
print(" hi ".strip()) # 'hi' (also lstrip/rstrip)
print("a,b,c".split(",")) # ['a', 'b', 'c']
print("-".join(["a", "b"])) # 'a-b'
print("Hello".replace("l", "L")) # 'HeLLo'
print("Hello".lower(), "hi".upper())
print("file.txt".endswith(".txt")) # True (also startswith)
print("abc".find("b")) # 1 (-1 if not found)
print("42".isdigit(), "ab".isalpha())
Raw, multi-line, and byte strings
path = r"C:\new\test" # raw — backslashes are literal
doc = """line 1
line 2""" # triple-quoted spans lines
data = b"bytes" # bytes, not str; .decode()/.encode() to convert
print(path)
print(doc)
print(data, data.decode()) # b'bytes' bytes
list and "".join(parts) at the end — concatenating with += repeatedly is O(n²).8. Lists
A list is an ordered, mutable sequence — a dynamic array under the hood. It can hold mixed types, though homogeneous lists are more common.
xs = [1, 2, 3]
xs[0] = 99 # mutable
print(xs) # [99, 2, 3]
print(xs[1:3]) # [2, 3] (returns a NEW list)
print(xs[::-1]) # reversed copy
Core methods
xs = [1, 2, 3]
xs.append(4) # add one item to the end
xs.extend([5, 6]) # add many (not append, which would nest)
xs.insert(0, 9) # insert at index
print(xs) # [9, 1, 2, 3, 4, 5, 6]
print(xs.pop()) # 6 (remove & return last)
xs.remove(9) # remove first matching value
print(xs.index(5)) # 4 (first position of value)
print(xs.count(2)) # 1
xs.sort() # in place; sorted(xs) returns a new list
print(xs)
words = ["ccc", "a", "bb"]
words.sort(key=len, reverse=True) # sort by a key function
print(words) # ['ccc', 'bb', 'a']
Building & copying
grid = [[0] * 3 for _ in range(3)] # 3x3 — correct way
print(grid) # [[0, 0, 0], [0, 0, 0], [0, 0, 0]]
# DON'T: [[0]*3]*3 → three references to the SAME inner list
xs = [1, 2, 3]
shallow = xs[:] # or xs.copy() / list(xs)
print(shallow) # [1, 2, 3]
collections.deque instead.9. Tuples
A tuple is an ordered, immutable sequence. Because it's immutable it's hashable, so tuples can be dict keys and set members.
t = (1, "two", 3.0)
print(t[0]) # 1 — reading is fine
# t[0] = 9 → TypeError: 'tuple' object does not support item assignment
one = (5,) # single-element tuple NEEDS the trailing comma
not_tuple = (5) # just the int 5
empty = () # empty tuple
bare = 1, 2, 3 # parentheses are optional
print(one, not_tuple, empty, bare) # (5,) 5 () (1, 2, 3)
Unpacking & multiple return
x, y, z = (1, 2, 3)
print(x, y, z) # 1 2 3
first, *rest = [1, 2, 3, 4] # star capture
print(first, rest) # 1 [2, 3, 4]
d = [3, 1, 9]
def stats(): return min(d), max(d) # returns a tuple
print(stats()) # (1, 9)
Named tuples — readable, still lightweight
from collections import namedtuple
Point = namedtuple("Point", ["x", "y"])
p = Point(3, 4)
print(p.x, p.y) # 3 4 attribute access
print(p[0]) # 3 still indexable
print(p) # Point(x=3, y=4)
t = ([1], 2); t[0].append(9) is allowed.10. Dictionaries
A dict maps hashable keys to values. Since Python 3.7 insertion order is guaranteed. Lookup, insert, and delete are average O(1).
user = {"name": "Ada", "age": 36}
print(user["name"]) # 'Ada' — KeyError if missing
print(user.get("email")) # None — safe, no error
print(user.get("email", "n/a")) # default if missing
user["age"] = 37 # insert or update
del user["age"] # remove key
print("name" in user) # True — membership tests KEYS
print(user)
Iterating
user = {"name": "Ada", "age": 36}
for key in user: # iterates keys by default
print("key:", key)
for k, v in user.items(): # key/value pairs
print(k, "=", v)
print(list(user.keys()), list(user.values()))
Useful patterns
a, b = {"x": 1}, {"x": 9, "y": 2}
print({**a, **b}) # {'x': 9, 'y': 2} b wins on conflicts
print(a | b) # same, 3.9+
user = {}
user.setdefault("tags", []).append("x") # get-or-create
print(user) # {'tags': ['x']}
from collections import defaultdict, Counter
groups = defaultdict(list) # missing key auto-creates []
groups["a"].append(1)
print(dict(groups)) # {'a': [1]}
print(Counter("mississippi")) # Counter({'i': 4, 's': 4, 'p': 2, 'm': 1})
# dict comprehension
squares = {n: n*n for n in range(5)}
print(squares)
11. Sets
A set is an unordered collection of unique, hashable elements. Membership testing is average O(1) — far faster than scanning a list.
s = {1, 2, 3}
empty = set() # {} is an empty DICT, not a set!
s.add(4)
s.discard(9) # no error if absent (remove() raises)
print(2 in s) # True — fast membership
print(set([1, 1, 2, 2])) # {1, 2} — dedup an iterable
print(s) # {1, 2, 3, 4}
Set algebra
a, b = {1, 2, 3}, {2, 3, 4}
print(a | b) # union {1, 2, 3, 4}
print(a & b) # intersection {2, 3}
print(a - b) # difference {1}
print(a ^ b) # symmetric diff {1, 4}
print(a <= b) # subset test False
frozenset is the immutable, hashable version — usable as a dict key or an element of another set. Sets and their comprehensions ({x*x for x in nums}) are the idiomatic way to dedup and to test overlap between collections.
12. File Handling
Always use the with statement — it guarantees the file is closed even if an error occurs.
# the playground has an in-memory filesystem — write a file first
with open("data.txt", "w") as f:
f.write("line one\nline two\n")
with open("data.txt", "r", encoding="utf-8") as f:
content = f.read() # whole file as one string
print(repr(content))
with open("data.txt") as f:
for line in f: # memory-efficient — streams line by line
print(line.rstrip())
Modes
| Mode | Meaning |
|---|---|
"r" | read (default); error if file missing |
"w" | write; truncates existing file |
"a" | append to end |
"x" | create; error if it already exists |
"b" / "t" | binary / text (add to another mode, e.g. "rb") |
"r+" | read and write |
Writing
with open("out.txt", "w") as f:
f.write("one line\n") # write does NOT add a newline
f.writelines(["a\n", "b\n"])
print(open("out.txt").read())
pathlib — the modern path API
from pathlib import Path
p = Path("app.txt")
p.write_text("hi") # one-liner write
print(p.exists(), p.suffix, p.stem) # True .txt app
print(p.read_text(encoding="utf-8")) # one-liner read → hi
Structured formats
import json
data = json.loads('{"a": 1}') # str → dict (load() from a file)
print(data) # {'a': 1}
print(json.dumps(data, indent=2)) # dict → str (dump() to a file)
import csv, io
buf = io.StringIO("name,age\nAda,36\nBob,40\n")
for row in csv.DictReader(buf): # each row is a dict keyed by header
print(row["name"], row["age"])
13. Exception Handling
x = 0
try:
result = 10 / x
except ZeroDivisionError:
print("cannot divide by zero")
except (TypeError, ValueError) as e: # catch several; bind to e
print(f"bad input: {e}")
else:
print("ran only if no exception")
finally:
print("always runs — cleanup")
Raising and re-raising
age = -1
try:
if age < 0:
raise ValueError(f"age must be non-negative, got {age}")
except ValueError as e:
print("caught:", e)
# bare 'raise' here would re-raise, keeping the traceback
# 'raise RuntimeError("wrapped") from e' chains with a cause
Custom exceptions
class InsufficientFundsError(Exception):
"""Raised when a withdrawal exceeds the balance."""
try:
raise InsufficientFundsError("balance too low")
except InsufficientFundsError as e:
print(type(e).__name__, "-", e)
except: (it swallows KeyboardInterrupt and system exits) — catch Exception at least. Don't use exceptions for ordinary control flow, and don't silence errors with an empty except: pass.try: d[k] except KeyError — over "Look Before You Leap" checks. It's idiomatic and avoids race conditions.14. Modules
A module is any .py file; a package is a directory of modules (historically containing __init__.py).
import math
print(math.sqrt(16)) # 4.0
from math import sqrt, pi # bring specific names into scope
print(sqrt(9), round(pi, 4)) # 3.0 3.1416
from math import sqrt as s # alias
print(s(25)) # 5.0
# third-party modules use the same syntax, e.g. import numpy as np
Your own module & the __main__ guard
# file: calc.py
def add(a, b): return a + b
if __name__ == "__main__":
# runs only when executed directly (python calc.py),
# NOT when imported by another module
print(add(2, 3)) # 5
Python searches for modules along sys.path (the script's directory, then PYTHONPATH, then installed packages). Standard-library highlights worth knowing: os, sys, datetime, collections, itertools, functools, random, re, json, pathlib.
15. Object-Oriented Programming
class Account:
bank = "Acme" # class attribute — shared by all instances
def __init__(self, owner, balance=0):
self.owner = owner # instance attributes
self.balance = balance
def deposit(self, amount): # instance method — self is the instance
self.balance += amount
return self.balance
acc = Account("Ada", 100)
print(acc.deposit(50)) # 150
Inheritance & super()
class Account:
def __init__(self, owner, balance=0):
self.owner, self.balance = owner, balance
def deposit(self, amount):
self.balance += amount
return self.balance
class Savings(Account):
def __init__(self, owner, rate):
super().__init__(owner) # call parent constructor
self.rate = rate
def deposit(self, amount): # override
return super().deposit(amount * (1 + self.rate))
s = Savings("Ada", 0.10)
print(s.deposit(100)) # 110.0
Dunder methods (operator overloading)
class Vector:
def __init__(self, x, y): self.x, self.y = x, y
def __repr__(self): return f"Vector({self.x}, {self.y})"
def __add__(self, o): return Vector(self.x+o.x, self.y+o.y)
def __eq__(self, o): return (self.x, self.y) == (o.x, o.y)
print(Vector(1, 2) + Vector(3, 4)) # Vector(4, 6)
Other key dunders: __str__ (user-facing string), __len__, __getitem__ (indexing), __iter__/__next__ (iteration), __call__ (make an instance callable), __enter__/__exit__ (context managers).
Properties & encapsulation
class Circle:
def __init__(self, r): self._r = r # _leading underscore = "internal"
@property
def area(self): # call as circle.area, no parentheses
return 3.14159 * self._r ** 2
c = Circle(2)
print(c.area) # 12.56636 — accessed like an attribute
dataclasses — boilerplate-free classes
from dataclasses import dataclass
@dataclass
class Product:
name: str
price: float
qty: int = 0
# auto-generates __init__, __repr__, __eq__
print(Product("pen", 1.5)) # Product(name='pen', price=1.5, qty=0)
_x signals "internal"; two leading underscores __x trigger name-mangling to avoid subclass clashes. There's also @classmethod (receives the class as cls) and @staticmethod (receives neither).16. Virtual Environments
A virtual environment is an isolated Python installation with its own packages, so each project's dependencies don't collide. Create one per project.
# create — makes a ./venv folder
python -m venv venv
# activate
source venv/bin/activate # macOS / Linux
venv\Scripts\activate # Windows
# your prompt now shows (venv); python & pip point inside it
deactivate # leave the environment
Django 4 while Project B stays on Django 5. Add venv/ to your .gitignore — you commit the requirements file, not the environment.Popular alternatives you'll encounter: conda (data-science focused, manages non-Python deps too), pipenv, poetry, and the fast newcomer uv (uv venv, uv pip install).
17. pip & Packages
pip is Python's package installer, pulling from PyPI (the Python Package Index).
pip install requests # latest version
pip install "requests==2.31.0" # exact pin
pip install "requests>=2.28,<3" # range
pip install --upgrade requests # upgrade
pip uninstall requests
pip list # installed packages
pip show requests # details for one package
requirements.txt — reproducible installs
# freeze current environment into a file
pip freeze > requirements.txt
# recreate elsewhere
pip install -r requirements.txt
# requirements.txt contents
requests==2.31.0
pandas>=2.0
python-dotenv
pip install inside an activated virtual environment, never system-wide. If a command reports permission errors and suggests sudo pip, that's a sign you forgot to activate your venv.Modern projects increasingly declare dependencies in pyproject.toml instead of requirements.txt, managed by tools like poetry or uv. Both approaches solve the same problem: pinning exactly what your project needs.
18. Comprehensions
A concise, faster way to build a list, set, or dict from an iterable. Read left to right: expression, then loop, then optional filter.
# list comprehension
squares = [n*n for n in range(10)]
print(squares)
evens = [n for n in range(10) if n % 2 == 0] # with filter
print(evens)
labels = ["even" if n%2==0 else "odd" for n in range(5)]
print(labels)
# nested — flatten a 2D list
matrix = [[1, 2, 3], [4, 5, 6]]
flat = [x for row in matrix for x in row]
print(flat) # [1, 2, 3, 4, 5, 6]
# dict and set comprehensions
print({n: n*n for n in range(5)})
print({c for c in "mississippi"}) # {'m','i','s','p'}
for loop is clearer. A comprehension with side effects (calling a function only for its effect) is an anti-pattern — use a loop.19. Lambda
An anonymous, single-expression function. lambda args: expression — no return, no statements, just one expression that becomes the result.
square = lambda x: x * x
print(square(5)) # 25
add = lambda a, b=0: a + b # defaults allowed
print(add(3), add(3, 4)) # 3 7
Their real use is as short throwaway functions passed to other functions:
from collections import namedtuple
P = namedtuple("P", "name age")
people = [P("Ada", 36), P("Bob", 28)]
people.sort(key=lambda p: p.age) # sort by a computed key
print([p.name for p in people]) # ['Bob', 'Ada']
words = ["hi", "hello", "hey"]
print(max(words, key=lambda w: len(w))) # hello
items = [(1, "b"), (1, "a"), (0, "z")]
print(sorted(items, key=lambda t: (t[1], t[0]))) # multi-key sort
f = lambda x: ...) — just use def, which gives a proper name in tracebacks. Lambdas shine only when inline and disposable.20. map / filter
map applies a function to every item; filter keeps items where a predicate is true. Both return lazy iterators, so wrap in list() to materialize.
nums = [1, 2, 3, 4]
print(list(map(lambda x: x*x, nums))) # [1, 4, 9, 16]
print(list(filter(lambda x: x % 2 == 0, nums))) # [2, 4]
print(list(map(str.upper, ["a", "b"]))) # ['A', 'B']
print(list(map(lambda a, b: a+b, [1,2], [10,20]))) # [11, 22]
map/filter with a lambda: [x*x for x in nums] beats map(lambda x: x*x, nums). Reach for map when you already have a named function to apply (map(int, tokens)). Also look at functools.reduce for folding a sequence into one value.21. Generators
A generator produces values lazily, one at a time, holding only the current value in memory — ideal for large or infinite sequences. Any function with yield is a generator function.
def countdown(n):
while n > 0:
yield n # pauses here, resumes on next()
n -= 1
for x in countdown(3): # 3, 2, 1
print(x)
gen = countdown(3)
print(next(gen)) # 3 — pull one value manually
Generator expressions
Like a list comprehension but with parentheses — evaluated lazily, no intermediate list built.
total = sum(n*n for n in range(1_000_000)) # no million-item list in memory
print(total) # 333332833333500000
data = [5, 50, 500, 5000]
first_big = next(x for x in data if x > 1000) # stop at first match
print(first_big) # 5000
itertools module (count, cycle, islice, chain, groupby) is built around this model.22. Decorators
A decorator is a function that takes a function and returns a modified function — a clean way to wrap behavior (logging, timing, caching, auth) around existing code. @name is syntactic sugar.
import functools
def timer(func):
@functools.wraps(func) # preserves func's name/docstring
def wrapper(*args, **kwargs):
import time
start = time.perf_counter()
result = func(*args, **kwargs) # call the original
print(f"{func.__name__} took {time.perf_counter()-start:.4f}s")
return result
return wrapper
@timer
def slow():
return sum(range(1_000_000))
# @timer means: slow = timer(slow)
print(slow()) # prints timing line, then 499999500000
Decorators with arguments
import functools
def repeat(times): # outer takes the argument
def deco(func):
@functools.wraps(func)
def wrapper(*a, **k):
for _ in range(times):
r = func(*a, **k)
return r
return wrapper
return deco
@repeat(3)
def greet(): print("hi")
greet() # prints hi three times
Common built-in decorators: @property, @staticmethod, @classmethod, and @functools.lru_cache (memoize results automatically).
23. Context Managers
A context manager defines setup and teardown around a block via the with statement — guaranteeing cleanup even on exceptions. You've already used one: open().
with open("f.txt", "w") as f:
f.write("hello from a file")
with open("f.txt") as f:
data = f.read()
print(data) # file is closed automatically, even if read() raised
# manage several at once
with open("in.txt", "w") as s: s.write("copy me")
with open("in.txt") as src, open("out.txt", "w") as dst:
dst.write(src.read())
print(open("out.txt").read()) # copy me
Writing one — the class protocol
import time
class Timer:
def __enter__(self): # runs at 'with' entry; return value → 'as' target
self.start = time.perf_counter()
return self
def __exit__(self, exc_type, exc_val, tb): # runs at exit, even on error
self.elapsed = time.perf_counter() - self.start
# return True to SUPPRESS an exception; False/None re-raises it
with Timer() as t:
sum(range(100000))
print(f"elapsed: {t.elapsed:.4f}s")
The easy way — contextlib
from contextlib import contextmanager
@contextmanager
def tag(name):
print(f"<{name}>")
yield # everything before = __enter__, after = __exit__
print(f"</{name}>")
with tag("b"):
print("bold") # prints <b> then bold then </b>
with lock:), or temporarily changing state. Put the yield inside a try/finally if cleanup must run even when the block raises.Git
Git is a distributed version-control system: it tracks every change as a commit, lets you branch off to work in isolation, and merge back. It's the safety net that makes experimentation safe and collaboration possible.
git init # start tracking a folder
git status # what changed?
git add . # stage changes for the next commit
git commit -m "message" # snapshot the staged changes
git log --oneline # history
git switch -c feature # create & switch to a branch
git merge feature # fold a branch back into main
Learn the difference between merge (preserves history, adds a merge commit) and rebase (replays your commits on top of another branch for a linear history). Always keep a .gitignore so you never commit venv/, .env, secrets, or build artifacts.
GitHub
GitHub hosts Git repositories in the cloud and adds collaboration on top: pull requests (propose and review changes before they merge), issues, releases, and Actions for CI/CD. The everyday remote loop:
git remote add origin https://github.com/you/repo.git
git push -u origin feature # publish your branch
# open a Pull Request on github.com → review → merge → delete branch
git pull # fetch + merge others' changes
For agents you'll also use GitHub as a tool target (opening PRs, reading issues) via its API — see Phase 4.
HTTP
HTTP is the request/response protocol the whole web runs on. A request has a method, a URL, headers, and an optional body; a response has a status code and body.
| Method | Meaning | Status ranges |
|---|---|---|
GET | read a resource | 2xx success |
POST | create / perform an action | 3xx redirect |
PUT/PATCH | update | 4xx client error (401, 404, 429) |
DELETE | remove | 5xx server error |
Key headers you'll set constantly: Authorization (credentials), Content-Type (e.g. application/json), and Accept.
REST APIs
REST is a convention for exposing resources at URLs and acting on them with HTTP methods. It's stateless (every request carries its own auth) and predictable, which is why nearly every service offers a REST API.
# GET /users/{id} → fetch one user
# POST /users → create a user (body = JSON)
# PATCH /users/{id} → update fields
# DELETE /users/{id} → remove
import requests
r = requests.get("https://api.github.com/users/torvalds")
print(r.status_code) # 200
data = r.json() # dict parsed from the JSON body
JSON
JSON is the universal data-interchange format for APIs. It maps almost one-to-one onto Python: object→dict, array→list, string/number/bool/null→str/int|float/bool/None.
import json
payload = {"name": "Ada", "roles": ["admin", "dev"], "active": True}
text = json.dumps(payload) # dict → JSON string (to send)
print(text)
back = json.loads(text) # JSON string → dict (when received)
print(back["roles"][0]) # admin
Authentication
Authentication proves who you are; authorization decides what you may do. APIs authenticate you in a few common ways: an API key in a header, a bearer token, HTTP Basic auth, or a full OAuth flow. The golden rule: credentials live in environment variables, never in code or Git.
OAuth
OAuth 2.0 is the "Log in with Google/Slack/GitHub" delegation protocol. Your app never sees the user's password — instead the provider issues a scoped access token (and often a refresh token) that lets you act on the user's behalf, only within the scopes they approved.
# the authorization-code flow, conceptually:
# 1. redirect user to provider's consent screen (with your client_id + scopes)
# 2. provider redirects back with a short-lived ?code=...
# 3. exchange that code (+ client_secret) for an access_token
# 4. call the API with: Authorization: Bearer <access_token>
JWT
A JSON Web Token is a signed, self-contained token in three dot-separated parts: header.payload.signature. Because it's cryptographically signed, a server can verify it without a database lookup — the token itself carries the claims (user id, expiry, scopes).
# decoded payload (the middle part) looks like:
{ "sub": "user_123", "exp": 1735689600, "scope": "read:msgs" }
# NB: JWTs are signed, not encrypted — never put secrets in the payload
Webhooks
Webhooks invert the usual polling model. Instead of you repeatedly asking "anything new?", the service POSTs an event to a URL you expose the moment something happens — a Slack message, a GitHub push, a Stripe payment. Your endpoint receives the JSON, verifies a signature, and reacts.
# a minimal webhook receiver (FastAPI)
@app.post("/webhooks/slack")
async def on_event(event: dict):
if event["type"] == "message":
handle(event)
return {"ok": True} # respond fast (2xx) or the sender retries
Environment variables
Environment variables keep configuration and secrets out of your code, so the same code runs in dev and prod with different settings. Load them from a local .env file (git-ignored) in development.
import os
from dotenv import load_dotenv # pip install python-dotenv
load_dotenv() # reads .env into the environment
key = os.getenv("OPENAI_API_KEY") # None if unset — fail loudly if required
Logging
Logging beats print(): it has severity levels, timestamps, and can be routed to files or log aggregators without touching your code. Use it from day one.
import logging
logging.basicConfig(level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("agent")
log.info("agent started")
log.warning("token budget at 80%%")
log.error("tool call failed") # levels: DEBUG < INFO < WARNING < ERROR < CRITICAL
Testing
Automated tests let you change code fearlessly. pytest is the de-facto standard: any function named test_* that asserts becomes a test. Tests matter doubly for agents, where you want to pin down tool behavior even when the LLM is non-deterministic.
def add(a, b):
return a + b
def test_add():
assert add(2, 3) == 5
assert add(-1, 1) == 0
test_add() # run: `pytest -q`; here we call it directly
print("all tests passed")
Debugging
When something breaks, reach for a debugger before scattering prints. breakpoint() drops you into pdb where you can inspect variables and step line by line; every IDE wraps this in a visual debugger. Read the traceback bottom-up — the last line is the actual error.
def process(order):
total = order["qty"] * order["price"]
breakpoint() # pauses here: inspect `total`, step with n/s, continue with c
return total
Project structure
A clean layout pays off the moment a project grows past one file. A common agentic shape separates concerns so each part can evolve independently:
Agents need to store state, look up facts, and cache expensive results. Learn SQL deeply against one relational database, then get a feel for a document store and a cache. Below, each SQL clause gets its own note; the SQLite example at the end actually runs in the playground.
SELECT
SELECT reads rows. Choose columns, filter with WHERE, and limit results. This is the clause you'll write most.
SELECT name, email -- columns (or * for all)
FROM customers
WHERE active = true AND country = 'US'
LIMIT 10;
JOIN
A JOIN combines rows from two tables on a matching key. INNER JOIN keeps only matches; LEFT JOIN keeps all left rows and fills missing right values with NULL.
SELECT c.name, p.amount
FROM customers c
JOIN purchases p ON p.customer_id = c.id; -- inner: only customers who purchased
SELECT c.name, p.amount
FROM customers c
LEFT JOIN purchases p ON p.customer_id = c.id; -- all customers, NULL if none
GROUP BY
GROUP BY collapses rows that share a value into one, so you can apply aggregates (COUNT, SUM, AVG, MIN, MAX). Filter groups with HAVING (not WHERE).
SELECT customer_id, COUNT(*) AS orders, SUM(amount) AS spend
FROM purchases
GROUP BY customer_id
HAVING SUM(amount) > 1000; -- filter AFTER aggregating
ORDER BY
ORDER BY sorts the result. ASC (default) or DESC, and you can sort by multiple columns.
SELECT name, amount
FROM purchases
ORDER BY amount DESC, name ASC;
Window functions
Window functions compute across a set of rows related to the current row — without collapsing them like GROUP BY does. Great for rankings, running totals, and per-group comparisons.
SELECT name, region, amount,
RANK() OVER (PARTITION BY region ORDER BY amount DESC) AS rnk,
SUM(amount) OVER (PARTITION BY region) AS region_total
FROM sales; -- each row keeps its identity, plus rank & total
CTEs
A Common Table Expression (WITH ... AS) names a subquery so complex queries read top-to-bottom instead of nesting. CTEs can also be recursive (for trees/graphs).
WITH top_customers AS (
SELECT customer_id, SUM(amount) AS spend
FROM purchases
GROUP BY customer_id
)
SELECT * FROM top_customers WHERE spend > 1000;
Indexes
An index is a lookup structure (usually a B-tree) that makes reads on a column dramatically faster — at the cost of extra disk and slightly slower writes. Add them on columns you frequently filter, join, or sort on.
CREATE INDEX idx_purchases_customer ON purchases(customer_id);
-- use EXPLAIN to confirm a query actually uses your index
EXPLAIN SELECT * FROM purchases WHERE customer_id = 42;
PostgreSQL
The production default. A powerful open-source relational database with JSON columns, full-text search, strong concurrency, and the pgvector extension for storing embeddings — meaning you can even do RAG (Phase 10) inside Postgres.
# pip install psycopg[binary]
import psycopg
with psycopg.connect("postgresql://user:pass@localhost/mydb") as conn:
rows = conn.execute("SELECT id, body FROM notes WHERE id = %s", (1,)).fetchall()
SQLite
An embedded, single-file relational database that ships with Python — zero setup, perfect for local dev, prototypes, and small apps. Same SQL you'll use in Postgres. This one runs in the playground:
import sqlite3
conn = sqlite3.connect(":memory:") # in-memory DB for the demo
conn.execute("CREATE TABLE notes(id INTEGER PRIMARY KEY, body TEXT)")
conn.executemany("INSERT INTO notes(body) VALUES (?)",
[("hello",), ("world",)]) # ? placeholders stop SQL injection
conn.commit()
for row in conn.execute("SELECT id, body FROM notes ORDER BY id"):
print(row) # (1, 'hello') (2, 'world')
MongoDB (NoSQL)
A document database that stores JSON-like documents instead of rows. There's no fixed schema, so it's handy when your data is nested or evolving fast. You query with a document-shaped filter rather than SQL.
# pip install pymongo
from pymongo import MongoClient
db = MongoClient().mydb
db.users.insert_one({"name": "Ada", "roles": ["admin"]}) # nested, schemaless
doc = db.users.find_one({"name": "Ada"})
Redis (caching)
Redis is an in-memory key–value store — extremely fast, ideal for caching, session state, rate limiting, and simple queues. In agent systems it's commonly used to cache LLM responses and hold short-term conversation state with a TTL (time to live).
# pip install redis
import redis
r = redis.Redis()
r.set("answer:42", "cached response", ex=3600) # expires in 1 hour
cached = r.get("answer:42") # skip the expensive LLM call on a hit
This is one of the biggest skills. Every service worth automating has an API, and if you can integrate an API you can turn it into an agent tool. The pattern never changes: authenticate, send a request, parse the JSON, handle errors. Learn it once and every service below becomes reachable.
Example APIs worth knowing
| API | What you can automate | Auth |
|---|---|---|
| Google Ads API | campaigns, budgets, performance reports | OAuth 2.0 + developer token |
| Meta API | Facebook/Instagram ads, pages, insights | OAuth 2.0 |
| Slack API | post messages, read channels, slash commands | Bot token (OAuth) |
| Jira API | create/update issues, sprints, JQL search | API token / OAuth |
| GitHub API | PRs, issues, repos, Actions | PAT / OAuth |
| Gmail API | read, send, label, search email | OAuth 2.0 |
They differ only in endpoints and scopes. Read the docs for the resource you need, find the auth method, and the request shape follows the same recipe.
requests
The classic, synchronous HTTP client — simplest to learn and perfect for scripts and one-off integrations. Blocks until each call returns.
import requests
r = requests.get("https://api.github.com/repos/python/cpython",
headers={"Authorization": f"Bearer {token}"})
r.raise_for_status() # raise on 4xx/5xx
print(r.json()["stargazers_count"])
httpx
The modern successor to requests: same friendly API but supports async and HTTP/2. A good default for new code, especially if you'll eventually make concurrent calls.
import httpx
# sync — drop-in familiar
r = httpx.post("https://slack.com/api/chat.postMessage",
headers={"Authorization": f"Bearer {token}"},
json={"channel": "#general", "text": "deploy done"})
r.raise_for_status()
# async — for concurrency
async with httpx.AsyncClient() as client:
resp = await client.get(url)
aiohttp
A fully async HTTP client/server. Reach for it when you need to fan out to many APIs at once — dozens of concurrent requests without blocking, which matters when an agent gathers data from several sources in parallel.
import asyncio, aiohttp
async def fetch(session, url):
async with session.get(url) as resp:
return await resp.json()
async def main(urls):
async with aiohttp.ClientSession() as s:
return await asyncio.gather(*[fetch(s, u) for u in urls]) # all at once
429/5xx; keep keys in environment variables; set timeouts; and wrap each integration behind a small, well-named function so it can later become an agent tool (Phase 8).Don't go too deep into research or math. You need a working mental model of how LLMs behave so you can build on them reliably. Here's each core concept.
What is an LLM?
A Large Language Model is a neural network trained to predict the next token given the preceding text. That single objective, at massive scale, produces a model that can summarize, translate, write code, and appear to reason. It has no memory between calls and no live access to the world — everything it "knows" is either baked in from training or supplied in the prompt.
Tokens
Models don't read characters or words — they read tokens, sub-word chunks. Roughly 4 characters ≈ 1 token in English. You're billed per token (input + output), and every model limit is measured in tokens, so token-awareness is cost-awareness.
Context window
The context window is the maximum number of tokens the model can consider at once — prompt plus its reply. Everything the model uses to answer must fit inside it. Exceed it and you must truncate, summarize, or retrieve only the relevant pieces (that's what RAG is for).
Embeddings
An embedding turns text into a vector of numbers that captures meaning: similar meanings land near each other in vector space. Comparing vectors (cosine similarity) is how you do semantic search — the foundation of memory (Phase 9) and RAG (Phase 10).
# "dog" and "puppy" produce nearby vectors; "dog" and "invoice" are far apart
emb = client.embeddings.create(model="text-embedding-3-small", input="a happy dog")
vector = emb.data[0].embedding # e.g. a list of 1536 floats
Prompting
The prompt is your entire interface to the model — the instructions, context, and question you send. Small wording changes meaningfully change output, which is why prompt engineering (Phase 6) is its own skill.
Temperature
Temperature controls randomness. 0 makes output focused and near-deterministic (best for extraction, classification, tool use); higher values (0.7–1.0) add variety and creativity (brainstorming, copywriting). For agents that call tools, keep it low.
Hallucinations
A hallucination is confident, plausible-sounding output that is simply wrong. LLMs generate likely text, not verified facts. You reduce hallucination by grounding the model in real data (RAG), giving it tools to look things up, asking for citations, and keeping tasks narrow.
RAG
Retrieval-Augmented Generation: fetch relevant documents first, then let the LLM answer using them. It keeps answers current and grounded without retraining. Covered in depth in Phase 10.
Fine-tuning (conceptually)
Fine-tuning further-trains a base model on your own examples to bake in a style or task. It's powerful but rarely your first move — it needs data, costs money, and goes stale. Prompting plus RAG solves most problems; reach for fine-tuning only when you need a consistent format or behavior that prompting can't reliably produce.
Function calling / tool use
This is what turns a chatbot into an agent. You describe functions (tools) to the model; when useful, it returns a structured request to call one; you execute it and feed the result back so it can continue. The loop of think → call tool → observe → continue is the heart of every agent framework.
# the model replies with a tool call instead of prose:
{ "tool": "get_weather", "arguments": {"city": "Austin"} }
# you run get_weather("Austin"), return the result, model writes the final answer
Model context
"Context" is everything you pack into a request: system instructions, conversation history, retrieved documents, and tool results. Managing it well — deciding what to include, summarize, or drop so the important parts fit the window — is a core agent-engineering skill. (The Model Context Protocol, MCP, is an emerging standard for feeding tools and data to models in a uniform way.)
Putting it together
# the shape of a modern chat call (OpenAI-style)
from openai import OpenAI
client = OpenAI()
resp = client.chat.completions.create(
model="gpt-4o",
temperature=0, # focused, repeatable
messages=[
{"role": "system", "content": "You are a concise assistant."},
{"role": "user", "content": "Summarize this ticket..."},
],
)
print(resp.choices[0].message.content)
Learn to write prompts like programs: precise inputs, a defined output format, examples, and tests. This is the highest-leverage skill for building reliable agents. Each technique below is a tool you'll combine.
Role prompting
Assign the model a role and it adopts the relevant knowledge, tone, and priorities. Put this in the system message — it steers everything that follows.
SYSTEM = """You are a senior SQL reviewer.
Flag correctness bugs and injection risks. Be terse and specific."""
Few-shot prompting
Show 2–5 input→output examples so the model copies the pattern. Few-shot is often the fastest way to lock in a format or edge-case handling without long instructions.
EXAMPLES = """
Input: "My payment failed twice" -> {"category": "billing", "urgent": true}
Input: "How do I change my avatar?" -> {"category": "account", "urgent": false}
Input: "The app crashes on launch" -> {"category": "bug", "urgent": true}
""" # then ask the model to classify a new input in the same shape
Chain of thought
The idea that letting a model reason step-by-step improves hard, multi-step tasks (math, logic, planning). Understand the concept — but with modern models you generally don't need to explicitly ask them to reveal their reasoning; many reason internally. Prefer asking for the answer (and a brief justification) over dumping raw step-by-step traces into user-facing output.
Structured output
Force output into a fixed shape your code can consume, instead of free prose. This is what makes an LLM safe to wire into a pipeline — the next step gets predictable fields.
JSON output
The most common structured format. Ask for JSON and validate it — pair the prompt with a pydantic model (or the provider's "JSON mode" / structured-output feature) so malformed responses fail loudly instead of corrupting downstream steps.
from pydantic import BaseModel
class Ticket(BaseModel):
category: str
urgent: bool
SYSTEM = 'Return ONLY JSON: {"category": str, "urgent": bool}.'
ticket = Ticket.model_validate_json(llm_output) # raises if the shape is wrong
Tool calling
Prompt the model to choose and fill in a tool rather than answer directly. You provide tool descriptions; the model returns a structured call. Clear tool names and argument descriptions are the prompt here — invest in them.
SYSTEM = """You can call tools. Use `search_docs(query)` for product questions,
and `create_ticket(summary, priority)` to escalate. Prefer a tool over guessing."""
Planning prompts
For complex tasks, ask the model to lay out a plan (a list of steps or sub-tasks) before executing. A manager agent often plans, then delegates each step to a specialist (Phase 11).
SYSTEM = """First produce a numbered plan of steps.
Then execute them one at a time, checking the result of each before continuing."""
| Framework | Sweet spot |
|---|---|
| LangChain | Broad ecosystem of integrations; good for chaining LLM calls and tools |
| LangGraph | Graph-based control flow for stateful, cyclic agent workflows — strong for complex multi-step/multi-agent logic |
| OpenAI Agents SDK | Lightweight, first-party agents + tools + handoffs; great starting point |
| LlamaIndex | RAG-first: ingestion, indexing, retrieval over your data |
| CrewAI | Role-based crews of collaborating agents; quick to prototype teams |
| AutoGen | Conversational multi-agent orchestration (Microsoft) |
Pick one framework and learn it deeply — the concepts transfer to the rest. LangChain gives the broadest integration ecosystem; LangGraph models agents as a graph for stateful, cyclic workflows; the OpenAI Agents SDK is a lightweight first-party starting point; LlamaIndex is RAG-first; CrewAI makes role-based agent teams quick; AutoGen focuses on conversational multi-agent orchestration.
Agents
An agent is an LLM running in a loop with access to tools: it reads the goal, decides whether to answer or call a tool, observes the result, and repeats until done. Everything else in this phase supports that loop.
# OpenAI Agents SDK — an agent with one tool
from agents import Agent, function_tool
@function_tool
def get_weather(city: str) -> str:
return f"Sunny in {city}"
agent = Agent(
name="Assistant",
instructions="Help the user. Use tools when needed.",
tools=[get_weather],
)
Tools
Tools are the functions an agent can call to affect the world — search, query a DB, send an email. The framework exposes their names, argument types, and docstrings to the model. Building good tools is important enough to be its own phase (Phase 8).
Memory
Memory is how an agent carries state across turns and sessions: the conversation so far (short-term) and facts or documents recalled later (long-term). Frameworks provide memory abstractions; the underlying stores are covered in Phase 9.
Routing
Routing decides what happens next — which tool to use, which specialist agent to hand off to, or whether the task is finished. In graph frameworks like LangGraph this is an explicit edge/condition; in others the LLM itself routes by choosing a tool or handoff.
Multi-agent systems
Rather than one do-everything agent, a manager coordinates specialists, each with a focused role and its own tools. Keeps prompts small and reliable. Expanded in Phase 11.
Human approval steps
For risky or irreversible actions (sending money, emailing customers, deleting data), the agent should pause and wait for a human to approve before proceeding. Frameworks call this human-in-the-loop; build it in from the start for anything consequential.
# conceptual gate before a destructive action
if action.is_destructive:
if not request_human_approval(action): # pause & wait
return "cancelled by reviewer"
An agent is only as useful as the tools it can call. A tool is a well-described function wrapping an API or capability — this is where Phase 4 (APIs) pays off. The model reads each tool's name, argument types, and docstring to decide when and how to call it, so those descriptions are part of your prompt.
Common tools and what they wrap
| Tool | Wraps | Example use |
|---|---|---|
| Google Search | a search API | look up current facts the model doesn't know |
| Slack | Slack API | post updates, read a channel |
| Google Ads / Meta Ads | ads APIs | pull performance, adjust budgets |
| Database | SQL/NoSQL driver | answer questions from your data |
| Calculator | safe eval / math | exact arithmetic (LLMs are unreliable at math) |
| Gmail/SMTP | draft and (after approval) send mail | |
| Browser | headless browser | read pages, fill forms, scrape |
| GitHub | GitHub API | open PRs, read issues, comment |
| Jira | Jira API | create/update tickets, run JQL |
Anatomy of a tool
Every tool follows the same shape: a clear name, typed arguments, a docstring that tells the model when to use it, and a concise return value.
from agents import function_tool
import httpx
@function_tool
def send_slack_message(channel: str, text: str) -> str:
"""Post a message to a Slack channel. Use for notifications."""
r = httpx.post("https://slack.com/api/chat.postMessage",
headers={"Authorization": f"Bearer {TOKEN}"},
json={"channel": channel, "text": text})
return "sent" if r.json().get("ok") else "failed"
A pure-Python tool needs no API at all — here's a calculator tool you can run:
import ast, operator as op
OPS = {ast.Add: op.add, ast.Sub: op.sub,
ast.Mult: op.mul, ast.Div: op.truediv, ast.Pow: op.pow}
def calculator(expr: str) -> float:
"""Safely evaluate a math expression like '2 * (3 + 4)'."""
def ev(n):
if isinstance(n, ast.Constant): return n.value
if isinstance(n, ast.BinOp): return OPS[type(n.op)](ev(n.left), ev(n.right))
raise ValueError("unsupported")
return ev(ast.parse(expr, mode="eval").body)
print(calculator("2 * (3 + 4)")) # 14
LLMs are stateless between calls — memory is how an agent remembers the conversation, the user, and relevant knowledge across turns and sessions. There are three kinds.
Short-term memory (conversation history)
The running transcript of the current conversation. Usually just a growing list of messages you resend each turn. Because it consumes context-window tokens, you eventually trim or summarize old turns.
history = [
{"role": "user", "content": "My name is Ada"},
{"role": "assistant", "content": "Nice to meet you, Ada"},
]
# append each turn; summarize the oldest turns before you hit the token limit.
# Redis is a common store for per-session history with a TTL.
Long-term memory (user preferences)
Durable facts that should survive across sessions — a user's name, preferences, past decisions. Store these in a regular database (SQL or document), keyed by user, and load the relevant bits into the prompt when needed.
# persist a preference, recall it in a later session
db.save("user:42:prefs", {"tone": "concise", "timezone": "CT"})
prefs = db.load("user:42:prefs") # inject into the system prompt next time
Knowledge (documents)
The body of documents an agent can draw on to answer questions — manuals, wikis, PDFs. Too large for the context window, so you store it as embeddings in a vector database and retrieve only the relevant pieces on demand (that's RAG, Phase 10).
Vector databases
A vector DB stores embeddings and finds the nearest ones to a query vector — semantic search at scale.
| Option | Nature | Best for |
|---|---|---|
| Chroma | open-source, local/embedded | learning and small/medium apps — easiest to start |
| FAISS | in-memory library (Meta) | fast similarity search you embed in your own process |
| Pinecone | managed cloud service | production scale without running infrastructure yourself |
# store & recall knowledge with Chroma (it embeds text for you)
import chromadb
col = chromadb.Client().create_collection("kb")
col.add(documents=["Refunds take 5-7 days."], ids=["d1"])
hit = col.query(query_texts=["refund time?"], n_results=1)
Embeddings
The starting point: convert both your documents and the incoming question into vectors with an embedding model, so meaning becomes comparable as distance in vector space. Use the same model for both sides.
Chunking
Documents are too big to embed whole, so split them into passages — typically 500–1000 tokens with a little overlap so ideas aren't cut mid-sentence. Chunk size is a real tuning knob: too big dilutes relevance, too small loses context.
Vector search
Embed the user's question and find the chunks whose vectors are nearest (cosine similarity). This returns the passages most semantically related to the question, not just keyword matches.
Retrieval
Take the top matching chunks and inject them into the prompt as context, then ask the LLM to answer using only those chunks. This grounds the answer and lets you cite sources.
Re-ranking (optional at first)
Vector search is fast but approximate. A re-ranker runs a stronger model over the top ~20 candidates to reorder them by true relevance before you pass the best few to the LLM. Skip it while learning; add it when precision matters.
Minimal RAG in code
# minimal RAG with Chroma
import chromadb
client = chromadb.Client()
col = client.create_collection("docs")
col.add(documents=["Refunds take 5-7 days.", "We ship worldwide."],
ids=["d1", "d2"]) # Chroma embeds for you
hits = col.query(query_texts=["how long for a refund?"], n_results=1)
context = hits["documents"][0][0] # -> "Refunds take 5-7 days."
# then: prompt the LLM with `context` + the user question
Each agent has a specialized role and only the tools it needs. The manager (or a router) decides who acts next, passes results between them, and decides when the task is done. This mirrors how a human team divides labor — and keeps each prompt focused and reliable.
The roles in a typical crew
| Agent | Responsibility | Typical tools |
|---|---|---|
| Manager | plan the work, delegate, decide when done | routing / handoffs |
| Research | gather facts and context | search, browser, RAG |
| Coding | write or modify code | file/editor, GitHub |
| Testing | verify the work, catch regressions | test runner, sandbox |
| Reporting | summarize results for humans | doc/Slack/email |
| Deployment | ship it | CI/CD, cloud APIs |
Two common coordination shapes: a manager/hierarchy (one agent delegates to specialists and integrates their output) and a pipeline (each agent hands its result to the next, like an assembly line). Keep each agent's prompt narrow — that's what makes the whole system reliable.
# handoff pattern (OpenAI Agents SDK)
from agents import Agent
researcher = Agent(name="Researcher", instructions="Gather facts.")
writer = Agent(name="Writer", instructions="Write the report.")
manager = Agent(
name="Manager",
instructions="Delegate: research first, then hand off to the writer.",
handoffs=[researcher, writer],
)
The most important phase. Concepts stick only when you ship something end to end. Build small, finish it, then add complexity. Each idea below exercises the whole stack — APIs, prompts, memory, and often RAG.
Email assistant
Reads your inbox, drafts replies, and (after you approve) sends them. Forces the Gmail API, tool-calling, drafting prompts, and — crucially — a human-approval step before anything is sent.
PDF chatbot
Answer questions about a PDF you upload. This is a complete RAG loop end to end: extract text, chunk it, embed it, store vectors, retrieve on each question, and answer with citations. The best first project for learning RAG.
Resume reviewer
Score a resume against a job description and return structured feedback. Forces role prompting (act as a recruiter), a scoring rubric, and structured/JSON output you can render as a report.
SQL assistant
Turn plain-English questions into SQL, run them, and explain the results. Forces a database tool, natural-language-to-SQL prompting, and guardrails — read-only access and validation so the agent can't run destructive queries.
An agent on your laptop isn't a product. Learn to package it, serve it, and ship it reliably. Build up roughly in the order below.
Linux basics
Servers run Linux, so get comfortable at the shell: navigating files, permissions (chmod/chown), environment variables, processes, and reading logs. You don't need to be a sysadmin — just fluent enough to deploy and debug.
ls -la # list files incl. hidden
cd /app && cat app.log # move around, read a log
export API_KEY=... # set an env var
ps aux | grep uvicorn # find a running process
Docker
Docker packages your app and its dependencies into a reproducible image that runs identically on any machine — no "works on my laptop" surprises. A Dockerfile describes how to build it.
# Dockerfile: reproducible everywhere
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
# build & run: docker build -t myagent . && docker run -p 8000:8000 myagent
FastAPI
FastAPI is the standard way to expose your agent as an HTTP API — fast, typed, with automatic docs. It turns your agent into something other apps (and webhooks) can call.
from fastapi import FastAPI
app = FastAPI()
@app.post("/ask")
def ask(question: str):
answer = run_agent(question)
return {"answer": answer}
# run: uvicorn app:app --reload → interactive docs at /docs
CI/CD
Continuous Integration / Continuous Deployment automates the path from commit to production: on every push, run tests, build the image, and deploy if green. It removes manual, error-prone release steps.
GitHub Actions
The most common CI/CD system for GitHub repos. You describe a workflow in YAML; it runs on GitHub's servers on the events you choose (push, PR, schedule).
# .github/workflows/ci.yml
on: [push]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pip install -r requirements.txt
- run: pytest -q # fail the build if tests fail
Cloud platforms
AWS, Azure, and GCP host your containers and provide databases, secrets, queues, and scaling. Pick one and learn its basics — how to run a container, store secrets, and read logs. Simpler PaaS options (Render, Railway, Fly.io) are great for a first deploy.
Server deployment
Tying it together: build the Docker image in CI, push it to a registry, and run it on your cloud host behind HTTPS. Store secrets as environment variables in the platform (never in the image), and wire up logging/monitoring (Phase 14) before you call it done.
Real AI systems are non-deterministic and cost money per call. You can't improve what you can't see — instrument these from day one of any production agent.
Logging
Record what the agent did: which tools it called, with what arguments, and what came back. When an agent misbehaves, structured logs are your first and best clue. (You built the habit in Phase 2.)
Tracing
A trace follows a single request end to end across every LLM call, tool call, and sub-agent — with timing for each step. Essential for multi-agent systems where one user request fans out into many operations.
Evaluation
Score output quality against a fixed set of test cases (a "golden set") so you can tell whether a prompt or model change actually improved things — or quietly regressed them. This is how you iterate with confidence instead of vibes.
Cost tracking
Every call costs tokens × price, and agent loops can multiply that fast. Track spend per request, per user, and per feature so a runaway loop or a chatty prompt doesn't surprise you on the invoice.
Latency monitoring
Measure where time goes — retrieval vs generation vs tool calls — so you optimize the actual bottleneck. Users feel latency directly, and long agent chains add up.
Prompt versioning
Prompts are code: version them, review changes, and keep the ability to roll back. Tie each production output to the prompt version that produced it so you can reproduce and debug.
Tools you'll encounter: LangSmith, Langfuse, Phoenix, and Weights & Biases for LLM tracing/eval, plus general APM (Datadog, Grafana) for latency and cost dashboards.
| Window | Focus |
|---|---|
| Month 1–2 | Python + Git + REST APIs + SQL (Phases 1–4). Solid fundamentals first. |
| Month 3 | LLM fundamentals, prompting, the OpenAI API — and build a few simple AI apps (Phases 5–6). |
| Month 4 | RAG, vector databases, and an AI framework like LangGraph or the OpenAI Agents SDK (Phases 7, 9, 10). |
| Month 5 | Multi-agent workflows, tool integration (Slack, Jira, Google APIs), and FastAPI (Phases 8, 11, 13). |
| Month 6 | Build two or three end-to-end portfolio projects and deploy them (Phases 12–13). |
| Month 7–8 | Advanced topics: evaluation, observability, Docker, cloud deployment, authentication, and production best practices (Phases 13–14). |