Lesson 03

Implementation Patterns

From concepts to code. Building agent components, wiring them into workflows, and understanding why the class-based pattern beats a pile of scripts.

Two Paths to Building Agents

When you set out to build an agentic system, there is a fork in the road. Neither path is wrong. The right one depends on what you are optimising for.

Build Agents choose your path Use a Framework LangChain, CrewAI, AutoGen, OpenAI Agents SDK Fast start Pre-built tools Community docs Abstractions hide detail Build From Scratch Python + LLM API directly (what we do in this series) Full control Deep understanding No lock-in More code to write

Why start from scratch?

Frameworks are valuable for production systems and rapid iteration. But building the core logic yourself reveals exactly how agents work under the hood — how prompts shape behaviour, how data flows between components, how orchestration happens. That understanding transfers to any framework you use later.

Emerging protocols like MCP (Model Context Protocol) and A2A (Agent-to-Agent) are standardising how agents discover tools and communicate with each other. The class-based patterns here underlie those standards.

Three Core Components

Whether you use a framework or build from scratch, every agentic system has the same three layers. The difference is who writes them: you or the framework.

Workflow Orchestration Sequence, data flow, agent collaboration, execution control Who runs next? What data do they get? When are we done? uses Agent Logic & Prompting Persona, capabilities, prompt engineering, decision-making What can each agent do? How does it reason? calls LLM Engine GPT-4, Claude, Llama, Gemini -- the intelligence layer Swappable. Your agent logic should not be tied to one model. You build You define You connect

LLM Engine

The intelligence behind every agent. A well-designed system makes this swappable -- you should be able to switch from GPT-4 to Claude without rewriting agent logic. In practice: a thin wrapper that accepts a prompt and returns text.

Agent Logic

Python code that defines how each agent behaves. This is where prompt engineering lives -- system prompts give agents personas (the role or character each one plays), and the code implements their specific capabilities.

Workflow Orchestration

The conductor. It decides which agents run, in what order, and how data flows between them. For deterministic workflows -- ones that run the same fixed steps every time -- this is a fixed sequence. For agentic workflows it is dynamic, decided at runtime based on agent outputs.

Code Architecture

A clean separation of concerns makes agent systems maintainable. Three layers of code, each with a single responsibility: define, configure, wire.

Agent Definitions classes = blueprints class Agent: def run(self, input) # base behaviour class ResearchAgent: def run(self, query) # search + analyse class SummarizerAgent: def run(self, text) # condense + format Reusable across workflows Define once, import anywhere import Instantiation objects = configured agents researcher = ResearchAgent("Analyst") checker = FactCheckAgent("Verifier") summarizer = SummarizerAgent("Writer") Each agent gets a role Same class, different configs wire up Workflow Logic the actual execution flow researcher.run() checker.run() summarizer.run() Result Data flows through agents Output of one = input of next

The shared chat() helper

Everything rests on one small wrapper -- chat() -- that sends a prompt to the model and returns the text reply. Agents never touch the API client directly. That indirection is what makes the LLM engine swappable.

utils.py
from openai import OpenAI
client = OpenAI()

def chat(user_prompt, system_prompt="You are a helpful assistant.", max_tokens=200):
    response = client.chat.completions.create(
        model="gpt-4",
        messages=[
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": user_prompt},
        ],
        max_tokens=max_tokens,
    )
    return response.choices[0].message.content

Agent definitions

A base Agent class enforces the contract: every agent has a name and a run() method. Specialist agents inherit from it and override run() with their own logic. The system prompt in each subclass is where the agent's persona lives.

agent_definitions.py
class Agent:
    """Base agent -- every agent needs a name and a run() method."""
    def __init__(self, name):
        self.name = name

    def run(self, input_data):
        raise NotImplementedError


class ResearchAgent(Agent):
    def run(self, query):
        return chat(query, system_prompt="You are a research analyst. Gather and summarise relevant information.")


class FactCheckerAgent(Agent):
    def run(self, text):
        return chat(text, system_prompt="You are a fact checker. Verify claims and flag anything uncertain.")


class SummarizerAgent(Agent):
    def run(self, text):
        return chat(text, system_prompt="You are a summariser. Condense the verified content into a short paragraph.")

Instantiation and workflow

Instantiation configures each agent for a specific role in this workflow. The workflow script then wires them together: each agent's output becomes the next agent's input.

main_workflow.py
# 1. Configure agents for this workflow
researcher   = ResearchAgent("Research Assistant")
fact_checker = FactCheckerAgent("Fact Checker")
summarizer   = SummarizerAgent("Summarizer")

# 2. Wire them together -- output of one feeds the next
query = "Agentic workflows in AI systems"

research_results = researcher.run(query)
verified_results = fact_checker.run(research_results)
final_summary    = summarizer.run(verified_results)

