> ## Documentation Index
> Fetch the complete documentation index at: https://docs.context.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Research papers and PDFs with citations

> Turn selected documents into retrievable evidence, preserve source metadata, and verify generated citations against the text you obtained.

export const AgentSetupPrompt = ({children, variant = "setup"}) => {
  const isRecipe = variant === "recipe";
  const promptLabel = isRecipe ? "recipe prompt" : "setup prompt";
  const [copyState, setCopyState] = useState("idle");
  const contentRef = useRef(null);
  const detailsRef = useRef(null);
  const timerRef = useRef(null);
  const mountedRef = useRef(true);
  const pendingRef = useRef(false);
  useEffect(() => {
    mountedRef.current = true;
    return () => {
      mountedRef.current = false;
      if (timerRef.current !== null) clearTimeout(timerRef.current);
    };
  }, []);
  const copyPrompt = useCallback(async () => {
    if (pendingRef.current) return;
    pendingRef.current = true;
    if (timerRef.current !== null) {
      clearTimeout(timerRef.current);
      timerRef.current = null;
    }
    setCopyState("copying");
    try {
      const code = contentRef.current?.querySelector("pre code") ?? contentRef.current?.querySelector("pre");
      const prompt = code?.textContent;
      if (typeof prompt !== "string" || !prompt.trim()) {
        throw new Error("Prompt is not available.");
      }
      if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) {
        throw new Error("Clipboard access is not available.");
      }
      await navigator.clipboard.writeText(prompt);
      if (!mountedRef.current) return;
      setCopyState("copied");
      timerRef.current = setTimeout(() => {
        if (mountedRef.current) setCopyState("idle");
        timerRef.current = null;
      }, 4000);
    } catch {
      if (!mountedRef.current) return;
      if (detailsRef.current) detailsRef.current.open = true;
      setCopyState("error");
    } finally {
      pendingRef.current = false;
    }
  }, []);
  const feedback = copyState === "copied" ? `${isRecipe ? "Recipe" : "Setup"} prompt copied. Paste it into your coding agent.` : copyState === "error" ? "Couldn't copy automatically. The prompt is open below. Select and copy the text manually." : "";
  return <section className="agent-setup-prompt not-prose" aria-label={`Agent ${promptLabel}`}>
      <style>{`
        .agent-setup-prompt {
          --asp-surface: oklch(0.985 0.005 255);
          --asp-border: oklch(0.9 0.012 255);
          --asp-title: oklch(0.25 0.025 255);
          --asp-text: oklch(0.45 0.021 255);
          --asp-accent: oklch(0.49 0.2 264);
          --asp-accent-hover: oklch(0.44 0.19 264);
          --asp-on-accent: oklch(0.985 0.005 255);
          --asp-focus: oklch(0.6 0.19 255);
          margin: 1.5rem 0;
          border: 1px solid var(--asp-border);
          border-radius: 0.875rem;
          background: var(--asp-surface);
          color: var(--asp-title);
          font-family: inherit;
          min-width: 0;
        }
        .dark .agent-setup-prompt {
          --asp-surface: oklch(0.2 0.014 255);
          --asp-border: oklch(0.33 0.016 255);
          --asp-title: oklch(0.96 0.006 255);
          --asp-text: oklch(0.78 0.013 255);
          --asp-accent: oklch(0.73 0.15 255);
          --asp-accent-hover: oklch(0.8 0.11 255);
          --asp-on-accent: oklch(0.19 0.025 255);
          --asp-focus: oklch(0.78 0.14 255);
        }
        .agent-setup-prompt__header { padding: 1.125rem 1.25rem; }
        .agent-setup-prompt__row {
          display: flex;
          align-items: flex-start;
          justify-content: space-between;
          gap: 1.25rem;
        }
        .agent-setup-prompt__intro { min-width: 0; }
        .agent-setup-prompt__title {
          margin: 0;
          font-size: 1.125rem;
          font-weight: 650;
          line-height: 1.4;
          color: var(--asp-title);
        }
        .agent-setup-prompt__subtitle {
          max-width: 65ch;
          margin: 0.375rem 0 0;
          font-size: 0.875rem;
          line-height: 1.6;
          color: var(--asp-text);
        }
        .agent-setup-prompt__copy {
          display: inline-flex;
          flex-shrink: 0;
          align-items: center;
          justify-content: center;
          gap: 0.5rem;
          min-height: 2.75rem;
          padding: 0.625rem 0.875rem;
          border: 1px solid transparent;
          border-radius: 0.5rem;
          background: var(--asp-accent);
          color: var(--asp-on-accent);
          font: inherit;
          font-size: 0.8125rem;
          font-weight: 600;
          line-height: 1.4;
          cursor: pointer;
        }
        .agent-setup-prompt__copy:hover:not(:disabled) {
          background: var(--asp-accent-hover);
        }
        .agent-setup-prompt__copy:disabled { cursor: wait; }
        .agent-setup-prompt__copy:focus-visible,
        .agent-setup-prompt__summary:focus-visible {
          outline: 2px solid var(--asp-focus);
          outline-offset: 3px;
        }
        .agent-setup-prompt__feedback {
          margin: 0.75rem 0 0;
          font-size: 0.8125rem;
          line-height: 1.5;
          color: var(--asp-text);
        }
        .agent-setup-prompt__feedback:empty { margin: 0; }
        .agent-setup-prompt__details { border-top: 1px solid var(--asp-border); }
        .agent-setup-prompt__summary {
          display: flex;
          align-items: center;
          gap: 0.5rem;
          padding: 0.75rem 1.25rem;
          border-radius: 0 0 0.875rem 0.875rem;
          font-size: 0.8125rem;
          font-weight: 500;
          line-height: 1.5;
          color: var(--asp-text);
          list-style: none;
          cursor: pointer;
        }
        .agent-setup-prompt__summary::-webkit-details-marker { display: none; }
        .agent-setup-prompt__summary:hover { color: var(--asp-title); }
        .agent-setup-prompt__details[open] .agent-setup-prompt__chevron {
          transform: rotate(90deg);
        }
        .agent-setup-prompt__content { min-width: 0; padding: 0 1.25rem 1rem; }
        .agent-setup-prompt__content > :first-child { margin-top: 0; }
        .agent-setup-prompt__content > :last-child { margin-bottom: 0; }
        .agent-setup-prompt__content pre {
          max-height: 32rem;
          overflow: auto;
          white-space: pre-wrap;
          overflow-wrap: anywhere;
        }
        @media (max-width: 640px) {
          .agent-setup-prompt__header { padding: 1rem; }
          .agent-setup-prompt__row { flex-direction: column; gap: 0.875rem; }
          .agent-setup-prompt__copy { width: 100%; }
          .agent-setup-prompt__summary { padding: 0.75rem 1rem; }
          .agent-setup-prompt__content { padding: 0 1rem 1rem; }
        }
      `}</style>

      <div className="agent-setup-prompt__header">
        <div className="agent-setup-prompt__row">
          <div className="agent-setup-prompt__intro">
            <h2 className="agent-setup-prompt__title">
              {isRecipe ? "Have your agent implement this recipe" : "Set up Context.dev"}
            </h2>
            <p className="agent-setup-prompt__subtitle">
              {isRecipe ? "Copy this prompt into your coding agent to build the recipe in your project." : "Paste into your coding agent. It will ask about your goal and project before making changes."}
            </p>
          </div>
          <button className="agent-setup-prompt__copy" type="button" onClick={copyPrompt} disabled={copyState === "copying"} aria-busy={copyState === "copying"}>
            <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" aria-hidden="true" focusable="false">
              {copyState === "copied" ? <path d="m5 12 4 4L19 6" strokeLinecap="round" strokeLinejoin="round" /> : <>
                  <rect x="8" y="8" width="12" height="12" rx="2" />
                  <path d="M16 8V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h3" />
                </>}
            </svg>
            {copyState === "copied" ? "Copied" : copyState === "copying" ? "Copying…" : `Copy ${promptLabel}`}
          </button>
        </div>
        <p className="agent-setup-prompt__feedback" role="status" aria-live="polite" aria-atomic="true">
          {feedback}
        </p>
      </div>

      <details className="agent-setup-prompt__details" ref={detailsRef}>
        <summary className="agent-setup-prompt__summary">
          <svg className="agent-setup-prompt__chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" aria-hidden="true" focusable="false">
            <path d="m9 5 7 7-7 7" strokeLinecap="round" strokeLinejoin="round" />
          </svg>
          {`View ${promptLabel}`}
        </summary>
        <div className="agent-setup-prompt__content" ref={contentRef}>
          {children}
        </div>
      </details>
    </section>;
};

