Build a Research Assistant

Su Suriya Ravichandran Updated 10 Nov 2026
6 min read ·Lesson 199 of 200

The Goal

A research assistant takes a question, breaks it into parts, gathers sources for each part, and writes an answer in which every claim points to the source it came from. The reader can then check any sentence against the page it cites. It is the most demanding project in this section, because it combines planning, retrieval, grounded writing, and verification, and because its usefulness depends on whether the citations are real.

The project draws on the search pattern from Lesson 99, the citation handling from Lessons 155 and 157, and the checking habits from every earlier section. The key discipline is that an answer may cite only sources the assistant actually gathered. Any citation to a source outside that set is an error, and the report says so.


What You Will Build

A research function that:

  1. splits the question into sub-questions,
  2. gathers sources for each sub-question, keeping each source once,
  3. asks the model to answer from the gathered sources, with citations enabled,
  4. verifies that every citation names a gathered source,
  5. returns the answer, the sources it used, and any citation problems.

Project Structure

research_assistant/
├── research.py    # planning, gathering, answering, and verifying
└── demo.py        # a stand-in search index and a stand-in model with one bad citation

Step 1: Planning and Gathering

Planning turns one question into parts that can each be searched. The planner here is a fixed function, so the demonstration is predictable, and a production version would ask the model for the parts. Gathering runs each part through the search, and keeps a source once, identified by its address:

"""research.py: plan a question, gather sources, answer from them with citations, and verify the citations."""

from dataclasses import dataclass

MODEL = "claude-sonnet-5-5"


@dataclass(frozen=True)
class Source:
    url: str
    title: str
    text: str


def plan(question: str) -> list[str]:
    """A fixed planner for the demonstration. A production planner asks the model for the parts."""
    return [f"{question} - definition", f"{question} - current policy"]


def gather(search, sub_questions: list[str]) -> dict[str, Source]:
    sources: dict[str, Source] = {}
    for query in sub_questions:
        for source in search(query):
            sources.setdefault(source.url, source)
    return sources

The setdefault call keeps the first copy of each source, so a page returned by two searches appears once in the list the model sees, and once in the citations the reader sees.


Step 2: Answering From the Sources

Each gathered source becomes a document block with citations enabled, so the model can cite the passages it relies on. The document blocks are sent with the question, and the answer's text blocks carry the citations:

def answer(client, question: str, sources: dict[str, Source]):
    documents = [
        {
            "type": "document",
            "title": source.title,
            "source": {"type": "text", "media_type": "text/plain", "data": source.text},
            "citations": {"enabled": True},
        }
        for source in sources.values()
    ]
    response = client.messages.create(
        model=MODEL,
        max_tokens=800,
        messages=[{"role": "user", "content": documents + [
            {"type": "text", "text": f"Answer from these sources only, and cite them: {question}"},
        ]}],
    )
    return response.content

The instruction to answer only from the sources is part of the request, and the verification in the next step checks that the instruction was followed in the form that matters: every citation must point to a gathered source.


Step 3: Verifying the Citations

Verification walks the answer's blocks and collects the URL of every citation. A citation to a URL outside the gathered set is recorded as a problem, because it means the answer relies on something the assistant never retrieved, which is the most serious failure a research tool can have:

def verify(content, sources: dict[str, Source]) -> tuple[str, list[str], list[str]]:
    parts, cited, problems = [], [], []
    for block in content:
        if block.type != "text":
            continue
        parts.append(block.text)
        for citation in getattr(block, "citations", None) or []:
            if citation.url in sources:
                if citation.url not in cited:
                    cited.append(citation.url)
            else:
                problems.append(f"citation to a source that was not gathered: {citation.url}")
    return "".join(parts), cited, problems

Step 4: A Demonstration

The stand-in search returns two sources for the first sub-question and one of them again for the second. The stand-in model cites the first source correctly and cites a source the search never returned, which exercises the verification:

"""demo.py: a research run with a stand-in search and a stand-in model that cites one unknown page."""

from types import SimpleNamespace

from research import Source, gather, plan, verify, answer

INDEX = {
    "refund - definition": [
        Source("https://a.example/policy", "Refund policy", "Refunds are available within 14 days."),
        Source("https://b.example/faq", "Refund FAQ", "Refund requests go to support."),
    ],
    "refund - current policy": [
        Source("https://a.example/policy", "Refund policy", "Refunds are available within 14 days."),
    ],
}


def search(query: str):
    return INDEX.get(query, [])


class ScriptedModel:
    def __init__(self):
        self.messages = self

    def create(self, **request):
        citations_ok = [SimpleNamespace(url="https://a.example/policy")]
        citations_bad = [SimpleNamespace(url="https://unknown.example/post")]
        return SimpleNamespace(content=[
            SimpleNamespace(type="text", text="Refunds are available within 14 days. ",
                            citations=citations_ok),
            SimpleNamespace(type="text", text="A blog post also mentions this.",
                            citations=citations_bad),
        ])


if __name__ == "__main__":
    question = "refund"
    sub_questions = plan(question)
    sources = gather(search, sub_questions)
    content = answer(ScriptedModel(), question, sources)
    text, cited, problems = verify(content, sources)
    print("sub-questions:", len(sub_questions))
    print("sources gathered:", len(sources))
    print("answer:", text)
    print("cited sources:", cited)
    print("problems:", problems)

Output:

sub-questions: 2
sources gathered: 2
answer: Refunds are available within 14 days. A blog post also mentions this.
cited sources: ['https://a.example/policy']
problems: ['citation to a source that was not gathered: https://unknown.example/post']

