Chapter 03
agent file conversion tutorial
Overview
Agents in production receive files in formats models cannot read: docx, pptx, xlsx, pdf, plus the long tail of images, audio, and archives. Two answers exist:
- The naive answer: upload every file to a converter API. It works, but it makes a metered network round trip out of work a laptop does in milliseconds, and it ships user files to a server for conversions that never needed one.
- The production answer: convert locally when the device can, and meter only what genuinely must run server-side. Local conversion costs nothing, adds no latency budget, and keeps the file on the machine, which for health, legal, finance, and HR files is not a nice-to-have.
This tutorial builds the production answer as a two-lane setup and finishes with the question most conversion content skips: measured against a popular extraction stack, what does reconstruction actually buy you?
What You Will Learn
- The two-lane routing rule and which formats belong to each lane
- Local, in-browser conversion with an open-source WebAssembly engine
- Conversion as an MCP tool call for Claude Code, Cursor, and other MCP hosts
- The hosted API flow from Python: submit, upload, poll, download
- How to read conversion fidelity numbers without falling for overclaims
The Two-Lane Architecture
The routing rule is the whole design: never pay, in money or in privacy, for a conversion a device can do itself.
- Local lane: images (PNG, JPG, WebP, AVIF, JXL), HEIC decode, audio, archives, PDF page operations (merge, render, extract text), CSV/JSON/YAML/XLSX data files, and DOCX-to-HTML preview all run as WebAssembly in the browser. Zero server cost, zero upload.
- Server lane: office documents to PDF, PDF to Word, PDF/PPTX/XLSX to LLM-ready markdown, and large video transcodes genuinely cannot run in a browser. These go to a metered API, as an MCP tool call or a REST call.
Everything below walks the lanes in that order, because the order is the point: the server lane is the fallback, not the default.
Step 1: Environment Setup
Installing Dependencies
The notebook itself only needs three small packages: requests for the API, python-docx to generate a realistic test document, and python-dotenv for key loading.
!pip install -q requests python-docx python-dotenvConfiguring API Credentials
The local lane needs no account and no key. For the server-lane sections:
- Create a free account at hushvert.com/developers/keys: sign-in is a one-time email code, no password, and creating an account is the same flow.
- Confirm your email, then create an API key on that page (keys look like
hv_live_...). - Set it as
HUSHVERT_API_KEYin your environment or a local.envfile (a.env.templatesits next to this notebook).
New accounts include a free monthly allowance of server conversions; this tutorial fits inside it without payment. Treat the key as a billing secret: server-side environments only, never a browser bundle.
import os
from getpass import getpass
from dotenv import load_dotenv
load_dotenv()
if not os.environ.get("HUSHVERT_API_KEY"):
os.environ["HUSHVERT_API_KEY"] = getpass("Enter your hushvert API key (hv_live_...): ")Step 2: The Local Lane - Conversions That Never Leave the Device
The local lane is @hushvert/engine, an MIT-licensed TypeScript library that runs image, HEIC, audio, archive, PDF page-op, and data-file conversions as WebAssembly in the user's browser. This half of the setup is JavaScript by nature (the lane IS the user's device), so it appears here as the code you would ship in your web app, while the rest of the notebook exercises the server lane from Python.
Images need zero configuration:
import { convertFile } from '@hushvert/engine'
// HEIC, PNG, JPG, WebP, AVIF, JXL. No configuration required.
const jpg = await convertFile(
heicFile,
{ from: 'heic', to: 'jpg', module: 'images' },
(pct) => console.log(`${pct}%`),
)
// `jpg` is a Blob. It never left the browser.Heavier codecs (audio, video, archives, PDF rendering) run from WASM workers your app serves, wired up with a one-time configureEngine call pointing at those assets.
Two properties make this lane worth enforcing in code review:
- Falsifiable privacy. Open the network tab during a conversion and no request carries the file; go fully offline and the conversion still completes. A privacy claim you can test beats one you have to trust.
- Zero marginal cost. Conversion volume lands on your users' CPUs, not your compute bill. An agent product that converts screenshots or voice notes at scale should never pay a server for that.
One honest caveat: the engine's own code is MIT, but two optional codec dependencies are copyleft (the ffmpeg core is GPL, HEIC decoding is LGPL). The modules are lazy-loaded, so projects that cannot take on those obligations simply avoid the audio/video and HEIC paths.
Step 3: The Agent Lane - Conversion as an MCP Tool Call
For agents you do not hand-code (Claude Code, Cursor, Cline, Zed, Claude Desktop), the server lane ships as an MCP server, @hushvert/mcp. The entire integration is one config block:
{
"mcpServers": {
"hushvert": {
"command": "npx",
"args": ["-y", "@hushvert/mcp"],
"env": { "HUSHVERT_API_KEY": "hv_live_your_key_here" }
}
}
}Then ask the agent to "convert quarterly.pptx to markdown" and it converts the file in one tool call, writing the result next to the input.
The server exposes four tools:
| Tool | What it does |
|---|---|
convert_file | Convert a local file to another format and write the output beside the input |
convert_poll | Finish a long conversion (large video) that outlived the first call's wait budget |
list_formats | List the server-only pairs the API supports |
check_usage | Show free conversions remaining and credit balance |
Three behaviors matter in production:
- It enforces the two-lane rule for you. Ask it for a client-convertible pair (HEIC to JPG, say) and it refuses and points at the engine, so an agent loop can never quietly burn credits on work the device could do.
- It is sandboxable. Set
HUSHVERT_ALLOWED_DIRand the server only reads and writes under that directory; setHUSHVERT_MAX_JOBS_PER_SESSIONas a guard against runaway agent loops. - It is budget-aware. Have the agent call
check_usagebefore a batch job instead of discovering an empty balance halfway through.
Step 4: The Hosted API from Python
For your own pipelines, the same server lane is a plain REST API. This section is fully runnable.
Discovering the Format Surface
The formats endpoint is public and returns every server pair as a slug like docx-to-md, with its size cap and credit cost. Fetch it once and treat it as the routing table; a hardcoded format list is how the two-lane rule rots.
import requests
HUSHVERT_API = "https://hushvert.com/api"
pairs = requests.get(f"{HUSHVERT_API}/v1/formats", timeout=30).json()["pairs"]
server_pairs = {p["pair"] for p in pairs}
md_inputs = sorted(p["from"] for p in pairs if p["to"] == "md")
print(f"{len(pairs)} server pairs available")
print("Convertible to LLM-ready markdown:", ", ".join(md_inputs))The Conversion Client
The API is a three-step flow: submit the pair and byte count (you get a jobId and a presigned uploadUrl), upload the bytes with a plain PUT (they go straight to object storage, not through the API host), then poll until done and download the result. The optional Idempotency-Key header makes retries safe: resubmitting with the same key returns the same job and never charges twice.
import pathlib
import time
def convert_file(path: str, to: str, idempotency_key: str | None = None) -> bytes:
"""Convert a local file via the hushvert API: submit, upload, poll, download."""
src = pathlib.Path(path)
data = src.read_bytes()
pair = f"{src.suffix.lstrip('.').lower()}-to-{to}"
headers = {"Authorization": f"Bearer {os.environ['HUSHVERT_API_KEY']}"}
if idempotency_key:
headers["Idempotency-Key"] = idempotency_key
submit = requests.post(
f"{HUSHVERT_API}/v1/conversions",
json={"pair": pair, "bytes": len(data)},
headers=headers,
timeout=30,
)
submit.raise_for_status()
job = submit.json()
requests.put(job["uploadUrl"], data=data, timeout=300).raise_for_status()
while True:
poll = requests.get(
f"{HUSHVERT_API}/v1/conversions/{job['jobId']}",
headers=headers,
timeout=30,
)
poll.raise_for_status()
state = poll.json()
if state["status"] == "done":
result = requests.get(state["downloadUrl"], timeout=300)
result.raise_for_status()
return result.content
if state["status"] == "failed":
raise RuntimeError(f"Conversion failed: {state.get('error')}")
time.sleep(2)Converting a Real Office Document
To keep the tutorial self-contained we generate the incoming file ourselves: a small vendor-review report in docx with a heading, a paragraph, and a real Word table, exactly the kind of document an agent gets handed in a ticket.
from docx import Document
doc = Document()
doc.add_heading("Vendor Security Review, Q3", level=1)
doc.add_paragraph(
"Four vendors were reviewed this quarter. One review remains open pending "
"an updated penetration-test report."
)
table = doc.add_table(rows=5, cols=3)
table.style = "Table Grid"
rows = [
("Vendor", "Risk score", "Status"),
("Acme Cloud", "Low", "Approved"),
("DataBridge", "Medium", "Approved with conditions"),
("Sky CRM", "High", "Rejected"),
("OptiLog", "Medium", "Open"),
]
for r, row in enumerate(rows):
for c, text in enumerate(row):
table.rows[r].cells[c].text = text
doc.save("vendor_review.docx")
print("Wrote vendor_review.docx")Run the Conversion and Inspect the Markdown
One call converts the docx to markdown. The part worth staring at is the table: it comes back as a GFM pipe table, so a model reading this output can still answer "which vendor was rejected and why does OptiLog need follow-up" row by row, instead of guessing across a flattened stream of cell values.
markdown = convert_file("vendor_review.docx", to="md").decode("utf-8")
print(markdown)Step 5: The Fidelity Question, Told Straight
Any conversion tutorial owes you an answer to "is this actually better than the extraction stack I already use?" Here is the honest version, from a benchmark of this API's PDF-to-markdown chain (reconstruction: pdf2docx, then pandoc's GFM writer) against markitdown 0.1.6, the popular pdfminer/pdfplumber-based extractor. The corpus: generated fixtures with known ground-truth cell values (counted mechanically, not eyeballed), covering bordered, borderless, merged-cell, and 60-row page-spanning tables, a two-column layout, and a mixed multi-element document.
| Fixture | Reconstruction chain | markitdown (pdfminer-based) |
|---|---|---|
| Bordered table | 4/4 rows preserved | 4/4 rows preserved |
| Borderless table | 7/7 | 7/7 |
| Merged cells | 6/6 | 6/6 |
| 60-row page-spanning table | 60/60 | 60/60 |
| Two-column layout | reading order correct (10/10 markers) | reading order broken (10/10 markers) |
Two findings, and the first one is the one most marketing copy would hide:
- On tables, modern local extractors have caught up. markitdown matched the reconstruction chain on every table fixture, including the hard ones. If your corpus is single-column documents with tables, a free local extractor does the job, and anyone telling you otherwise is selling something.
- The residual difference is reading order. On the two-column fixture, extraction interleaved the columns (every one of the 10 sequence markers came out misordered), while reconstruction read the columns correctly. Real-world reports, papers, and contracts are full of multi-column layouts, and interleaved text quietly poisons everything downstream (chunking, embedding, answers) without throwing a single error.
Caveats that keep the numbers honest: single-run wall times on a small synthetic-but-realistic corpus; the PPTX and XLSX markdown lanes of this API wrap markitdown itself, so parity there is by construction; and a scanned PDF with no text layer fails fast with a clear error and no charge, because the chain reads text layers and does not OCR.
The routing consequence: choose reconstruction when your inputs include real layouts, and feel free to extract locally when they do not. That is the same two-lane logic applied one level deeper.
Production Considerations
- Metering and quotas. Server conversions bill per use: free monthly allowance first, then prepaid credits.
GET /v1/usagereturns the remaining allowance and balance; batch jobs should check it up front rather than fail midway. - Size caps. Each pair carries a cap (the formats endpoint returns it as
freeMaxBytes); oversized uploads are rejected with HTTP 413 before any charge. - Retention. Uploaded inputs are deleted the moment a conversion finishes; outputs expire after about an hour. The download URL is a handoff, not storage: fetch the result and persist it yourself.
- Key hygiene. The API key is a billing secret. Keep it in server-side environments; if a browser app needs a heavy conversion, put a thin proxy of your own in front of the API and keep the key behind it. The local lane never needs a key at all.
- Idempotency. Wrap
convert_filecalls in retry logic with a stableIdempotency-Key, and a flaky network can never double-charge a job. - Enforce the lane split in review. A pull request that adds a server conversion for a client-convertible pair is a cost bug and a privacy bug at the same time. The MCP server refuses such calls at runtime; your review checklist should refuse them at merge time.
Summary
You built a two-lane document-intake setup for production agents:
- Local lane: an MIT WebAssembly engine converts images, HEIC, audio, archives, PDF page ops, and data files on the user's device, verifiably without uploads, at zero marginal cost.
- Agent lane: one MCP config block gives any MCP host a
convert_filetool for the heavy formats, with the client-convertible pairs refused by design. - Pipeline lane: a four-step REST flow (submit, upload, poll, download) with idempotent retries and honest failure states.
- A benchmark-informed routing rule: extraction is fine for single-column documents; reconstruction earns its cost when layouts are real, because reading order is where extraction breaks.
The larger lesson travels beyond file conversion: for any capability you give an agent, ask what part can run where the data already lives, and meter only the remainder.
Where to go next: the hushvert for developers page documents the API surface and pricing, and the engine source is open if you want to audit the no-upload claim or add a module.
