<<<<<<< HEAD ======= >>>>>>> origin/main Agentic AI Developer Roadmap <<<<<<< HEAD ======= >>>>>>> origin/main
Learning Roadmap

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.

How to use this: don't try to master every phase before moving on. Learn enough of phases 1–5 to be dangerous, then start building (Phase 12) in parallel — real projects are what make the earlier concepts stick. A suggested month-by-month plan is at the end.
Phase 1

Learn Python

Python is the lingua franca of AI. Everything below — frameworks, tools, RAG pipelines — is Python. The sections that follow are a complete, example-driven reference. Skim what you know; study the gotchas.

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.

Tip: Everything in Python is an object, including numbers and functions. 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:

CategoryTypesMutable?Example
Numericint, float, complexNo42, 3.14, 2+3j
BooleanboolNoTrue, False
TextstrNo"hello"
Sequencelist, tuple, rangelist only[1,2], (1,2)
MappingdictYes{"a": 1}
Setset, frozensetset only{1, 2}
Binarybytes, bytearraybytearrayb"abc"
NoneNoneTypeNone

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
Gotcha: 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
Short-circuit trick: 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)
Idiom: Prefer iterating the object directly (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)
Classic gotcha — mutable default arguments: defaults are evaluated once at definition time.
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 call

Returning 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
Tip: Building a big string in a loop? Append to a 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]
Performance: append/pop at the end and index access are O(1). Insert/remove/pop at the front are O(n) — for a queue, use 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)
When to use: tuple for a fixed heterogeneous record whose meaning is positional (a coordinate, an RGB triple, a DB row); list for a growing homogeneous collection.
Note: "Immutable" means you can't rebind elements. If an element is itself mutable, it can still change: 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)
Keys must be hashable: strings, numbers, and tuples work; lists and dicts do not.

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

ModeMeaning
"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)
Anti-patterns to avoid: never write a bare 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.
EAFP vs LBYL: Python favors "Easier to Ask Forgiveness than Permission" — 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)
Convention: Python has no true private members. One underscore _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
Why it matters: installing packages globally leads to version conflicts between projects. A venv keeps Project A on 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
Best practice: always 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'}
Style: keep comprehensions to a single, readable line. If you need two filters and a nested loop, a regular 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
Don't assign a lambda to a name to reuse it (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]
Pythonic take: a comprehension is usually clearer than 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
Why care: generators let you process a 10 GB file line by line, or model an infinite stream, without ever loading it all. They're exhausted after one pass — iterate again and you get nothing. The 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>
Use them for any acquire/release pair: files, network sockets, database transactions, locks (with lock:), or temporarily changing state. Put the yield inside a try/finally if cleanup must run even when the block raises.
Phase 2

Software Engineering

Agents are software. Before the AI parts, you need the habits that keep any codebase shippable: version control, HTTP, auth, config, logging, and tests.

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.

MethodMeaningStatus ranges
GETread a resource2xx success
POSTcreate / perform an action3xx redirect
PUT/PATCHupdate4xx client error (401, 404, 429)
DELETEremove5xx 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:

project/ ├── app.py # entry point ├── agents/ # agent definitions ├── tools/ # callable tools the agents use ├── prompts/ # prompt templates ├── memory/ # short- & long-term memory ├── database/ # models, queries, migrations └── config/ # settings, env loading
Phase 3

Databases

Agents need to store state, look up facts, and cache expensive results. Know one relational DB well, plus a document store and a cache.

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
Phase 4

APIs

One of the biggest force-multipliers. If you can integrate an API, you can turn it into an agent tool. Most "useful agent" ideas are just "LLM + the right APIs."

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

APIWhat you can automateAuth
Google Ads APIcampaigns, budgets, performance reportsOAuth 2.0 + developer token
Meta APIFacebook/Instagram ads, pages, insightsOAuth 2.0
Slack APIpost messages, read channels, slash commandsBot token (OAuth)
Jira APIcreate/update issues, sprints, JQL searchAPI token / OAuth
GitHub APIPRs, issues, repos, ActionsPAT / OAuth
Gmail APIread, send, label, search emailOAuth 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
Production habits for any API: read the rate limits and paginate large result sets; retry with exponential backoff on 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).
Phase 5

AI Fundamentals

You don't need to train models or read papers. You need a working mental model of how LLMs behave so you can build reliably on top of them.

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)
Phase 6

Prompt Engineering

