Capstone: Research Agent with Email Report

40 min read Module 12 of 12 Topic 12 of 12

What you'll learn

  • Wire all course concepts together into one coherent production agent
  • Build a multi-tool agent with retry, budget limits, and structured logging
  • Generate and send a formatted email report from agent output
  • Test and evaluate the agent on real-world research questions
Building this at your company? For enterprise and company teams taking this to production: book a 30-minute session with our AI engineers for architecture guidance, code review, and a rollout plan for your use case.
Book a Team Session

Bringing It All Together

You’ve learned tools, ReAct, multi-agent systems, memory, context management, planning, LangGraph, error handling, and observability. This capstone is where those pieces combine into one complete system.

We’re building a Research Agent that:

  1. Accepts a research topic from the user
  2. Searches the web for relevant information across multiple queries
  3. Synthesises the findings into a structured report
  4. Formats the report as a professional email
  5. Sends it to the user

The system includes budget controls, retry logic, structured logging, and output validation, everything you’d expect in a production deployment.


Project Architecture

User provides: topic + recipient email

[Budget Check], is this request within scope?

[Research Agent], multiple search queries + URL reading

[Validation], is the research complete and well-formed?

[Synthesis Agent], turns research notes into a structured report

[Email Formatter], converts report to HTML email

[Email Sender], sends via SendGrid/SMTP

[Cost Summary], logs total tokens and USD spent

Each stage is a separate function with clear inputs and outputs. This separation makes it easy to test each stage independently and swap implementations (e.g., change the email provider) without touching unrelated code.


Step 1: Tool Definitions

Define all the tools the research agent can use:

from openai import OpenAI
import requests, json, time, smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart

client = OpenAI()

