Lesson 2 of 5 · 18 min

Lesson 2: Your First Agent

Course: Building AI Agents

Estimated time: 30 minutes

Difficulty: Intermediate Builder


Most "getting started with agents" tutorials show you a minimal example that technically runs but doesn't resemble anything you'd actually ship. You follow the steps, it works, and then you're stuck because the gap between that hello-world demo and a real agent that does something useful is not obvious.

This lesson bridges that gap. You're going to build an agent that monitors a website and alerts you when it changes. It's simple enough to finish in an hour, but it has all the pieces of a real agent: a system prompt, tools, a memory mechanism, and a loop. Once you understand how these pieces fit together, building more complex agents is just extending the same structure.


The Four Parts of Every Agent

Before you write a line of code, understand what you're building. Every agent - no matter the platform or framework - has four components:

System prompt. This is the agent's identity, purpose, and instructions. It tells the model who it is, what it's supposed to do, how to behave, and what tools are available. A bad system prompt is the most common reason agents behave strangely. Most debugging sessions that feel like model problems are actually system prompt problems.

Tools. These are functions the agent can call during its loop. Without tools, the agent can only talk. With tools, it can browse the web, read files, call APIs, send messages, run code. Tools are what turn a chatbot into something that does work. You'll build one custom tool in this lesson.

Memory. Agents need to remember things across loop iterations. Short-term memory is the conversation context - what's happened in this session. Longer-term memory is usually a file or database that persists across sessions. The simplest form of agent memory is just writing state to a file and reading it at the start of each run.

Loop. The agent runs until a stopping condition. That might be: "user says done," "task is complete," "maximum 10 steps reached," or "error encountered." Without an explicit stopping condition, you'll end up with agents that either terminate too early or run forever.

Every time an agent behaves wrong, it's one of these four things. Not enough context in the system prompt. Wrong tools, missing tools, or tools with bad error handling. Missing or corrupt memory. A broken loop condition. Keep that mental model and debugging becomes systematic instead of guesswork.


Platform Options

You have four real choices for building your first agent. Each has trade-offs.

Raw API (OpenAI / Anthropic). Call the API directly, implement the tool-call loop yourself in Python or TypeScript. Maximum control, maximum verbosity. Good for understanding exactly what's happening. Takes 3-4x longer than using a framework for the same result. Recommended if you want to understand how agents work at the mechanism level.

LangGraph. A graph-based framework for building stateful agents. Great for complex workflows where you need precise control over state transitions. Steeper learning curve. Overkill for most first agents but excellent for production systems with complex routing.

CrewAI. Higher-level abstraction. You define agents by role and goal, define tasks, and let the framework handle orchestration. Gets you running fast. Less control over the low-level loop behavior. Good for multi-agent pipelines.

OpenClaw (or equivalent agent runtime). If you're running OpenClaw, you already have an agent runtime available. System prompts, tool definitions, and loops are handled by the runtime. You write skills (tools) and system prompts, not loop infrastructure. Good for building on top of existing infrastructure fast.

For this lesson, the examples use Python with the Anthropic API directly. Not because it's the fastest way, but because seeing the raw loop makes the concepts obvious. After you understand the loop, switching to a framework is straightforward.


Building the Website Monitor Agent

Here's what the agent will do:

  1. Accept a URL and a check interval as configuration
  2. Fetch the current content of the URL
  3. Compare it to the last known content (stored in a file)
  4. If it's changed, alert you with a summary of what changed
  5. Store the new content and wait for the next check

This is a complete agent with real utility. Let's build it.

The System Prompt


You are a website monitoring agent. Your job is to check a URL, compare the current content to the stored version, determine if anything meaningful has changed, and report what changed.

You have access to two tools:
- fetch_url: fetch the text content of a URL
- read_file: read the stored version of a URL's content
- write_file: write the current content to storage

When you run:
1. Fetch the current content of the URL
2. Read the stored content (if it exists)
3. Compare the two
4. If there's a meaningful change, write a clear summary of what changed
5. Write the new content to storage

A "meaningful change" means new text, removed text, or changed text that would matter to a person tracking this page. Ignore minor whitespace differences, formatting changes, or dynamic timestamp updates.

When you're done, output either:
- CHANGED: [summary of what changed]
- UNCHANGED: [no changes detected]

Notice what this system prompt does. It defines purpose, lists the available tools explicitly, walks through the exact sequence of steps, defines what "meaningful" means (because without that, the model makes its own call), and specifies the output format. That's a complete system prompt. Most bugs come from leaving out one of those things.

The Tools


import anthropic
import requests
import os
import json

client = anthropic.Anthropic()

def fetch_url(url: str) -> str:
    """Fetch the text content of a URL."""
    try:
        response = requests.get(url, timeout=10)
        response.raise_for_status()
        return response.text[:10000]  # Limit to 10k chars for context window
    except Exception as e:
        return f"ERROR: {str(e)}"

def read_file(path: str) -> str:
    """Read stored content from a file."""
    if not os.path.exists(path):
        return "FILE_NOT_FOUND"
    with open(path, "r") as f:
        return f.read()

def write_file(path: str, content: str) -> str:
    """Write content to a file."""
    os.makedirs(os.path.dirname(path), exist_ok=True)
    with open(path, "w") as f:
        f.write(content)
    return "OK"

