Every agent, index and eval starts with the same unglamorous problem: the knowledge is locked in files, and models read text. Papyrus is the layer in between — and it keeps the structure that makes the text worth reading.
PDF, Word, PowerPoint, Excel, HTML, EPUB, notebooks, archives — 22 formats.
Nothing is stored — files are converted and discarded.
The converted Markdown lands here — with YAML provenance, page anchors you can cite, and tables that survived the trip.
One line
Your agent can already read .txt and .md. It cannot read the PDF, the deck, or the spreadsheet — those are opaque binaries, and the usual workaround is a shell pipeline it reinvents every session.
Papyrus ships an MCP server. Five tools, shaped around the context window rather than around the Python API: a 300-page report paginates instead of swallowing the conversation, and inspect_document tells the model what reading a file would cost before it spends the tokens.
# Claude Code claude mcp add papyrus -- papyrus-mcp # or any MCP host, in its config { "papyrus": { "command": "papyrus-mcp" } }
| inspect_document | what is this, and what would reading it cost? |
| convert_document | read it as Markdown, paginated |
| convert_to_file | write to disk, spend almost no context |
| convert_to_chunks | retrieval chunks with page citations |
| list_supported_formats | everything it can read |
The argument
Below is one page of a financial report. On the left, what a text-extraction call returns. On the right, what Papyrus returns. The words are nearly the same. Only one of them can answer “what was EBITDA in 2025?”
Annual Report 2025 | Confidential← on every pageExecutive Summary← was a headingRevenue grew 41% year over year, ahead of the conservative plan set at the start of the period.← lines, not a paragraphMetric 2024 2025 Revenue 10.0M 14.1M EBITDA 0.9M 2.1M← was a table3← page number
--- source: {filename: report.pdf, sha256: 9f2a…} document: {pages: 48, author: Finance} --- <!-- papyrus:page 3 --> ## Executive Summary Revenue grew 41% year over year, ahead of the conservative plan set at the start of the period. | Metric | 2024 | 2025 | | --- | ---: | ---: | | Revenue | 10.0M | 14.1M | | EBITDA | 0.9M | 2.1M |
How it works
Parsers never emit Markdown. Renderers never parse files. Everything meets at the Document IR — which is why adding a format is one self-contained file, and why the same engine can target something other than Markdown tomorrow.
Magic bytes decide, not the filename. A PDF renamed .docx is still a PDF.
One parser per format, each about 150 lines. None of them writes Markdown.
Headings, lists, tables, code, images, page anchors. The contract everything meets.
CommonMark plus GFM tables. If the output is wrong, the bug is in a parser.
Split on headings, never mid-table. Each chunk carries its section path and page.
Coverage
| Format | Recovered |
|---|---|
| Outline, font-ranked headings, geometric tables, running headers dropped | |
| docx | Styles, true document order, list nesting, hyperlinks, inline emphasis |
| pptx | Slides as sections, reading order, speaker notes, chart source data |
| xlsx | Every sheet, number formats honoured, regions split on blank rows |
| html · xml | DOM walk — headings, nested lists, tables, fenced code, links |
| epub | Chapters in spine order, Dublin Core metadata |
| csv · tsv | Delimiter sniffed, header row detected, numeric columns aligned |
| json · jsonl | Arrays of objects become tables; nesting becomes headings |
| ipynb | Cells, outputs, errors and plots |
| eml | Headers, preferred body part, attachment manifest |
| rtf | Text and emphasis recovered without a dependency |
| zip | Every member converted, recursively, with the bombs refused |
| images | Metadata, and a text layer when OCR is enabled |
| code · text · md | Fenced with the right language; Markdown passes through |
Four ways in
# one file, or a whole tree papyrus convert report.pdf papyrus convert docs/ -o out/ -r # + chunks.jsonl for your index papyrus convert report.pdf --chunk # what is this file, and why papyrus inspect weird.bin
from papyrus import convert, ConvertOptions result = convert( "report.pdf", ConvertOptions(chunk=True), ) result.markdown # str result.document.blocks # the IR result.chunks # with citations result.write("out/") # bundle
papyrus serve --port 8787 curl -F file=@report.pdf \ localhost:8787/v1/convert # POST /v1/convert markdown | bundle # POST /v1/chunk embedding-ready # POST /v1/compare before and after # POST /v1/detect identify only # GET /v1/formats what is supported
claude mcp add papyrus -- papyrus-mcp # then, in the conversation: "Read Q3-board-deck.pptx and pull out the revenue table." # the model calls inspect_document, # then convert_document, and answers
What it is for
Chunks arrive with heading paths and page numbers, so a retrieved fragment can still cite where it came from. Retrieval quality is capped by ingestion quality.
One MCP install and the agent reads decks, contracts and workbooks the same way it reads source code.
Conversion is deterministic — the same bytes always produce the same Markdown — so a fixture stays a fixture and a diff means something.
Point it at a directory or a zip. It converts every member, reports what it could not read, and never stops on the first bad file.
Straight answers
No. There is no model call in the conversion path, which is what makes cost predictable and output reproducible. An AI cleanup pass belongs after the intermediate representation, never inside a parser.
Nothing is written to disk and nothing is retained. The hosted demo converts in memory and discards; run the container and the file never leaves your network at all. Even share links carry their excerpt inside the URL rather than a database.
Those give you the words. Papyrus keeps the structure the words were arranged in — heading levels, table headers, reading order, page provenance — because that is the part a model needs and the part flat text throws away.
It detects them and says so rather than returning a confident blank. Install the OCR extra and pass --ocr to transcribe them; that path needs Tesseract on the host and is approximate rather than deterministic.
It will refuse it, on purpose, and tell you which ceiling it hit. Every limit — size, pages, cells, archive members, compression ratio, nesting depth — is an environment variable.
A parser is one file of about 150 lines that returns the intermediate representation. It inherits the whole invariant suite the moment its fixture lands, so you find out immediately if it produces Markdown nobody can parse.
Where it runs
There is no model call in the conversion path. That makes cost predictable, output reproducible — the same bytes always produce the same Markdown — and deployment a matter of running a container next to the files it reads.
| Guard | Default |
|---|---|
| file size | 50 MB |
| pdf pages | 2,000 |
| sheet cells | 1,000,000 |
| csv rows | 50,000 |
| archive members | 500 |
| compression ratio | 200:1, then refused |
| archive nesting | 3 deep |
| uploaded names | sanitised, no traversal |
Nothing uploaded is ever executed. Every limit is an environment variable.