RESEARCH_TOOLS = [
    {
        "type": "function",
        "function": {
            "name": "search_web",
            "description": "Search the web for current information. Use specific, targeted queries.",
            "parameters": {
                "type": "object",
                "properties": {
                    "query":       {"type": "string", "description": "The search query"},
                    "num_results": {"type": "integer", "description": "Results to return (3-8)", "default": 5}
                },
                "required": ["query"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "read_webpage",
            "description": "Read the full text content of a URL. Use to get details from a specific page.",
            "parameters": {
                "type": "object",
                "properties": {
                    "url":        {"type": "string", "description": "The URL to read"},
                    "max_chars":  {"type": "integer", "description": "Max chars to return", "default": 3000}
                },
                "required": ["url"]
            }
        }
    }
]

Step 2: Tool Implementations

def search_web(query: str, num_results: int = 5) -> list[dict]:
    """Search using the Serper API."""
    response = requests.post(
        'https://google.serper.dev/search',
        headers={'X-API-KEY': 'YOUR_SERPER_KEY'},
        json={'q': query, 'num': num_results},
        timeout=10
    )
    results = response.json().get('organic', [])
    return [{'title': r['title'], 'url': r['link'], 'snippet': r['snippet']}
            for r in results[:num_results]]

def read_webpage(url: str, max_chars: int = 3000) -> str:
    """Fetch and extract text from a URL."""
    from bs4 import BeautifulSoup
    
    response = requests.get(url, timeout=10, headers={'User-Agent': 'ResearchBot/1.0'})
    soup = BeautifulSoup(response.text, 'html.parser')
    
    # Remove navigation, ads, scripts
    for tag in soup(['script', 'style', 'nav', 'footer', 'header']):
        tag.decompose()
    
    text = soup.get_text(separator='\n', strip=True)
    return text[:max_chars]

TOOL_REGISTRY = {
    'search_web':   search_web,
    'read_webpage': read_webpage,
}

Step 3: The Research Agent

import uuid

RESEARCH_SYSTEM = """
You are a research specialist. Your job is to find accurate, comprehensive information.

Research strategy:
1. Start with 2-3 broad searches to understand the landscape
2. Follow up with specific searches for key facts, numbers, and recent developments
3. Read 1-2 key pages in full for depth
4. Take notes on important findings as you go

When you have covered: overview, key players, recent developments, and future outlook, you're done.
Stop searching and provide a final structured summary.

Output format (at the end):
## OVERVIEW
...
## KEY PLAYERS
...  
## RECENT DEVELOPMENTS
...
## FUTURE OUTLOOK
...
## SOURCES
- url1
- url2
"""

def run_research_agent(topic: str, budget_usd: float = 0.75) -> dict:
    """Research a topic and return structured findings."""
    run_id = str(uuid.uuid4())[:8]
    cost   = 0.0
    
    messages = [
        {'role': 'system', 'content': RESEARCH_SYSTEM},
        {'role': 'user',   'content': f'Research this topic thoroughly: {topic}'},
    ]
    
    print(f"[{run_id}] Starting research: {topic}")
    
    for step in range(15):
        response = client.chat.completions.create(
            model='gpt-5.6-sol', messages=messages, tools=RESEARCH_TOOLS,
            tool_choice='auto', temperature=0.1,
        )
        
        # Track cost
        usage = response.usage
        step_cost = (usage.prompt_tokens * 2.5 + usage.completion_tokens * 10) / 1_000_000
        cost += step_cost
        
        if cost > budget_usd:
            return {'error': f'Budget exceeded at step {step}: ${cost:.3f}', 'partial': True}
        
        msg = response.choices[0].message
        messages.append(msg)
        
        if not msg.tool_calls:
            print(f"[{run_id}] Research complete after {step+1} steps. Cost: ${cost:.4f}")
            return {
                'content': msg.content,
                'topic':   topic,
                'cost':    round(cost, 4),
                'steps':   step + 1,
                'run_id':  run_id,
            }
        
        # Execute tool calls
        for tc in msg.tool_calls:
            name   = tc.function.name
            args   = json.loads(tc.function.arguments)
            print(f"[{run_id}] → {name}({list(args.keys())})")
            
            try:
                result = TOOL_REGISTRY[name](**args)
            except Exception as e:
                result = f"Error: {e}"
            
            messages.append({
                'role':         'tool',
                'tool_call_id': tc.id,
                'content':      json.dumps(result)[:4000],  # trim large results
            })
    
    return {'error': 'Max steps reached', 'partial': True}

Step 4: Synthesis and Email

Turn the raw research into a polished email:

def synthesise_report(research: dict) -> str:
    """Convert research notes into a clean email-ready report."""
    
    response = client.chat.completions.create(
        model='gpt-5.6-sol',
        messages=[{
            'role': 'user',
            'content': f"""Convert this research into a professional email report.

Topic: {research['topic']}

Research findings:
{research['content']}

Write a clear, scannable email with:
- Subject line
- 2-sentence introduction  
- 4 key findings as bullet points with specific facts/numbers
- 1-paragraph future outlook
- List of 3-5 sources with URLs

Format: plain text, professional tone."""
        }],
        temperature=0.3,
    )
    return response.choices[0].message.content

def send_email_report(to_address: str, report_text: str, topic: str):
    """Send the report via SMTP."""
    # Extract subject if present
    lines = report_text.strip().split('\n')
    subject = f"Research Report: {topic}"
    for line in lines[:3]:
        if line.lower().startswith('subject:'):
            subject = line.split(':', 1)[1].strip()
            break
    
    msg = MIMEMultipart('alternative')
    msg['Subject'] = subject
    msg['From']    = '[email protected]'
    msg['To']      = to_address
    msg.attach(MIMEText(report_text, 'plain'))
    
    # Replace with your SMTP settings
    with smtplib.SMTP('smtp.gmail.com', 587) as server:
        server.starttls()
        server.login('[email protected]', 'your-app-password')
        server.send_message(msg)
    
    print(f"Email sent to {to_address}")

Step 5: The Complete Pipeline

Wire everything together into one function:

def research_and_email(topic: str, recipient: str, budget_usd: float = 1.0) -> dict:
    """
    Complete pipeline: research a topic and email the report.
    Returns a summary of what was done and how much it cost.
    """
    print(f"\nResearch pipeline starting...")
    print(f"Topic: {topic}")
    print(f"Recipient: {recipient}")
    print(f"Budget: ${budget_usd:.2f}\n")
    
    # Phase 1: Research
    research = run_research_agent(topic, budget_usd=budget_usd * 0.7)
    
    if research.get('error'):
        return {'success': False, 'error': research['error']}
    
    # Phase 2: Synthesise
    print("Synthesising report...")
    report = synthesise_report(research)
    
    # Phase 3: Send
    print("Sending email...")
    try:
        send_email_report(recipient, report, topic)
        success = True
    except Exception as e:
        print(f"Email failed: {e}")
        success = False
    
    summary = {
        'success':        success,
        'topic':          topic,
        'recipient':      recipient,
        'research_steps': research.get('steps', 0),
        'total_cost_usd': research.get('cost', 0),
        'report_preview': report[:300] + '...',
    }
    
    print(f"\nPipeline complete!")
    print(f"Cost: ${summary['total_cost_usd']:.4f}")
    print(f"Steps: {summary['research_steps']}")
    
    return summary

# Run it!
result = research_and_email(
    topic="The impact of AI coding assistants on software developer productivity in 2024",
    recipient="[email protected]",
    budget_usd=1.00
)

print(json.dumps(result, indent=2))

Congratulations: You’ve Built a Production Agent

The system you’ve just built contains:

  • Multi-tool agent loop with search and webpage reading
  • Budget controls that prevent runaway costs
  • Retry-capable tools (add @with_retry from module 10)
  • Cost tracking logged per run
  • Multi-stage pipeline with clear separation between research, synthesis, and delivery
  • Real deliverable, an actual email in the recipient’s inbox

These are the same patterns used in real production agent systems at companies across every industry. The tools and models will change as technology advances. The patterns, separation of concerns, reliability-first design, observability, human checkpoints, are enduring principles.

Exercise: Run the full pipeline on a topic relevant to your work or industry. Before running, estimate how many search calls it will make and what it will cost. After running, compare your estimate to reality. Then add one improvement: perhaps a validation step that checks whether the research found at least 3 distinct sources before moving to synthesis, retrying if not.

Knowledge Check

3 questions to test your understanding

1 In the capstone agent, why do we validate the research output before passing it to the email writer?

2 The capstone agent has a $1.00 budget per run. A user asks it to research 50 companies. What should the agent do?

3 What is the most important test to run before declaring the capstone agent 'production-ready'?

Discussion

Questions and notes from learners on this topic

Loading discussion…

Go further with expert guidance

Ready to build production AI?
Talk to our R&D team.

These courses give you the foundation. Our embedded AI teams take you from prototype to production in 30–90 days, with your team, your codebase, your goals. Book a free strategy call to see how we can accelerate your AI initiative.

30 minutes · No obligation · Expert AI engineers, not sales reps

AI Architecture Review

Audit your current stack and identify high-impact improvements

Project Review

Get expert feedback on your AI implementation and codebase

Team Mentoring

Upskill your engineers with hands-on AI coaching sessions

AI Strategy

Define your AI roadmap, prioritization, and implementation plan