A tutorial project demonstrating how to build agentic AI applications in Python. AutoAgent takes a research topic, investigates it with multiple specialized AI agents, produces a quality-reviewed report, and generates a two-host podcast episode — all powered by local AI inference via MindRouter.
Slides: tech_talk_agents_april_2026.pptx
- What This Project Demonstrates
- Architecture Overview
- Prerequisites
- Setup
- Tutorial: Building It Step by Step
- Running the Application
- How It Works
- Extending the System
- Troubleshooting
- Multi-agent orchestration with PydanticAI — how to build specialized agents that collaborate on complex tasks
- Tool integration — wrapping external APIs (Brave Search) as agent tools
- Iterative evaluation loops — using an evaluator agent as a quality gate with feedback-driven improvement
- Structured outputs — using Pydantic models to ensure agents return well-typed, parseable results
- Memory management — persistent file-based memory so agents can build on prior work
- Skill files — separating agent behavior instructions from code
- Real-time status updates — using Server-Sent Events (SSE) to stream agent activity to a web UI
- Text-to-Speech podcast generation — synthesizing multi-voice audio with Mindrouter's Kokoro TTS engine
- Timeouts and retries — production-grade error handling for LLM calls
┌─────────────────────────────────────────────────────────┐
│ Web Browser │
│ ┌─────────────┐ ┌──────────────┐ ┌───────────────┐ │
│ │ Topic Input │ │ Status Panel │ │ Results Tabs │ │
│ └──────┬──────┘ └──────▲───────┘ └───────▲───────┘ │
└─────────┼────────────────┼──────────────────┼───────────┘
│ POST │ SSE │ GET
▼ │ │
┌─────────────────────────────────────────────────────────┐
│ FastAPI Server │
│ │
│ ┌─────────────────── Orchestrator ──────────────────┐ │
│ │ │ │
│ │ ┌────────────┐ ┌────────┐ ┌───────────┐ │ │
│ │ │ Researcher │────▶│ Writer │────▶│ Evaluator │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ • Search │ │ │ │ Pass? ────┼─-┼──┼──▶ Podcaster ──▶ TTS Audio
│ │ │ • Scrape │ │ │ │ │ │ │ │
│ │ │ • Memory │ │ │ │ │ No │ │ │
│ │ └────────────┘ └────────┘ │ ▼ │ │ │
│ │ ▲ │ Loop back │ │ │
│ │ └────────────────────────────┘───────────┘ │ │
│ └────────────────────────────────────────────────── ┘ │
│ │
│ ┌──────────┐ ┌──────────┐ ┌─────────────────────┐ │
│ │ Memory │ │ Skills │ │ Mindrouter API │ │
│ │ (JSON) │ │ (.md) │ │ (LLM + TTS + STT) │ │
│ └──────────┘ └──────────┘ └─────────────────────┘ │
└─────────────────────────────────────────────────────────┘
| Agent | Role | Tools | Output |
|---|---|---|---|
| Researcher | Gathers information via web search and scraping | brave_web_search, scrape_url, recall_memory, save_fact |
ResearchFindings |
| Writer | Transforms research into a polished report | None (pure generation) | WrittenReport |
| Evaluator | Scores quality, provides improvement feedback | None (pure evaluation) | Evaluation |
| Podcaster | Creates a two-host podcast script | None (pure generation) | PodcastScript |
- Python 3.11+
- Mindrouter API key — Get one at https://mindrouter.uidaho.edu/dashboard
- Brave Search API key — Free tier at https://api-dashboard.search.brave.com
- ffmpeg — Required by pydub for audio processing (
brew install ffmpegon macOS)
# Clone the repository
git clone https://github.com/sheneman/autoagent.git
cd autoagent
# Create a virtual environment
python -m venv .venv
source .venv/bin/activate
# Install dependencies
pip install -r requirements.txt
# Configure API keys
cp .env.example .env
# Edit .env with your Mindrouter and Brave Search API keysThis section walks through how each component was built and why.
autoagent/
├── guide.md # System-level instructions
├── app/
│ ├── config.py # Environment-based configuration
│ ├── main.py # FastAPI web application
│ ├── agents/
│ │ ├── deps.py # Shared dependency container
│ │ ├── orchestrator.py # Pipeline coordinator
│ │ ├── researcher.py # Web research agent
│ │ ├── writer.py # Report writing agent
│ │ ├── evaluator.py # Quality evaluation agent
│ │ └── podcaster.py # Podcast generation agent
│ ├── tools/
│ │ ├── brave_search.py # Brave Search API wrapper
│ │ └── web_scraper.py # HTML content extractor
│ ├── memory/
│ │ └── manager.py # File-based memory store
│ ├── skills/ # Agent behavior guides (markdown)
│ │ ├── research.md
│ │ ├── writing.md
│ │ ├── evaluation.md
│ │ └── podcasting.md
│ └── static/
│ └── index.html # Web interface
└── output/ # Generated reports and audio
Why this structure? Agents, tools, and skills are separated so each can be modified independently. Skills are markdown files so non-developers can tune agent behavior without touching Python code.
The Config dataclass in app/config.py loads all settings from environment
variables with sensible defaults:
@dataclass
class Config:
mindrouter_base_url: str = field(
default_factory=lambda: os.getenv("MINDROUTER_BASE_URL", "https://mindrouter.uidaho.edu/v1")
)
mindrouter_api_key: str = field(
default_factory=lambda: os.getenv("MINDROUTER_API_KEY", "")
)
# ... more settingsKey design choice: Using a dataclass instead of raw os.getenv() calls
gives you type safety and a single place to see all configuration. Every
component receives the same Config instance through dependency injection.
Tools are async Python functions that agents can call. PydanticAI injects
dependencies via RunContext[AgentDeps]:
@agent.tool
async def brave_web_search(ctx: RunContext[AgentDeps], query: str) -> str:
"""Search the web using Brave Search."""
headers = {"X-Subscription-Token": ctx.deps.config.brave_api_key}
resp = await ctx.deps.http_client.get(
"https://api.search.brave.com/res/v1/web/search",
headers=headers,
params={"q": query, "count": 8},
)
# ... format and return resultsWhy httpx instead of a library? Direct HTTP calls give full control over timeouts, retries, and response parsing. The tool is just a function — no framework-specific abstractions to learn.
The MemoryManager stores facts, conversations, and preferences as JSON files:
# Saving a fact during research
memory.add(MemoryEntry(
content="GPT-4 was released in March 2023",
category="facts",
tags=["gpt-4", "openai", "timeline"],
source="https://example.com/article",
))
# Recalling facts later
results = memory.search("GPT-4", category="facts")Why file-based? It's the simplest approach that works — no database setup, easy to inspect, and trivial to back up. For production, swap in SQLite, Redis, or a vector database.
Each agent is a PydanticAI Agent with a system prompt, dependency type,
and structured result type:
class ResearchFindings(BaseModel):
topic: str
key_findings: list[str]
sources: list[str]
gaps: list[str]
raw_notes: str
researcher_agent = Agent(
deps_type=AgentDeps,
output_type=ResearchFindings,
system_prompt="You are a meticulous research agent...",
retries=2,
)Why structured outputs? Pydantic models ensure the agent returns data
in a predictable format. The writer can reliably access
research.key_findings instead of parsing free text. If the model returns
malformed data, PydanticAI retries automatically.
The orchestrator is plain Python — no LLM calls, just sequential logic with an evaluation loop:
for iteration in range(config.max_research_iterations):
research = await researcher_agent.run(prompt, deps=deps, model=model)
report = await writer_agent.run(write_prompt, deps=deps, model=model)
evaluation = await evaluator_agent.run(eval_prompt, deps=deps, model=model)
if evaluation.output.passed:
break # Quality threshold met
# Otherwise, loop back with evaluator feedbackWhy not an LLM orchestrator? An explicit Python loop is predictable, debuggable, and testable. LLM orchestrators add latency and can make surprising routing decisions. Use LLM orchestration when the workflow is genuinely dynamic; use code orchestration when the steps are known.
The frontend uses vanilla HTML/CSS/JS with Server-Sent Events for real-time status updates:
const eventSource = new EventSource(`/api/status/${runId}`);
eventSource.onmessage = (e) => {
const data = JSON.parse(e.data);
addStatus(data.stage, data.message);
updatePipeline(data.stage); // Highlight active agent
};Why SSE instead of WebSockets? SSE is simpler for one-way server→client streaming. The UI only needs to receive status updates, not send frequent messages back. One less protocol to debug.
The podcaster agent writes a two-host script, which is then synthesized segment-by-segment using Mindrouter's Kokoro TTS:
# Each script line is tagged with a speaker
segments = parse_script_segments(script)
# [{"speaker": "ALEX", "text": "..."}, {"speaker": "SAM", "text": "..."}, ...]
# Different voices for each host
voice_map = {"ALEX": "bm_george", "SAM": "af_heart"}
for seg in segments:
resp = await http_client.post(
f"{base_url}/audio/speech",
json={"model": "kokoro", "input": seg["text"], "voice": voice_map[seg["speaker"]], "speed": 1.2},
)
# Append audio segmentAudio segments are concatenated with 400ms pauses between speakers using pydub.
# Start the server
uvicorn app.main:app --reload --port 8000
# Open in your browser
open http://localhost:8000- Enter a research topic (e.g., "The impact of quantum computing on cryptography")
- Watch the pipeline tracker as each agent works
- View the generated report, evaluation scores, and podcast
- You enter a topic → POST to
/api/research - Orchestrator loads skill files and checks memory for prior research
- Researcher searches the web via Brave Search, scrapes promising pages, stores key facts in memory
- Writer creates a report using the structured research findings
- Evaluator scores the report on 5 dimensions. If any score < 7 or overall < 7.5, the loop repeats with specific feedback
- Podcaster writes a script with two hosts (Alex and Sam) discussing the research
- TTS generates audio segment by segment using Mindrouter's Kokoro engine, with different voices per host
- Results appear in the UI — report, evaluation scores, podcast script, and audio player
- Create
app/agents/your_agent.pywith a PydanticAIAgent - Define a Pydantic
BaseModelfor its structured output - Add it to the orchestrator pipeline
- Create a function in
app/tools/following theRunContext[AgentDeps]pattern - Register it with
agent.tool(your_function)
- Create
app/skills/your_skill.mdwith guidelines - Load it in the orchestrator and inject into the agent prompt
Change MINDROUTER_MODEL in .env. Good options on Mindrouter:
qwen3:32b— Fast, good tool callingllama3.3:70b— Higher quality, slowermistral-large— Good balancegemma-4— Google's latest
| Problem | Solution |
|---|---|
| "Missing API key" | Check your .env file has valid MINDROUTER_API_KEY and BRAVE_API_KEY |
| Timeouts | Increase REQUEST_TIMEOUT in .env (default: 120s) |
| TTS audio missing | Install ffmpeg: brew install ffmpeg (macOS) or apt install ffmpeg (Linux) |
| Agent loops forever | MAX_RESEARCH_ITERATIONS caps the loop (default: 3) |
| Model errors | Check available models at https://mindrouter.uidaho.edu/models |
| Port in use | Change PORT in .env or use uvicorn app.main:app --port 8001 |
| Component | Technology | Why |
|---|---|---|
| Agent Framework | PydanticAI | Type-safe, fast, great docs, Python-native |
| LLM Inference | MindRouter | Local, FERPA-compliant, OpenAI-compatible |
| Web Search | Brave Search API | Privacy-focused, generous free tier |
| TTS Engine | Kokoro (via Mindrouter) | High-quality, multiple voices |
| Web Framework | FastAPI | Async, fast, auto-docs |
| Status Streaming | Server-Sent Events | Simple one-way streaming |
| Audio Processing | pydub | Easy audio concatenation |
Built as a tutorial for building agentic AI applications.