Why this separation matters

Agent definitions are reusable across workflows. The same ResearchAgent class can appear in a report-writing workflow today and an email-drafting workflow tomorrow. Instantiation configures agents for a specific job. Orchestration logic is the only part that changes between workflows.

Deterministic vs LLM-Powered

The simplest way to understand what an LLM adds to an agent: compare a hardcoded function with an LLM-powered one handling the same job.

Deterministic Function if "weather" in query: return "Sunny, 24°C" elif "time" in query: return "12:00 PM" elif "hello" in query: return "Hello!" else: return "I don't understand." Limitations: Only handles keywords it was coded for No reasoning -- pattern matching only Every new case needs a new branch LLM-Powered Agent return chat(query, system_prompt="You are helpful...") Advantages: Handles any query, even ones never seen before Reasons about context and nuance New capabilities from better prompts, not more code Tradeoffs: Non-deterministic -- same input, different output Latency and cost per call Can hallucinate when uncertain

This is the fundamental tradeoff: deterministic functions are predictable and fast but brittle. LLM-powered agents are flexible and reasoning-capable but slower and less predictable. Real agentic systems use both -- deterministic logic for well-known paths, LLM calls for the parts that require judgment.

A Complete Workflow

Here is how a three-agent pipeline looks end to end. Each agent transforms the data and passes it forward. The workflow itself is still deterministic -- the agents always run in the same order. Making it agentic means adding a decision step that can loop back or branch based on what the agents produce.

Query "Agentic AI" Research Agent searches + gathers info LLM findings Fact Checker verifies claims LLM verified Summarizer condenses + formats LLM Result verified summary Three LLM agents, one fixed execution order -- still a deterministic workflow. To make it agentic: add a decision step that can loop back when quality is insufficient.

Exercise: Data Pipeline With Error Handling

This worked example shows the pattern in a runnable form. Two agents -- a fetcher and a processor -- wired into a pipeline. No LLM yet: the point is the class structure and the early-exit on error, which applies identically when real API calls replace the simulated data.

user_id "123" DataFetchingAgent source: "MainUserDB" returns full profile dict or {error: "not found"} profile DataProcessingAgent fields: [name, occupation, id] extracts only what is needed processed_info {name, occupation} error path: workflow stops early if user not found
agent_definitions.py
class Agent:
    def __init__(self, name):
        self.name = name

    def execute(self, data=None):
        raise NotImplementedError


class DataFetchingAgent(Agent):
    def __init__(self, name, data_source):
        super().__init__(name)
        self.data_source = data_source

    def execute(self, user_id):
        # Simulated -- a real agent would call an API or database here
        users = {
            "123": {"name": "Alice Wonderland", "occupation": "Dreamer", "user_id": "123"},
        }
        return users.get(user_id, {"error": "User not found"})


class DataProcessingAgent(Agent):
    def __init__(self, name, fields_to_extract=None):
        super().__init__(name)
        self.fields_to_extract = fields_to_extract or ["name"]

    def execute(self, fetched_data):
        if "error" in fetched_data:
            return {"error": fetched_data["error"]}
        return {f: fetched_data.get(f, "N/A") for f in self.fields_to_extract}

Notice the three things worth carrying forward: inheritance guarantees every agent exposes the same interface; configuration at init (data_source, fields_to_extract) makes agents reusable with different settings; and the error check inside DataProcessingAgent.execute() means a failure in the fetcher surfaces cleanly instead of crashing half-way through.

main_workflow.py
from workflow_agents.agent_definitions import DataFetchingAgent, DataProcessingAgent

def run_user_data_workflow(user_id):
    # Instantiation -- configure each agent
    fetcher = DataFetchingAgent(name="ProfileFetcher", data_source="MainUserDB")
    processor = DataProcessingAgent(
        name="InfoExtractor",
        fields_to_extract=["name", "occupation", "user_id"],
    )

    # Workflow -- wire agents together
    user_data = fetcher.execute(user_id=user_id)

    if "error" in user_data:        # early exit on failure
        return {"status": "error", "detail": user_data["error"]}

    report = processor.execute(fetched_data=user_data)
    return {"status": "success", "data": report}


if __name__ == "__main__":
    print(run_user_data_workflow(user_id="123"))  # happy path
    print(run_user_data_workflow(user_id="999"))  # error path
output
{'status': 'success', 'data': {'name': 'Alice Wonderland', 'occupation': 'Dreamer', 'user_id': '123'}}
{'status': 'error', 'detail': 'User not found'}

Architecture in action

This exercise is deliberately simple -- no LLM, no API calls. The point is the pattern: agent classes define capabilities, instantiation configures them, and the workflow script wires them together. Once you are comfortable with this skeleton, replacing the simulated data with a real chat() call is a one-line change per agent.

Lesson recap