A personal deep-research agent built on LangGraph — and a teaching reference for Multi-Agent Architectures & Human-in-the-Loop with LangGraph. Give Cerebro a research question; it plans the work, dispatches a hierarchical team of specialist agents to gather evidence (web, academic, and your own documents), drafts a cited, research-paper-style report, self-critiques in a bounded loop, and pauses at three approval gates for your call. Every run is checkpointed, so you can inspect history, edit past state, and fork a new branch from any point.
It is designed to make four LangGraph concepts individually visible:
- Supervisor orchestrating sub-agents — a top-level
plannerdecomposes the question and routes work. - Hierarchical / nested teams —
research_teamis a compiled subgraph whose lead fans sub-questions out to specialists in parallel via theSendAPI. - HITL approval gates — real
interrupt()/Command(resume=...)pauses with editable state at three points (plan, draft, final). - Time-travel / checkpointing — inspect the checkpoint history, edit a past checkpoint, and fork a divergent branch.
Runs fully offline with no API keys — a deterministic mock model + mock search/embeddings drive the entire loop (including real token streaming), so you can demo it anywhere. Add a provider key to switch to live models, web search, and vector retrieval, with zero code changes.
- Multi-agent research graph — the
StateGraphis the single source of truth:planner → plan_gate → research_team (subgraph) → synthesizer → draft_gate → critic (bounded revise loop) → finalize → END. The FastAPI layer only streams graph events; React only renders them. - Document RAG over your own corpus — upload PDF / DOCX / TXT / MD → async ingestion
(extract → chunk → embed → store) → hybrid retrieval (pgvector HNSW + Postgres full-text
search, fused with Reciprocal Rank Fusion) exposed as a third
corpusspecialist. Findings carry a page/section locator, so the report can cite "your document · p.N". Works in-memory with a deterministic mock embedder; pgvector activates whenDATABASE_URLis set. - Research-paper-style reports — abstract, background, thematic synthesis, discussion,
limitations, and conclusion, with inline
[n]citations. Export to PDF or Word, sources included. - Evaluation harness (
cerebro-eval) — structural metrics (citation validity, grounding coverage, plan quality, retrieval recall@k) + optional LLM-judge faithfulness. Runs hermetically offline and gates CI. - Per-run observability — token / cost / latency streamed over SSE and shown in a live meter,
cumulative across gates. Optional LangSmith tracing via
LANGCHAIN_*. - Local run history — every run autosaves to the browser; reopen, review, or clear past runs from a history drawer, even after a reload.
- Typed end-to-end contract — one source of truth: Pydantic → OpenAPI → generated TypeScript
(
frontend/lib/api-types.ts); a CI check guards drift.
Backend (Python 3.12, FastAPI, LangGraph, SSE) Frontend (Next.js App Router, React Flow)
──────────────────────────────────────────── ──────────────────────────────────────────
graph/ the StateGraph (source of truth) app/ the research workspace
planner ─▶ plan_gate ─▶ research_team (subgraph) components/
(HITL #1) lead ─Send▶ web_search graph/ live React Flow topology
academic conversation/ question + streamed report
corpus (RAG) hitl/ editable approval cards
reduce inspector/ live state + metrics meter
─▶ synthesizer ─▶ draft_gate (HITL #2) timeline/ checkpoints + "fork from here"
─▶ critic ⇄ (bounded revise loop) documents/ corpus upload + sources drawer
─▶ finalize (HITL #3) ─▶ END lib/ typed client + SSE + generated types
Runtime / system view — how a request flows across the stack:
flowchart LR
subgraph Client["Browser · Next.js SPA (Cloudflare Pages)"]
UI["React Flow topology<br/>HITL cards · streamed report"]
end
subgraph Server["FastAPI (Render container)"]
SSE["SSE stream<br/>/threads · /runs · /resume · /fork"]
end
subgraph Graph["LangGraph StateGraph — single source of truth"]
direction TB
P[planner] --> PG{{plan_gate · HITL}}
PG --> RT["research_team subgraph<br/>lead —Send▶ web · academic · corpus"]
RT --> SY[synthesizer] --> DG{{draft_gate · HITL}}
DG --> CR[critic]
CR -->|revise ≤ bound| SY
CR --> FZ{{finalize · HITL}}
end
Models["get_model / get_embedder<br/>Gemini · Claude · mock"]
Search["SearchTool<br/>Tavily · arXiv · mock"]
Corpus[("Corpus<br/>pgvector HNSW + FTS · RRF")]
Ckpt[("Checkpointer<br/>SQLite / Postgres")]
UI <-->|"HTTPS + SSE (fetch/ReadableStream)"| SSE
SSE <--> Graph
P -.-> Models
SY -.-> Models
CR -.-> Models
RT -.-> Search
RT -.-> Corpus
Graph -.->|checkpoint every step| Ckpt
The compiled graph topology (also rendered to backend/docs/graph.png):
graph TD;
__start__ --> planner;
planner --> plan_gate;
plan_gate -. reject .-> planner;
plan_gate -. approve .-> research_team;
research_team --> synthesizer;
synthesizer -. first draft .-> draft_gate;
synthesizer -. revision .-> critic;
draft_gate -. reject .-> research_team;
draft_gate -. approve .-> critic;
critic -. revise (≤ bound) .-> synthesizer;
critic -. pass .-> finalize;
finalize -. request changes .-> synthesizer;
finalize -. approve .-> __end__;
The graph is the single source of truth for orchestration — the FastAPI layer only streams graph events and relays human decisions; the React layer only renders them.
| Backend | Python 3.12, FastAPI (async), LangGraph, LangChain, SSE via sse-starlette, AsyncSqliteSaver checkpointer (Postgres-swappable), pgvector for the corpus, structlog, Pydantic |
| Frontend | Next.js 16 (App Router), React 19, TypeScript (strict), TanStack Query, React Flow (@xyflow/react), Tailwind CSS v4 (tokenized theme), next-themes |
| Models | Provider-agnostic get_model(role) / get_embedder() — Google Gemini or Anthropic Claude by env key, else a deterministic offline mock. Stronger tier for planner/critic, faster tier for workers. |
| Search / retrieval | SearchTool interface — Tavily (web) + arXiv (academic) + corpus retrieval (pgvector HNSW + Postgres FTS, fused via RRF), each with a no-network mock fallback. |
| Evaluation | cerebro-eval — offline structural metrics + optional LLM judge, gating CI. |
| Contract | Pydantic models → OpenAPI → generated TypeScript (frontend/lib/api-types.ts). |
First time here? Follow the step-by-step Getting Started guide — install, run, and exactly how to approve / edit / reject at each human-in-the-loop gate. For a non-technical overview, open the intro slide deck.
docker compose up --build
# frontend → http://localhost:3000 backend → http://localhost:8000Runs in offline mock mode by default. To use live models / search, create a root .env with any of
GOOGLE_API_KEY, ANTHROPIC_API_KEY, TAVILY_API_KEY (and DATABASE_URL for pgvector) first.
One command (both services): from the project root, run python dev.py — it starts the
backend and frontend together, prefixes each one's logs, and stops both on Ctrl+C. Prefer separate
terminals? Use the two blocks below.
Backend (needs uv):
cd backend
cp .env.example .env # optional; the app runs with zero keys
uv sync # install deps
uv run cerebro-api # serve at http://127.0.0.1:8000Frontend (needs Node 20+):
cd frontend
npm install
npm run dev # http://localhost:3000The frontend reads the backend URL from NEXT_PUBLIC_API_BASE_URL (default http://127.0.0.1:8000).
Whichever key is present is used, in priority Google (Gemini) → Anthropic (Claude) → mock:
# backend/.env
GOOGLE_API_KEY=... # gemini-2.5-pro (planner/critic) + gemini-2.5-flash (workers) + embeddings
# or
ANTHROPIC_API_KEY=... # claude-sonnet-5 / claude-haiku-4-5
TAVILY_API_KEY=... # live web search (else mock)
DATABASE_URL=... # pgvector Postgres for the corpus (else in-memory + mock embedder)Ask a question → pause at the plan gate and edit the sub-questions → watch the hierarchical team fan out on the live graph → review / edit the draft → see the critic loop → approve the final report → export it to PDF / Word (sources included), or open the checkpoint timeline and fork a different branch from an earlier state.
| Plan gate (HITL) | Streaming report + graph |
|---|---|
![]() |
![]() |
cd backend
uv run cerebro "Is intermittent fasting actually good for you?" # answer gates from stdin
uv run cerebro --auto "Do electric cars really help the environment?" # auto-approve every gate
uv run cerebro-eval # run the eval harness| Method | Path | Purpose |
|---|---|---|
POST |
/threads |
create a research session → {thread_id} |
POST |
/threads/{id}/runs |
start a run — streams node transitions, findings, report tokens, metrics, and interrupt events |
POST |
/threads/{id}/resume |
resume from an interrupt with a decision (+ edited plan/draft) |
GET |
/threads/{id}/state |
current graph state |
GET |
/threads/{id}/history |
checkpoint history (time-travel timeline) |
POST |
/threads/{id}/fork |
edit a past checkpoint and branch a new run |
GET |
/threads/{id}/report.pdf · report.docx |
download the report (sources included) |
GET, POST, DELETE |
/documents … |
upload / list / delete corpus documents |
GET |
/meta/graph |
the real node/edge topology (for the graph view) |
GET |
/meta/examples |
a fresh, randomly-sampled set of starter questions |
Interactive docs at http://127.0.0.1:8000/docs.
# Backend
cd backend
uv run ruff check src && uv run ruff format --check src # lint + format
uv run mypy src # strict type check
uv run pytest # tests (interrupt→resume, fork, RAG, export, evals)
uv run cerebro-eval # eval harness (gates CI)
uv run python scripts/dump_openapi.py # regenerate OpenAPI after any Pydantic change
uv run python scripts/render_graph.py # regenerate backend/docs/graph.png + .mmd
# Frontend
cd frontend
npm run gen:api # regenerate lib/api-types.ts from the backend OpenAPI
npx tsc --noEmit # type check
npm run build # production build
npx jest # component + storage testsbackend/
src/cerebro/
config.py typed settings singleton (pydantic-settings)
models.py get_model(role) + offline FakeStreamingChatModel
embeddings.py get_embedder() + deterministic MockEmbedder
observability.py per-run token/cost/latency accounting (SSE run_metrics)
graph/ state, nodes, gates, research_team subgraph, reasoning, build, checkpointer
corpus/ CorpusStore (in-memory + pgvector), hybrid retrieval + RRF, chunker, schema.sql
ingestion/ extract / chunk / embed pipeline + in-process queue worker
tools/ SearchTool (Tavily + mock), arXiv, corpus retrieval
schemas/ Pydantic API + SSE-event + document models (the wire contract)
api/ FastAPI app, SSE mapping, routes (threads / runs / documents), export
evals/ cerebro-eval harness: dataset, metrics, judge, runner, CI report
cli.py end-to-end CLI driver
scripts/ dump_openapi.py, render_graph.py
tests/ pytest suite
frontend/
app/ workspace, layout, providers, globals
components/ graph / conversation / hitl / inspector / timeline / documents / history / ui
lib/ typed client, SSE parser, query hooks, localStorage run store, generated types
design-system/ DESIGN.md + tokens.css
docker-compose.yml zero-key local demo
docker-compose.prod.yml hardened production stack (pgvector + Postgres checkpointer)
Full walkthrough: docs/DEPLOYMENT.md. Nothing deploys automatically — every step needs your own cloud accounts and is confirmation-gated.
Recommended topology
Browser --HTTPS/SSE--> Cloudflare Pages (static Next.js export, client SPA)
|
cross-origin HTTPS (CORS)
v
FastAPI + LangGraph container (Render, free) <-- UptimeRobot pings
| /health every 5 min
v (no cold starts)
pgvector Postgres (checkpointer + corpus vectors)
- Frontend → Cloudflare Pages. The web app is a pure client SPA, so it ships as a static export
(
output: export→./out). No Cloudflare adapter needed. - Backend → a container host, not a Worker. FastAPI + LangGraph + long-lived SSE + a Postgres
pool is a container workload. Recommended default: Render (
render.yaml, free tier); Fly.io (backend/fly.toml) and Railway work too. The samebackend/Dockerfileruns everywhere. - Keep-warm on free hosting: Render's free instance sleeps after ~15 min idle, so an
UptimeRobot monitor pings
/healthevery 5 minutes to spare visitors the ~1 min cold start. - Database: one pgvector Postgres serves both the LangGraph checkpointer (
POSTGRES_DSN) and the corpus vector store (DATABASE_URL). - Offline demo survives deployment: with no provider keys the live site still runs the deterministic mock model + mock search/embeddings end to end.
Build/deploy artifacts: hardened multi-stage backend/Dockerfile and
frontend/Dockerfile; docker-compose.prod.yml;
frontend/wrangler.toml for Pages; render.yaml for the
backend service; and .github/workflows/deploy.yml, which deploys
both on every push to main (Pages via Wrangler, Render via its API, waiting for the deploy to go
live). Credentials, free-tier caveats, the UptimeRobot keep-warm setup, and the go-live checklist
are in docs/DEPLOYMENT.md.
- Persistence: SQLite by default (
AsyncSqliteSaver). SetCHECKPOINTER=postgres+POSTGRES_DSN(install thepostgresextra:uv sync --extra postgres) to swap in Postgres without touching graph code.CHECKPOINTER=memoryis used for tests. - Corpus: in-memory store + deterministic mock embedder with zero keys; set
DATABASE_URLto activate pgvector (HNSW index) with Postgres full-text search fused via RRF. - Streaming: SSE is consumed with
fetch+ aReadableStreamreader (POST streams). Report tokens, findings, and metrics stream over LangGraph'scustomchannel; node transitions and interrupts overupdates. - Observability: structured logs (human-readable locally, JSON in prod), a live per-run
token/cost/latency meter, and optional LangSmith tracing via the standard
LANGCHAIN_*variables.
Light and dark are both hand-tuned.