Treat prompts like programs: precise inputs, defined output format, examples, and tests. This is the highest-leverage skill for reliable agents.

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."""
Prompt like an engineer: keep prompts in version control (Phase 14), test them against a fixed set of inputs, change one thing at a time, and always validate structured output before trusting it downstream.
Phase 7

AI Frameworks

Frameworks handle the plumbing — message loops, tool routing, memory, retries — so you focus on behavior. Learn one deeply rather than all of them shallowly.

FrameworkSweet spot
LangChainBroad ecosystem of integrations; good for chaining LLM calls and tools
LangGraphGraph-based control flow for stateful, cyclic agent workflows — strong for complex multi-step/multi-agent logic
OpenAI Agents SDKLightweight, first-party agents + tools + handoffs; great starting point
LlamaIndexRAG-first: ingestion, indexing, retrieval over your data
CrewAIRole-based crews of collaborating agents; quick to prototype teams
AutoGenConversational 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"
Phase 8

Build Tools

An agent is only as useful as the tools it can call. A "tool" is just a well-described function the LLM can invoke — this is where Phase 4 (APIs) pays off.

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

ToolWrapsExample use
Google Searcha search APIlook up current facts the model doesn't know
SlackSlack APIpost updates, read a channel
Google Ads / Meta Adsads APIspull performance, adjust budgets
DatabaseSQL/NoSQL driveranswer questions from your data
Calculatorsafe eval / mathexact arithmetic (LLMs are unreliable at math)
EmailGmail/SMTPdraft and (after approval) send mail
Browserheadless browserread pages, fill forms, scrape
GitHubGitHub APIopen PRs, read issues, comment
JiraJira APIcreate/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
Tool design rules: one clear job per tool; a descriptive name and docstring (the model chooses tools from these); typed, validated arguments; and return concise strings/JSON — not giant blobs that flood the context window. Gate destructive tools (send email, delete row, spend money) behind a human-approval step.
Phase 9

Memory

LLMs are stateless between calls. Memory is how an agent remembers the conversation, the user, and relevant knowledge across turns and sessions.

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.

OptionNatureBest for
Chromaopen-source, local/embeddedlearning and small/medium apps — easiest to start
FAISSin-memory library (Meta)fast similarity search you embed in your own process
Pineconemanaged cloud serviceproduction 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)
Phase 10

RAG — Retrieval-Augmented Generation

Essential. RAG grounds the model in your data so it answers from real documents instead of hallucinating.

User asks a question │ Embed the question & search the vector DB │ Retrieve the most relevant document chunks │ Put those chunks into the prompt │ LLM answers using ONLY those documents

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
Why it matters: RAG is how you build a chatbot over your company's docs, a PDF Q&A tool, or a support agent — without fine-tuning and with citations you can trust.
Phase 11

Multi-Agent Systems

Where it gets exciting. Instead of one do-everything agent, a manager delegates to specialists — each with its own role, tools, and prompt.

Manager Agent │ ┌────────┬───┴────┬─────────┬───────────┐ Research Coding Testing Reporting Deployment Agent Agent Agent Agent Agent

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

AgentResponsibilityTypical tools
Managerplan the work, delegate, decide when donerouting / handoffs
Researchgather facts and contextsearch, browser, RAG
Codingwrite or modify codefile/editor, GitHub
Testingverify the work, catch regressionstest runner, sandbox
Reportingsummarize results for humansdoc/Slack/email
Deploymentship itCI/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],
)
Cost & control: multi-agent loops can spiral into many LLM calls. Cap iterations, add a human-approval step for high-stakes actions, and log every hop (Phase 14) so you can see what happened.
Phase 12

Build Real Projects

The most important phase. Concepts stick only when you ship something end to end. Build small, finish it, then add complexity.

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.

Portfolio tip: finish two or three projects, put them on GitHub with a clear README and a short demo (a GIF or Loom), and deploy at least one (Phase 13). A deployed, working agent beats a dozen half-built notebooks.
Phase 13

Cloud & Deployment

An agent on your laptop isn't a product. Learn to package it, serve it, and ship it reliably.

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.

Phase 14

Observability

Real AI systems are non-deterministic and cost money per call. You can't improve what you can't see — instrument everything.

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.

Study Plan

A Realistic Timeline

Roughly six months to job-ready, with two optional months for production depth. Adjust to your pace — the key is building alongside learning.

WindowFocus
Month 1–2Python + Git + REST APIs + SQL (Phases 1–4). Solid fundamentals first.
Month 3LLM fundamentals, prompting, the OpenAI API — and build a few simple AI apps (Phases 5–6).
Month 4RAG, vector databases, and an AI framework like LangGraph or the OpenAI Agents SDK (Phases 7, 9, 10).
Month 5Multi-agent workflows, tool integration (Slack, Jira, Google APIs), and FastAPI (Phases 8, 11, 13).
Month 6Build two or three end-to-end portfolio projects and deploy them (Phases 12–13).
Month 7–8 (optional)Advanced topics: evaluation, observability, Docker, cloud deployment, authentication, and production best practices (Phases 13–14).
The one habit that matters most: start building real projects (Phase 12) by Month 3 and never stop. Everything else — frameworks, RAG, memory — makes far more sense once you've felt the problem it solves.

End of roadmap. Phase 1 examples run on Python 3.10+; later phases show representative code for the OpenAI Agents SDK, Chroma, FastAPI, and friends. Save this file anywhere and open it in a browser — no internet connection required.