How It Works

  1. Two sub-questions are planned, and the search returns three results between them, of which one page repeats. Gathering keeps two distinct sources.
  2. The answer contains two text blocks, and the verification reads the citations on each block.
  3. The first block's citation is to a gathered source, so it is recorded in the cited list, once.
  4. The second block's citation names a page the search never returned. Verification reports it as a problem, which is exactly the failure a reader would otherwise trust.
  5. The problem is reported, not hidden. A research answer with a flagged citation should be marked for review, or the unsupported sentence removed, before the report is shared.

Going Live

For live use, the stand-in search is replaced by a real search, which may be the web search tool (Lesson 155) or the application's own index (Lesson 99), and the stand-in model by Anthropic(). The verification is unchanged. With the live model, the most common problem is an answer that states a fact without a citation at all, and a stricter check flags uncited text as well as wrong citations.


Limits

A research assistant can only be as good as its sources. Its planner, its search, and its choice of sources each shape the answer, and the citation check confirms only that the answer points to what was gathered, not that the sources are right. Source quality should be judged separately, by checking the domains and recency of what is gathered, and by asking a person to review answers on questions where the stakes are high.

0 Comments

Reviewed before they appear

No comments yet.

Anthropic SDK Using Python
Anthropic SDK Intro Claude API Intro SDK Requirements Install Anthropic SDK Upgrade the SDK Create an API Key Set API Keys Use Environment Variables Import Anthropic Create a Client Send Your First Message Choose a Claude Model Set Max Tokens Read a Response Print Response Text Understand Message Blocks Use System Prompts Send User Messages Set Temperature Handle Stop Reasons Messages API Intro Create Messages Use Message Roles Send Conversation History Continue a Conversation Build a Chat Loop Reset Chat History Use Multiple Turns Inspect Message IDs Read Usage Data Write Clear Prompts Set a System Prompt Add Context to Prompts Use Prompt Templates Add Examples to Prompts Request JSON Output Use XML Tags Control Response Length Improve Prompt Quality Avoid Prompt Injection Read Text Blocks Read Content Blocks Check Stop Reasons Count Input Tokens Count Output Tokens Count Tokens Before Sending Limit Output Tokens Use Stop Sequences Read Request IDs Parse API Responses Streaming Intro Stream Text Output Use Stream Events Use Stream Helpers Get Final Messages Stream Async Responses Handle Stream Errors Build a Live Chat Stream Long Responses Close Streams Safely Build a Chatbot Keep Chat History Manage Chat Sessions Add Chat Memory Build a CLI Chatbot Build a Web Chatbot Use Flask with Claude Use FastAPI with Claude Return JSON from an API Add Chat Error Messages Async SDK Intro Create an Async Client Send Async Messages Use asyncio Run Parallel Requests Stream Async Text Handle Async Errors Set Async Timeouts Close Async Clients Choose Sync or Async Tool Use Intro Define a Tool Create Tool Schemas Send Tools to Claude Read Tool Calls Run Python Tools Return Tool Results Continue After Tool Use Control Tool Choice Handle Tool Errors Use the beta_tool Decorator Create a Tool Runner Add Tool Docstrings Validate Tool Inputs Use Multiple Tools Limit Tool Calls Log Tool Activity Build a Weather Tool Build a Search Tool Build a Calculator Tool Structured Output Intro Request JSON Data Validate JSON Output Use Pydantic Models Handle Invalid JSON Extract Fields from Text Generate Typed Data Use Image URLs Send Base64 Images Read PDFs with Claude Use Output Schemas Build Data Extraction Test Structured Responses Send Image Inputs Upload Files Use File IDs Process Text Files Summarize Documents Extract Table Data Handle File Errors Read Cache Usage Reduce Repeated Input Costs Debug Cache Misses Prompt Caching Intro Enable Prompt Caching Cache System Prompts Cache Long Context Test Cache Performance Use Cache Breakpoints Cache Best Practices Message Batches Intro Create a Message Batch Set Batch IDs Check Batch Status List Message Batches Get Batch Results Process Batch Outputs Handle Failed Batch Items Use Batches for Bulk Tasks Compare Batches and Requests Handle API Errors Handle Rate Limits Retry Failed Requests Set Request Timeouts Handle Network Errors Use Exponential Backoff Build Reliable API Calls Use Extended Thinking Log API Errors Track Request IDs Paginate API Results Set Thinking Budgets Read Thinking Blocks Use Tool Search Use Web Search Tools Use Computer Tools Use Citations Use Token Counting Read Raw Responses Set Custom Headers Use Claude on AWS Bedrock Use Claude on Vertex AI Use Claude on Microsoft Foundry Configure Cloud Credentials Choose a Cloud Provider Compare API Integrations Configure Provider Clients Handle Cloud Errors Use Cloud SDK Extras Deploy Cloud Apps Protect API Keys Use a .env File Ignore Secrets in Git Validate User Input Reduce Prompt Injection Limit Tool Permissions Avoid Unsafe Tool Calls Sanitize Tool Results Set Usage Limits Review Security Logs Test SDK Functions Mock API Responses Test Error Handling Test Streaming Code Measure Token Usage Track API Costs Configure Logging Deploy with Docker Deploy a FastAPI App Monitor Production Apps Build a Claude Chatbot Build a Text Summarizer Build a Code Explainer Build a JSON Extractor Build a PDF Assistant Build a Streaming Chat App Build a Tool-Calling Agent Build a Batch Processor Build a Research Assistant Build a Claude API Dashboard
Ask about this post
AI Ask about this post

Ask questions about Build a Research Assistant and get answers drawn from it.

Signed-in readers only.