# Tool definitions for the API
tools = [
    {
        "name": "fetch_url",
        "description": "Fetch the text content of a URL.",
        "input_schema": {
            "type": "object",
            "properties": {
                "url": {"type": "string", "description": "The URL to fetch"}
            },
            "required": ["url"]
        }
    },
    {
        "name": "read_file",
        "description": "Read stored content from a file path.",
        "input_schema": {
            "type": "object",
            "properties": {
                "path": {"type": "string", "description": "File path to read"}
            },
            "required": ["path"]
        }
    },
    {
        "name": "write_file",
        "description": "Write content to a file path.",
        "input_schema": {
            "type": "object",
            "properties": {
                "path": {"type": "string", "description": "File path to write"},
                "content": {"type": "string", "description": "Content to write"}
            },
            "required": ["path", "content"]
        }
    }
]

The Loop


SYSTEM_PROMPT = """You are a website monitoring agent..."""  # Full prompt from above

def run_agent(url: str, storage_dir: str = "./monitor_data"):
    storage_path = f"{storage_dir}/{url.replace('/', '_').replace(':', '')}.txt"
    
    messages = [
        {
            "role": "user",
            "content": f"Check this URL for changes: {url}\nStorage file: {storage_path}"
        }
    ]
    
    # The loop - runs until model stops calling tools
    while True:
        response = client.messages.create(
            model="claude-opus-4-5",
            max_tokens=4096,
            system=SYSTEM_PROMPT,
            tools=tools,
            messages=messages
        )
        
        # Add model response to message history
        messages.append({"role": "assistant", "content": response.content})
        
        # If model is done (no more tool calls), extract and return the result
        if response.stop_reason == "end_turn":
            final_text = next(
                (block.text for block in response.content if hasattr(block, "text")),
                "No output"
            )
            return final_text
        
        # Handle tool calls
        tool_results = []
        for block in response.content:
            if block.type == "tool_use":
                # Dispatch to the right function
                if block.name == "fetch_url":
                    result = fetch_url(block.input["url"])
                elif block.name == "read_file":
                    result = read_file(block.input["path"])
                elif block.name == "write_file":
                    result = write_file(block.input["path"], block.input["content"])
                else:
                    result = f"Unknown tool: {block.name}"
                
                tool_results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,
                    "content": result
                })
        
        # Feed tool results back into the conversation
        messages.append({"role": "user", "content": tool_results})

# Run it
if __name__ == "__main__":
    url = "https://stripe.com/pricing"
    result = run_agent(url)
    print(result)

That's the complete loop. Each iteration: call the API, check if the model is done, if not handle the tool calls, feed results back, repeat. The loop terminates when stop_reason == "end_turn", which happens when the model generates text output instead of more tool calls.


What's Actually Happening

Walk through a first run mentally:

  1. The model reads the message: "Check this URL: stripe.com/pricing, storage at ./monitor_data/..."
  2. The model decides to call fetch_url("https://stripe.com/pricing") - it needs the current content
  3. Your code fetches the URL, returns the HTML
  4. The model reads the HTML, decides to call read_file(storage_path) - it needs the old version
  5. read_file returns FILE_NOT_FOUND (first run, nothing stored yet)
  6. The model decides to call write_file(storage_path, current_html) - stores the first version
  7. The model generates text: UNCHANGED: No previous version to compare against. Stored initial content.
  8. stop_reason == "end_turn", loop exits

On the next run, step 5 returns the stored content, the model compares old vs new, and either reports changes or reports none.

This is the full perceive-decide-act-learn loop in code. The model perceives via tool results, decides what tool to call next, acts by calling it, and the results update the conversation history - which is the agent's working memory for this session.


Adding Alerting

The monitor isn't useful if you have to run it manually and read the output. Add a simple email or Slack alert tool:


def send_slack_alert(webhook_url: str, message: str) -> str:
    """Send a message to a Slack webhook."""
    try:
        requests.post(webhook_url, json={"text": message})
        return "OK"
    except Exception as e:
        return f"ERROR: {str(e)}"

Add it to your tools list and update the system prompt to say: "If the page has changed, use send_slack_alert to notify the team before writing the new content to storage."

Now the agent is actually doing something - monitoring, comparing, alerting, storing. Four tool calls per run, but from the outside it looks like one operation.


Exercise: Build Your Website Monitor

Clone the code structure above and build your version:

  1. Set up your tools - fetch_url, read_file, write_file are in the template above. Add at least one alert channel (Slack webhook, email via SendGrid, or just write to a log file).
  1. Write your system prompt. Use the structure: role, tools available, step sequence, decision criteria, output format. Don't skip the decision criteria - "meaningful change" needs to be defined for your use case.
  1. Implement the loop. The template above is complete - you mostly need to fill in your system prompt and tool set.
  1. Run it on a URL you actually care about monitoring: a competitor's pricing page, a product you track, a job board, a news section.
  1. Run it twice. First run should store content. Second run should report no changes. Then manually change something in the stored file and run again - verify it catches the "change."

When it works, you have a real agent. Not a demo. Not a tutorial toy. An agent you can schedule to run every hour and actually use.


Key Takeaway

Every agent is the same four parts: system prompt, tools, memory, loop. Understand each part and you can build any agent. Debug each part separately and you can fix any agent.


What's Next

Your agent has three basic tools. Lesson 3 is about making agents genuinely powerful - connecting them to real data sources, external APIs, and the full range of tools that turn a capable agent into an indispensable one.