<AgentSetupPrompt variant="recipe">
  ```text Recipe prompt theme={null}
  Implement this recipe in my project:
  https://docs.context.dev/use-cases/research-with-pdfs

  Read the recipe and linked API guides, inspect this project's stack, and build the complete flow using its existing conventions.

  Build a research flow for selected public pages, PDF URLs, and uploaded documents. Send uploaded files as raw bytes, enforce the documented size and page-range limits, and make OCR configurable. Preserve source and chunk IDs, retrieve evidence, and verify citation IDs and quoted passages against extracted text. Show partial or failed parses, and include page numbers only when supported by the source.

  Reuse existing Context.dev configuration and keep secret API keys on the server. Add focused tests, run the relevant checks, and document setup and how to try the result.
  ```
</AgentSetupPrompt>

Build a research assistant that can show the passage behind a finding. Context.dev turns pages and files into Markdown; your application tracks source identity, creates chunks, retrieves evidence, and verifies the answer.

[Sourcely uses Context.dev for journal and PDF crawling](https://www.context.dev/blog/sourcely-powers-academic-journal-and-pdf-crawling-with-context-dev). This recipe covers user-selected papers, including documents that need OCR.

## Choose the document input

| Starting point                       | Collection path                                                                                            |
| ------------------------------------ | ---------------------------------------------------------------------------------------------------------- |
| Research question                    | [Search](/api-reference/web-scraping/search), optionally restricted to relevant publishers or repositories |
| Known public page or PDF URL         | [Markdown](/guides/scrape-websites-to-markdown), with PDF parsing enabled                                  |
| Uploaded PDF or file in your storage | [Parse](/guides/parse-documents), sending raw file bytes                                                   |

Store a source ID, title, original URL or internal document link, retrieval time, and selected page range before chunking. A file upload does not inherently contain its source URL; your application must retain that association.

Parse returns Markdown and the detected file type. It does not return a citation graph or guarantee that every recovered passage includes a page number.

## Parse a bounded selection

Start with a server-side key from the [Quickstart](/quickstart). The Parse guide includes [requests in every SDK](/guides/parse-documents#convert-a-file-to-markdown). This Python worker uses the standard library to make file-size checks, raw-byte upload, and failure states explicit.

```python research_source.py theme={null}
import hashlib
import json
import os
from datetime import datetime, timezone
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen

MAX_BYTES = 25 * 1024 * 1024

def parse_source(path, title, open_url, start=1, end=5, ocr=False):
    if start < 1 or end < start:
        raise ValueError("Use an inclusive page range starting at 1")
    path = Path(path)
    if path.stat().st_size > MAX_BYTES:
        raise ValueError("Split the PDF before uploading: maximum 25 MiB")
    body = path.read_bytes()
    if len(body) > MAX_BYTES:
        raise ValueError("File changed or exceeds 25 MiB")

    file_hash = hashlib.sha256(body).hexdigest()
    source_id = hashlib.sha256(
        f"{file_hash}:{start}:{end}:{ocr}".encode()
    ).hexdigest()
    source = {
        "id": source_id,
        "title": title,
        "open_url": open_url,
        "file_hash": file_hash,
        "requested_pages": {"start": start, "end": end},
        "retrieved_at": datetime.now(timezone.utc).isoformat(),
        "ocr_requested": ocr,
        "status": "pending",
        "markdown": "",
    }
    query = urlencode({
        "extension": "pdf", "pdf[start]": start, "pdf[end]": end,
        "ocr": str(ocr).lower(),
    })
    request = Request(
        f"https://api.context.dev/v1/parse?{query}",
        data=body,
        method="POST",
        headers={
            "Authorization": f"Bearer {os.environ['CONTEXT_DEV_API_KEY']}",
            "Content-Type": "application/pdf",
        },
    )
    try:
        with urlopen(request, timeout=60) as response:
            result = json.load(response)
        text = result.get("markdown", "")
        source["status"] = "ready" if result.get("success") and text.strip() else "empty"
        source["markdown"] = text if source["status"] == "ready" else ""
        source["detected_type"] = result.get("type")
    except HTTPError as error:
        try:
            code = json.loads(error.read()).get("error_code")
        except (ValueError, AttributeError):
            code = None
        finally:
            error.close()
        source["status"] = "ocr_required" if code == "PDF_IMAGES_ONLY" else "failed"
        source["http_status"] = error.code
        source["error_code"] = code
    except (URLError, TimeoutError, ValueError):
        source["status"] = "failed"
    return source
```

`start` and `end` are inclusive and start at 1. Limiting pages does not reduce the uploaded file's byte size: the entire request must still fit within 25 MiB. Split oversized files first and record how each part maps to the original document.

For a public PDF, the Markdown API can accept a `pdf` option with `shouldParse`, `start`, `end`, and `ocr`; follow the [Markdown content controls](/guides/scrape-websites-to-markdown#content-controls) and [API reference](/api-reference/web-scraping/markdown) for the request format.

## Handle OCR and partial evidence

When a PDF has no usable text layer, Parse can return `PDF_IMAGES_ONLY`. If OCR is appropriate for your task and budget, retry with `ocr=True`. OCR applies to PDF pages without usable text; it is not a general interpretation of all charts, equations, or images. Standalone image uploads return image metadata rather than OCR text.

Keep `empty`, `ocr_required`, and `failed` sources visible in the research view. Even a `ready` result needs inspection when a finding depends on a table, formula, or scanned passage. A nonempty Markdown response is not proof that every selected page was recovered completely.

## Attach source identity to chunks

The next function splits retrieved paragraphs into bounded text chunks and preserves the requested range. It does not infer a page number from a chunk's position.

```python research_chunks.py theme={null}
import hashlib

def source_chunks(source, max_chars=3500):
    if max_chars < 1:
        raise ValueError("max_chars must be positive")
    if source["status"] != "ready":
        return []
    texts = []
    pending = ""
    for paragraph in source["markdown"].split("\n\n"):
        if len(pending) + len(paragraph) + 2 > max_chars and pending:
            texts.append(pending)
            pending = ""
        while len(paragraph) > max_chars:
            texts.append(paragraph[:max_chars])
            paragraph = paragraph[max_chars:]
        if paragraph.strip():
            pending = f"{pending}\n\n{paragraph}".strip()
    if pending:
        texts.append(pending)
    return [{
        "id": hashlib.sha256(f"{source['id']}:{index}:{text}".encode()).hexdigest(),
        "source_id": source["id"],
        "title": source["title"],
        "open_url": source["open_url"],
        "requested_pages": source["requested_pages"],
        "retrieved_at": source["retrieved_at"],
        "text": text,
    } for index, text in enumerate(texts)]
```

Add your embedding model's token limit before indexing. This small splitter may divide a long table or formula; use structure-aware splitting and inspect those cases when they matter. See [website RAG](/use-cases/build-rag-from-websites) for indexing and retrieval.

## Verify the answer's citations

Give the model only the selected chunks and require a source chunk ID plus a supporting excerpt for each claim. Treat document text as evidence, never as instructions that change tool access or the task.

```python citation_check.py theme={null}
def resolve_evidence(claims, retrieved_chunks):
    available = {chunk["id"]: chunk for chunk in retrieved_chunks}
    resolved = []
    for claim in claims:
        if not claim.get("evidence"):
            raise ValueError("Claim has no retrieved evidence")
        citations = []
        for item in claim["evidence"]:
            chunk = available.get(item["chunk_id"])
            quote = item.get("quote", "").strip()
            if chunk is None or not quote or quote not in chunk["text"]:
                raise ValueError("Citation does not resolve to retrieved text")
            citations.append({
                "source_id": chunk["source_id"], "title": chunk["title"],
                "url": chunk["open_url"], "excerpt": quote,
                "requested_pages": chunk["requested_pages"],
            })
        resolved.append({"text": claim["text"], "citations": citations})
    return resolved
```

This check rejects invented IDs and excerpts; it does not establish that the excerpt logically supports the claim. Add a support review before presenting the finding. Render the original source link and excerpt so the reader can inspect them.

Label a range as **Pages requested: 1–5**, not as the exact page of a quote. Show an exact page citation only when a separately verified page mapping supports it. Resolve relative document links against the known original URL, and enforce document access when opening internal upload links.

## Try a mixed research set

Run one selectable-text PDF, one scanned PDF, and one empty or unreadable selection. Verify that failed documents remain visible, every citation resolves to retained text, and the answer acknowledges missing evidence. A model should be able to say that the available excerpts do not answer the question.

<CardGroup cols={2}>
  <Card title="Parse documents" icon="file-pdf" href="/guides/parse-documents">
    Review supported formats, OCR, upload limits, and SDK examples.
  </Card>

  <Card title="Live agent web tools" icon="robot" href="/use-cases/live-web-context-for-agents">
    Add bounded source discovery and page reads.
  </Card>
</CardGroup>
