Architecture
The whole system
Section titled “The whole system”flowchart TB
subgraph browser["Browser · GitHub Pages"]
UI["client/ · React
polls every 1.5s"]
end
subgraph render["FastAPI · Render free tier"]
API["app/main.py"]
BG["BackgroundTasks"]
AGENT["app/agent/research.py
Pydantic AI"]
MCP["/mcp
FastMCP server"]
TOOLS["app/tools/wikipedia.py"]
end
subgraph external["External services"]
DB[("Supabase Postgres
runs · steps · memories")]
GEM["Gemini API"]
WIKI["Wikipedia API"]
TAV["Tavily MCP server
(hosted by them)"]
end
CLAUDE["Claude Desktop
or any MCP client"]
UI -->|"POST /runs → 202 run_id"| API
UI -->|"GET /runs/id"| API
API -->|"queue"| BG
BG --> AGENT
AGENT -->|"tool calls"| TOOLS
AGENT <-->|"think"| GEM
AGENT -->|"write each step"| DB
API -->|"read run + steps"| DB
TOOLS --> WIKI
MCP --> TOOLS
CLAUDE -->|"MCP protocol"| MCP
AGENT -->|"MCP protocol"| TAVNote the two MCP arrows, pointing opposite ways. On the left, someone else’s client calls our server. On the right, our agent calls someone else’s. Same protocol, both ends — see Learn · MCP.
The request that matters
Section titled “The request that matters”What happens when you ask a question, in order:
sequenceDiagram
participant B as Browser
participant A as FastAPI
participant D as Postgres
participant G as Agent
B->>A: POST /runs {"query": "...", "thread_id": "..."}
A->>D: INSERT runs (status=queued)
D-->>A: run_id + thread_id
A-->>B: 202 {"run_id", "thread_id"} ← ~200ms, connection closes
Note over A,G: everything below happens after the response
A->>G: BackgroundTasks
G->>D: SELECT messages (the conversation so far)
G->>D: INSERT steps (Planning…)
B->>A: GET /runs/{id}
A->>D: SELECT run, steps
A-->>B: {status: running, steps: [1]}
G->>D: INSERT steps (Searching…)
B->>A: GET /runs/{id}
A-->>B: {status: running, steps: [1,2]}
G->>D: UPDATE runs (status=done, result, messages)
B->>A: GET /runs/{id}
A-->>B: {status: done, result: "..."}The critical line is the fourth one. The connection closes before the agent has done anything. Every timeout problem in Learn · Why deployment is hard dies right there.
The data
Section titled “The data”Three tables. Two of them do all the work.
erDiagram
runs ||--o{ steps : "has many"
runs {
uuid id PK
text query
text status "queued|running|done|error"
text result "the final answer"
text error
timestamptz created_at
timestamptz updated_at
}
steps {
bigserial id PK
uuid run_id FK
int seq "display order"
text label "shown in the UI"
text detail
timestamptz created_at
}
memories {
bigserial id PK
text content
vector embedding "768 dims"
timestamptz created_at
}runs is the job. steps is the thought process — and the reason the UI can
say “Agent is searching…” instead of showing a spinner. memories is there for
when you add RAG; nothing writes to it yet.
Where every file sits
Section titled “Where every file sits”deploying-agentic-ai-apps-workshop/├── app/ the deployed application — its own uv project│ ├── pyproject.toml dependencies, pinned by app/uv.lock│ ├── main.py the API — /runs vs /runs/naive│ ├── config.py environment variables, validated at startup│ ├── db.py Supabase over its REST API│ ├── llm.py raw Gemini calls (used by manual_loop only)│ ├── agent/│ │ ├── research.py the agent — Pydantic AI│ │ ├── manual_loop.py the same loop by hand, for reading│ │ └── steps.py step logging + the step ceiling│ ├── tools/│ │ ├── wikipedia.py the tools themselves│ │ └── server.py the same tools, over MCP│ ├── tests/ 37 tests, no network or keys needed│ └── http/ ready-made requests for driving the API by hand├── client/ the client — Vite + React + TypeScript│ └── src/│ ├── api.ts the five HTTP calls│ ├── useConversation.ts accept-and-poll + threads│ └── useHealth.ts which model is answering├── database/schema.sql three tables├── deploy/render.yaml infrastructure as a file├── slides/ the presentation deck├── website/ this site└── docs/ run of show, prep notes, free-tier notesThree deliberate choices
Section titled “Three deliberate choices”One service, not three. The API, the agent, and the MCP server all run in one FastAPI process. Splitting them would be more “correct” and would triple the number of things that can be asleep when you need them. On a free tier, fewer moving parts wins.
The database is the message bus. There’s no Redis, no queue. The agent writes rows; the client reads rows. It’s simpler than it sounds and it means every run leaves a complete, inspectable trace you can read in the Supabase table editor.
The frontend is a real app, not a demo page. Vite + React + TypeScript, with
its own package.json. A hand-written HTML file would be shorter, but it would
quietly skip the part that actually breaks: cancelling the poll loop when the
component unmounts, and not stacking requests when a cold server is slow to
answer. Those live in client/src/useConversation.ts. The fetch calls next door in
api.ts are identical from Vue, Flutter, or curl — it’s the code around them
that’s worth reading.