
Articles · Research · 18 min
How do I build an AI research agent that finishes a brief with citations?
A research agent is not a chat that googles. You give it a job: a brief file plus a citation table a script can check. It retrieves candidates, reads the sources, searches for contradiction, cites only what the runtime stored, and stops. If a claim has no locator, the run is a miss.
By Eric · Rome · Aug 29, 2026
The job is a two-page vendor brief in a folder, every claim with a locator a colleague can open, and a stop when that file exists. A thread that searched the open web and sounded sure is not the artifact.
That job is a research agent. A model that talks about the vendor is a chatbot with a search bar. I already wrote what a research agent is if you need the job definition, and this piece is the build: retrieve, read, contradict, cite, stop.
The first version always cheats: it cites the snippet, skips the PDF, never looks for dissent, and writes until the budget dies. A longer prompt will not fix it: you need four tools, a table the runtime owns, and a stop the model does not vote on.
A research agent finishes a brief you can file. It searches, reads, looks for dissent, and attaches locators a script can open. Chat that sounds informed is not the job: if a claim has no stored source, the run failed, so write that rule before you pick a model.
Write the finish line first
Done is a file plus a citation table. Name the path, the required fields, and the test that opens every locator. If a person has to decide whether the brief is finished, the person will stay in the loop, so a script should fail an uncited claim without asking you.
I write the artifact before I name a model. For this job that artifact is two things that travel together: a brief a partner can read, and a citation table a script can check.
Name the path: `/out/vendor-brief.md` is a finish line, and “write something about the vendor” is not. Name the fields the table must hold (claim id, source id, URI, quote, title, publisher, retrieved_at), because if a field is optional the model will leave it blank and call the run done.
The test is a script. It opens the brief, walks every claim, and resolves each source id to a row the runtime stored. A 404, a quote the page does not contain, or a claim with no row is a fail, and pretty footnotes that go nowhere are zeros.
Anthropic’s Building effective agents, published 19 December 2024, is blunt about when to add this kind of loop: start simple, measure, and add multi-step tool use only when a single call with retrieval is not enough. A research brief with citations is that case. You cannot hardcode the path, and you can still write what done looks like.
The same essay splits workflows from agents. A workflow is a predetermined path. An agent is a model that directs its own tool use, and research sits on the agent side because you cannot draw the next URL in advance, yet you can still draw the stop: file exists, table complete, locators live.
Keep the writer out of this job. The research agent returns a packet (claims, locators, dissent, gaps), and a later pass can turn that packet into a letter, which I cover in how to auto-write a research brief. Mixing the two is how a model starts inventing transitions instead of fetching the next source.
Write the forbidden list next to the finish line. The agent may not invent a URL, cite a title it has not read, or mark a claim as fact if the contradiction pass never ran. The runtime says it is done, not the prose.
The four steps
The loop is short on purpose: retrieve candidates, read the ones that matter, search for a source that disagrees, and cite only what the runtime stored. Stop when the brief and table exist, or when the cap hits. A fifth step is how the worker leaves the desk and starts browsing.
- 01
Retrieve
Search for candidate sources. Store titles, URLs, dates, and a short why-this-hit. Do not pull the page into the prompt: a candidate list is a map, not a source you have read.
- 02
Read
Fetch the source by URI. Extract the passage that actually bears on the claim. Keep a locator (URI, quote, title, publisher, retrieved_at) so the model cites that record instead of retyping the URL from memory.
- 03
Contradict
Before a claim enters the brief as fact, search for a source that disagrees. Log the dissent, or log that the search returned nothing. A one-sided packet with perfect citations is still a miss if this pass never ran.
- 04
Cite
Map each claim to stored source ids. The runtime rejects ids it does not have. Then stop: the brief file and the table exist, or the budget is gone, and the model does not get a vote on the cap.
Those four tools are the whole product. Memory, retries, and stop live in the runtime. The prompt names the job and the forbidden list, and if you add a fifth tool called “browse freely,” you are about to ship a wanderer.
The contract for each of those calls is tool calling: name, schema, timeout, permission. Search is not read. This agent needs four tools, plus a write_brief that can only write the artifact path.
Retrieve, then read
Search returns titles, URLs, and dates. It does not return a source you have read. A second tool fetches the page, extracts the passage, and you cite that passage: if you let one tool do both jobs, the model will quote a snippet and call it research.
Retrieve is a map. The tool hits a search API or an index you own and returns a short list: title, URL, date, rank, a one-line reason. Ten candidates is a map, eighty is a dump, and the model may retrieve more than once without treating a snippet as a quote.
Read is a fetch. The tool takes a URI from the candidate list, or from a corpus you already hold, and returns the passage plus the locator. If the fetch fails, it returns not found, paywall, timeout, or denied, and it does not paraphrase the error into a fake quote.
YouTube
Open originalLangChain’s Self-reflective RAG with LangGraph (Self-RAG and CRAG), 7 Feb 2024, grades retrieved documents before generation. That is the retrieve-then-read split this page wires: a snippet is a map, not a source you have read.
I split the tools because the failure modes differ. Retrieve fails by ranking junk. Read fails by skipping the file, and when they share a body the trace looks like one clever search.
Do not pour the whole page into the next thought. Return the passage that bears on the claim, the surrounding sentences, and the locator. A long filing dumped into context is how the model quotes the header and misses the risk section.
Keep your own corpus on the other side of this split. Filings, prior briefs, contracts, last quarter’s memo: those are not search results, they are resources with URIs. The Model Context Protocol specification, Resources section, current as of 28 July 2026, is the contract I use: servers expose URI-identified context through `resources/list` and `resources/read`.
MCP Resources are application-driven: the host lists them and reads them. MCP Tools are model-controlled: the model picks a name and arguments. I keep the corpus as resources so a citation can point at `file://` or a stable https URI, and I keep search and fetch as tools so the model still chooses the next query.
The spec is explicit: `resources/list` returns name, URI, title, description, mime type, and `resources/read` returns the contents for a URI. Servers must validate URIs. Access controls belong on sensitive files, and the agent should not get a glob over the whole drive because a prompt said “use whatever you need.”
The spec uses https as a resource scheme when the client can fetch the page itself, and file:// for things that behave like a filesystem. I pick the scheme a colleague can resolve three months later. A search-rank id is not a scheme.
When the source lives behind a login, read must fail as denied. Do not let the model invent the paragraph it wishes the filing contained. Hand the agent a resource the host already fetched, or skip the claim: a citation to a page the runtime never held is how briefs rot.
| Move | What it returns | What fails |
|---|---|---|
| Retrieve | Title, URL, date, rank, a short why | Dumping full pages into the prompt |
| Read | Passage plus URI, quote, publisher, retrieved_at | Summarizing so the quote disappears |
| Contradict | Opposing source, or a logged miss | Skipping because the first source was confident |
| Cite | Claim id mapped to stored source ids | Typing a URL the runtime never fetched |
Contradict before you cite
Before a claim becomes a sentence in the brief, the agent searches for a source that disagrees. Dissent goes in the packet even when the first source was confident. A one-sided brief with perfect citations is still a miss if the contradiction pass never ran.
This is the step teams skip because the first search felt enough. A vendor page will praise the product. A filing, a complaint, a competitor note, or last quarter’s memo may not, so the agent has to look.
I make contradict a named tool, not a vibe in the prompt. The call takes the claim and the source ids already in the packet, retrieves for the other side, then reads. It returns an opposing locator or a miss with the query it used: a miss is allowed, a skip is not.
Building effective agents describes an evaluator-optimizer workflow for this shape of work: one call produces, another decides whether more search is warranted. The essay, in December 2024, names complex search as a case where the evaluator decides if further rounds are needed. I keep that gate in code, and the brief cannot mark a claim as fact until contradict has returned.
The contradiction does not have to win. It has to exist in the packet. “Filing X states Y. Vendor page Z states the inverse. I have both locators” is a brief, and “Y, according to a source” with no dissent logged is a press release the model happened to format.
Write the claim status in the table: fact, disputed, unknown. Fact requires a supporting locator and a contradiction pass. Disputed requires both sides, and unknown is a gap a partner can act on.
Cap the rounds. Two contradiction retrieves per claim is a policy. Twelve is a way to spend the budget on one paragraph, so if the second retrieve still misses, log the miss and move.
Do not outsource dissent to the same snippet that supported the claim. A page that lists “risks” in a footer is not an opposing source. The contradict tool should prefer a different domain, date, or resource URI, and if the corpus has only one file, the packet should say so.
Cite only what the runtime stored
Citing is a write to the table, not a flourish in the prose. The agent picks stored source ids, and the runtime rejects ids it does not have. Footnotes that cannot be opened are zeros, and pretty numbering is not the score.
The model is a bad librarian. It will invent a plausible URL, cite a title it saw in training, and attach a year that feels right. You do not argue with that habit in the prompt: you make it impossible, and cite accepts only ids the retrieve, read, and contradict tools already wrote to the store.
Each row is a locator a colleague can use without the trace: URI, quote, title, publisher, retrieved_at, and optional page or section. The quote is the ground truth Anthropic’s December 2024 essay asks the agent to take from the environment. The environment here is the fetch, not the model’s memory of the fetch.
During execution, it is crucial for the agents to gain ground truth from the environment at each step (such as tool call results or code execution) to assess its progress.
A citation that cannot be opened is not ground truth. I run a checker after cite: it resolves the URI and looks for the quote in the fetched text. If the quote is gone or the host cannot fetch, the row fails, and the agent may re-read rather than shrug.
Keep ids boring. `src_014` is a good id. A slug the model generated from the title is a way to collide and to hallucinate, so the runtime mints the id when read succeeds and the model never types `https://` into the brief by hand.
Secondary sources need a flag. A news recap of a filing is not the filing, so if the agent read the recap, the table says recap, and if the job requires the primary, a recap-only claim fails. You get that split from a field and a test, not from a prompt that says “prefer primary sources.”
Dates belong in the row because research rots. retrieved_at is when your runtime saw the page, and published is when the source claims it went out. A brief that cites a pricing page with no retrieved_at is a brief you cannot defend in a month.
When two reads disagree, cite both. Do not average them in prose and pick the nicer URL. The packet holds the conflict, and a single footnote under a merged sentence is how that conflict dies.
Put stop on the runtime
The model does not get a vote on the budget. The runtime stops when the brief and table exist, when a required tool failed, or when steps and spend hit the cap. Unbounded reading is how a research demo becomes an invoice.
Building effective agents, again from 19 December 2024, says the task often ends on completion, and that it is common to include stopping conditions such as a maximum number of iterations. Completion is the file. The cap lives in the runtime.
I check done in code. The brief path exists, the table has a row for every claim, every row has a URI the checker opened or a logged deny, and contradict ran for every fact. If any of those are false, the agent is not done, and the model saying “I think that covers it” is a chat turn, not a stop.
I check the cap in the same place: max retrieves, reads, contradiction rounds, steps, and spend. When a cap hits, the runtime writes a partial packet with gaps marked unknown, and stops. A partial packet is a product, and a retry storm is a bill.
LangGraph’s documented Graph API quickstart is the shape I want people to see, even if they never import the library. You define a tool node that executes the calls in the last model message, and end logic that routes to that node or to END. The edge owns the loop, not the prompt.
In that quickstart the tool node walks `tool_calls` on the last message, runs each tool, and returns ToolMessages. If several calls are present, they can run together, then the graph comes back to the model. The conditional edge is the stop: if there are no tool calls the graph ends, and for a research agent I add a second gate so END wins when the done check is true or the cap is hit.
LangGraph also ships a prebuilt ToolNode that does the same job: run the tools from the last AIMessage, in parallel when there are several, and return the results. Use it, or write the twelve-line version in the quickstart. Tools run in a node the runtime owns, not because the model printed a function name into a string you hope to parse.
Put write_brief behind the same edge. The model may call it only when the table is complete enough to pass the checker, or when the cap forced a partial. A write in the middle of retrieve is how you ship a first draft with two URLs, so the node can refuse as a tool result, not as a scolding in the system prompt.
Retries belong here too. Retry a dropped connection, and do not retry a 404 or a deny. Log the error in the shape the next thought can use (not found, denied, timeout), which the December 2024 Anthropic essay calls ground truth from the environment.
Wire the tools like a desk, not a chat
The desk is four tools, one write path, and one runtime. The model picks the next call, and the runtime executes it, stores the locator, and decides whether to loop. Keep the descriptions as tight as the schemas, because a vague tool is a second prompt you will debug in production.
Building effective agents spends an appendix on this. Schluntz and Zhang argue you should treat the agent-computer interface with the same care you would a human-computer interface: document the tool, test how the model uses it, and change arguments so mistakes are harder. They report spending more time on tools than on the overall prompt while building their SWE-bench agent.
I apply that to research. Retrieve takes a query string and an optional domain allowlist, not “whatever you need.” Read takes a URI from the store or the candidate list. Contradict takes a claim id, cite takes stored ids, and write_brief takes no prose that is not already in the packet.
Return short true results: `not_found`, `paywall`, `timeout`, `denied`, or the record. Do not wrap tool output in a paragraph of help. The next thought needs facts, and marketing language in a tool result is how the model starts citing your wrapper.
Permissions split the same way. Retrieve can hit search, read can hit the URIs you allow (not the bank), and write_brief can write `/out/`, not send mail. I split when one worker would hold keys that should never sit together.
The December 2024 essay also warns that frameworks hide the prompts and make it tempting to add complexity. Start with the loop you can draw on one page. If you use LangGraph, read the tool node, and if you use MCP, read the resources/list payload.
Transparency is the third principle in that essay, next to simplicity and tool design. Show the planning steps. For a research agent the trace is the product as much as the brief: every retrieve, read, contradict miss, and rejected cite.
- Retrieve: query in, candidate list out. No full text.
- Read: URI in, passage and locator out. Errors stay errors.
- Contradict: claim id in, opposing locator or a logged miss out.
- Cite: claim ids and stored source ids only. Unknown ids fail.
- Write_brief: packet in, file at the named path out.
- Runtime: retries, store, caps, done check, stop.
Score the brief, not the chat
Freeze briefs you already know. Run the agent more than once, and score the file: every claim cited, contradiction logged, locators live. A fluent miss is a miss, so keep last week’s agent as the baseline and do not ship a prompt trick on vibes.
I keep a frozen set. Ten jobs is a start, fifty is better, and each job has a question, a finish line, and locators a careful human already opened, which the agent does not see. If you change the jobs every week, the score is noise.
Anthropic’s Demystifying evals for AI agents, published 9 January 2026, is the piece I send when a team wants to grade the essay. An eval is an input plus grading logic on the output. Because traces vary, they run multiple trials of the same task, combining code-based graders, model-based graders, and humans, with code first for this job.
The code grader is the same checker you already wrote for stop: file exists, every claim has a row, the URI opens, the quote appears, contradict ran. That grader does not care if the prose sings. A model-based grader can score tone, and a human reads a sample of misses while staying out of the inner loop.
Log every tool call: input, output, latency, cost, error. Failures hide in retrieve rank and in read timeouts. If you only keep the final markdown, you will not see that the model cited a snippet it never fetched, which is why Arena is this floor: same job, competing traces, the artifact as the score.
Score cost per success, not tokens in the abstract. A cheap fail is still a fail. Score steps to done, because drift shows up as longer traces, and do not cut the contradiction pass because it adds steps: that pass is the work.
Keep a baseline: last week’s agent, a script that only retrieves, or a careful human. If the new loop does not beat the baseline on the frozen set, it does not ship. The first partner who asked for a vendor brief is a desk, not a test set.
NIST’s AI Risk Management Framework 1.0 (January 2023) treats measurement as part of running the system, not a ceremony at the end. You are still in demo when you cannot name the score. A score of “people liked the write-up” grades a chatbot.
Promote one job at a time. A vendor brief with public sources can go unsupervised once it beats the baseline. A brief that needs a logged-in filing still needs a resource the host fetched, and maybe a person at the edge, because autonomy is a grant, not a switch on the whole research function.
What usually breaks
The loop is simple. The cheats are simple too: a snippet cited as a source, a one-sided packet, a URL the runtime never stored, a chat sentence pretending to be done. Read the trace for those before you add another model, because extra tools will not hide a missing contradiction pass.
- One search tool that retrieves and “reads” the snippet. The citation 404s.
- No contradiction pass. The brief is a press release with footnotes.
- The model types URLs. The runtime never stored them.
- Done is a chat sentence. The file is missing or the table is empty.
- No cap. The agent rereads the same domain until spend dies.
- Thirty tools. It shops, summarizes, and forgets to cite.
- Eval after the first partner sees it. The partner was the test set.
The fix is the same list you started with: write the finish line, split retrieve and read, force contradict, cite stored ids, stop in the runtime, score the file. You can do that in a week for one desk if you already know the jobs. The slow part is reading the misses.
A bad rank means tighten retrieve: allowlists, recency, your corpus as MCP resources. A skipped PDF means fix read and refuse to cite snippets. A one-sided packet fails without a contradiction row, and a fluent file with dead links means you built a writer, not a cite tool.
Questions
No. Retrieve returns titles, URLs, and dates. Read fetches the source and stores a locator. One tool that does both will cite the snippet. Split the names so the log can show which cheat happened.
As many as the claim needs, with a contradiction pass on every fact. A single supporting locator plus a logged dissent search can be enough. A pile of unread URLs is not.
Read must return denied unless the host already fetched it as a resource. Do not let the model invent the paragraph. Point citations at a URI the runtime held, or mark the claim unknown.
Not by itself. RAG retrieves and replies. A research agent retrieves, reads, contradicts, cites stored locators, writes the file, and stops. Retrieval is one tool. The finish line is the brief.
Next

