All projects

Synapse

An AI research pipeline: three agents, one report. Scout researches a topic, Scribe writes it up with inline citations, and Critic goes back through every claim to check whether the sources actually say what the draft says they say.

  • React
  • Python
  • LangGraph
Synapse — screenshot
Synapse

What it is

You hand Synapse a topic and it gets to work. Scout splits it into sub-questions, searches the web, and scores what it finds for credibility. Scribe turns that into a draft, wrapping every checkable claim in an addressable span with footnotes to its sources. Then Critic goes back through each claim and decides whether its sources support it, partially support it, don’t support it, or contradict it.

You watch the whole thing happen live. The run streams over WebSockets, so sub-questions, sources, sections, and verdicts show up as they get made. When it finishes you can export the report to Markdown or PDF.

Why Critic exists

I went in thinking the hard part would be getting the model to cite sources at all. It wasn’t. A model will happily cite a paper that doesn’t exist, tucked into an otherwise correct paragraph and written so confidently that you’d never think to check. And even when the source is real, half the time it doesn’t actually say what the sentence claims it says.

That’s why there’s a third agent. A report is only as trustworthy as the pass that goes back and rereads everything, so I set a simple rule: every claim gets an address and gets checked against the exact sources it cites, and any disagreement stays visible in the final document instead of getting quietly smoothed over.

Source, claim, verdict
Source, claim, verdictThree columns read left to right. On the left, Scout's output: a pool of four web sources, each with a short id and a credibility bar. In the middle, Scribe's draft: five lines of prose, four of them bracketed as addressable claims. Thin edges run from sources into claims — one claim cites two sources, one line of prose cites nothing. On the right, Critic's output: a verdict mark beside each claim, filled, half-filled, hollow or crossed, judged against exactly the sources that claim cites. The uncited line has no mark: the verdict is per claim, not per document.SCOUTsources, scoredSCRIBEdraft, one span per claimCRITICverdict per claims1s2s3s4

How it works

The backend is FastAPI driving a LangGraph state machine. Taskiq workers do the actual runs, Redis pub/sub carries their events out to the browser, and everything lands in PostgreSQL. The frontend is a React 19 app that uses API and WebSocket types generated straight from the backend’s OpenAPI spec, so if I rename a field the build breaks instead of the page breaking at runtime. None of that is exotic, the work went into making the agents work.

Scribe doesn’t get to just write. Every claim worth checking has to live inside an addressable span with an ID and a footnote:

<span data-claim="sec1.c1">The Hubble constant is roughly 70 km/s/Mpc[^s3]</span>.

That span is what makes the rest possible. Critic can pull each claim out of the prose and check it against the exact sources it points to, instead of trying to judge the whole report at once. It also makes empty sections cheap to spot. No spans means nothing to fact-check, so I skip the LLM call entirely.

Where it got hard

Structured output kept failing. Some models fail at it constantly, and always in the same few ways, so validation ended up layered. Pydantic goes first, then my own checks on claim-ID sequencing and footnote placement, then a retry where the model sees its own broken output and has to fix it instead of starting over. I tried starting over first. It was slower, and it just produced fresh mistakes instead of fixing the old ones.

The structural checks are unglamorous set arithmetic, which is exactly why they catch what a schema can’t:

One rule I settled on early was to never let the model carry anything it could drop or invent. Source lists, IDs, and timestamps get attached by code, never generated. Credibility scoring is half curated, with domain priors for sources I know, and half model-judged for everything else.

What I'd change

Scribe still writes whole sections at once, so you don’t get that token-by-token streaming feel while it drafts. There’s also no LangGraph checkpointing yet, so a crashed worker restarts its job from zero. I cut both on purpose to get it shipped, and they’d be the first things I’d put back.