Use LaTeX.to from AI agents and CLIs
Compile LaTeX to PDF, and math to PNG, with zero TeX installation. The same engine that runs on LaTeX.to is available as an agent skill, a command line tool and a browser API.
What it is
LaTeX.to runs a real TeX Live inside a WebAssembly Linux VM in the browser. There is nothing to install: no TeX distribution, no package manager, no compile server. Point it at a set of files and it hands back PDF bytes and the engine log. KaTeX covers the fast path for a single formula rendered to PNG.
For an agent this means a document can be compiled from a sandbox that has a browser and nothing else, and that the document never leaves the machine it was written on. See the privacy policy for what does and does not travel over the network.
Quick start
Coding agents (Agent Skills)
Install the skills once in your project or your home directory. They work in Claude Code, Codex CLI, Cursor, Gemini CLI, GitHub Copilot and other tools that read Agent Skills.
npx skills add latexto/skills # interactive
npx skills add latexto/skills -g -a claude-code -y # scripted, no prompts
-g installs globally, -a picks the client
(claude-code, cursor, codex and
the others), -y skips the prompts,
-s <skill> takes one skill instead of both, and
-l lists what the repo offers.
Two skills are installed: latex-to-pdf (compile a LaTeX
project to PDF) and latex-to-image (render a formula or a
page to an image). After that, an instruction like "compile the paper
and show me page 3" is enough.
Plain CLI
No install step, run it straight from npm. Node 18.3 or newer.
npx latexto pdf paper.tex # a self-contained single file
npx latexto pdf ./thesis # a project: the directory holding the main file
npx latexto pdf ./thesis --main dissertation.tex
npx latexto pdf paper.tex --engine xelatex --bib biblatex-biber
npx latexto image formula.tex -o out.png
npx latexto image ./thesis --page 3 -o p3.png
File or directory is the one thing to get right. Pass a
FILE only when the document is self-contained, one .tex
that reads nothing beside it. Pass the DIRECTORY that holds the main
file whenever the document uses \input,
\include, \includegraphics,
\bibliography or \addbibresource, a local
.cls, .sty or .bst,
\lstinputlisting or subfiles. When unsure,
pass the directory: files the document never reads are staged and
ignored, so it costs nothing.
A file argument stages that one file and nothing else, and nothing warns
about it locally. The browser starts, the engine runs, and LaTeX reports
the sibling it could not read, exit code 1:
! LaTeX Error: File `chapters/intro.tex' not found.
A directory is staged recursively (figures, .sty,
.cls, .bib), each file keeping its path
relative to the directory passed, with forward slashes. That directory
is also the working directory of the compile, so every relative path in
the document resolves against it and not against the main file's own
folder: a project/src/thesis.tex that says
\input{chapters/intro} wants
npx latexto pdf ./project/src, not
npx latexto pdf ./project --main src/thesis.tex.
The main file is --main (a staged path relative to the
directory passed, forward slashes on every OS), else
main.tex, else the only .tex in the root, else
the only .tex anywhere. Otherwise the run stops with exit
code 2 before any browser starts and lists the candidates. Refused and
reported on stderr as one skipped N files: name (reason)
line: symlinks leaving the project, files over 32 MB, whatever would
push the total past 128 MB, and the output file when it sits inside the
project. Dropped quietly: dotfiles, node_modules and
symlinked directories. When LaTeX cannot find a file, read that skipped
line first.
Only the PDF comes back: no .aux, .bbl or
.log is written into the project. Without -o
the PDF is <main>.pdf in the current working
directory; -o overwrites what is there and creates parent
directories. An Overleaf zip or an arXiv source tarball has to be
extracted first, and the extracted folder is what you compile: Overleaf
exports keep the main file at the root, arXiv sources usually need
--main. --engine and --bib take
the ids listed in the API reference below, so no quoting is needed.
npx latexto image follows the same rule: a bare math
snippet is rendered by KaTeX in milliseconds, a self-contained
.tex file is compiled and one page rasterized, and a
DIRECTORY is staged whole like a compile (same --main,
--engine and --bib) with one page of the
resulting PDF rasterized. --page picks that page (default
1) and a page past the end of the PDF ends the run with exit code 2 and
the page count. Without -o the PNG is
<input>.png, or <main>.png for a
directory.
Where this runs
The skills and the CLI need a shell on a machine or container that can
run a Chromium based browser and reach latex.to,
cdn.latex.to and cxrtnc.leaningtech.com. Local
coding agents (Claude Code, Codex, Cursor and similar) and CI runners
qualify. Browser-less or egress-restricted sandboxes do not: Claude.ai
code execution, Claude Code on the web and Cowork have no browser to
drive and no route to those hosts, and installing one there is not the
answer. An agent that finds itself in one should say so rather than try:
the same command works on the user's own machine or in CI, and
LaTeX.to itself runs in any browser.
First run and caching
The first run boots the TeX VM and streams the packages your document
needs, which takes minutes. Later runs reuse the cache and take
seconds, so budget the timeout of whatever calls the tool
accordingly, and keep the cache directory between runs of a CI job or
a container. It is the browser profile the tools drive:
~/.cache/latexto on Linux,
~/Library/Caches/latexto on macOS and
%LOCALAPPDATA%\latexto on Windows
(latexto help prints the resolved path).
The CLI drives a Chromium-based browser. Most agent sandboxes
already have one through Playwright; if yours does not,
npx playwright install chromium provides it.
Network: a compile fetches the site itself, the TeX Live image blocks
from cdn.latex.to as the document needs them, and the
CheerpX runtime from cxrtnc.leaningtech.com. Running the
tools through npx also reaches the npm registry. The
KaTeX route for a math snippet is same-origin only. Your document
itself is never uploaded: it is compiled in the browser on your own
machine.
Browser automation API
A page loaded from https://latex.to exposes
window.latexto, installed before the app mounts, so a
driver can poll for it as soon as the page's own scripts run. This is
the interface the CLI drives, and you can drive it yourself from
Playwright, Puppeteer or the devtools console.
Surface
- window.latexto.apiVersion
-
The number
1. It is raised only for a change that breaks existing callers, never for an addition: new calls, new options and new result fields arrive at the same version, so test for the feature you want (typeof window.latexto.someCall === 'function', or the presence of a field on a result) rather than for a number. CheckingapiVersion === 1before calling anything else is the right guard. - window.latexto.engines
-
Array of the engine ids
compileaccepts:auto, pdflatex, xelatex, lualatex, latex-dvi, pdftex, xetex, luatex, context, platex, uplatex. Each is the name of the program that runs, exceptlatex-dvi, which is LaTeX producing DVI, converted to PDF with dvipdfmx. The ids are the whole vocabulary: the labels the app's own dropdown shows are not accepted. Read the array rather than hard-coding this list. - window.latexto.bibliographies
-
Array of the bibliography ids
compileaccepts:auto, none, bibtex, biblatex-bibtex, biblatex-biber.
compile
const { pdf, log } = await window.latexto.compile({
files: {
'main.tex': '\\documentclass{article}\n\\begin{document}Hi\\end{document}',
'refs.bib': '@book{knuth1984, title={The TeXbook}, author={Knuth}}',
'plot.png': { base64: 'iVBORw0KGgoAAAANSUhEUgAA...' },
},
main: 'main.tex',
engine: 'xelatex',
bibliography: 'biblatex-biber',
})
-
filesmaps a file name to its contents: a plain string is taken as UTF-8 text, and{ base64 }carries binary (images, fonts, an included PDF). Names may contain sub-directories. -
mainis the key infilesto compile. It is required. -
engineandbibliographyare optional and both default to'auto', which detects what the document needs. Values must come fromenginesandbibliographies. -
cleanis optional. The page keeps the build directory between compiles so an edit converges in fewer passes;clean: truediscards it and runs every pass from scratch, the same as the app's Full recompile row.
The promise resolves with { pdf, log }:
pdf is a Uint8Array of PDF bytes,
log is the log this run produced, as a string. On a failed
compile it rejects with an Error that carries the same log
on its .log property, so a caller can show the real TeX
error rather than a generic failure. The log covers that call alone, and
is '' when the failure produced none.
- Bad input (an unknown engine, a main file that is not one of the files) rejects before anything starts, and emits no progress event.
-
A second
compilewhile one is still running rejects with "A compile is already running on this page. Wait for it to finish before starting another." One page compiles one document at a time. - A crash of the TeX engine itself rejects with "The TeX engine crashed while compiling this document. Reload the page to get a fresh engine.", with the engine's own fault text appended in parentheses. The page is not reloaded under you and no cache is cleared, so a driver decides what happens next.
compile needs SharedArrayBuffer, so the page
must be loaded with its cross-origin isolation headers intact
(Cross-Origin-Opener-Policy: same-origin and
Cross-Origin-Embedder-Policy: require-corp), which is how
LaTeX.to serves itself. Load the real origin: a local copy of the page
served without those headers has no working engine, and neither does
an iframe on a non-isolated host.
pdfToPng
const { png, width, height, pageCount, effectiveScale } =
await window.latexto.pdfToPng({
pdf,
page: 1,
scale: 2,
crop: 'auto',
})
pdfis PDF bytes, typically the ones compile returned.-
pageis the 1-based page number, default1. -
scalemultiplies the page's natural size. It defaults to2and may not exceed8. Whatever it asks for, neither side of the rendered page is allowed past 4096 pixels: a scale that would cross that is lowered to the one that just fits, andeffectiveScalereports what was really used. -
cropis'none'(the whole page, the default),'auto'(trim the surrounding whitespace) or an explicit{ x, y, width, height }rectangle in the page's own units at scale 1, measured from the TOP-LEFT corner of the page with y growing downwards. It must start on the page (xandyare 0 or greater and inside the page box), or the call rejects; a rectangle that runs off the right or bottom edge is trimmed to the page.
Resolves with
{ png, width, height, pageCount, effectiveScale }:
png is a Uint8Array of PNG bytes,
width and height are the pixel size of that
image, pageCount is the number of pages in the document, so
one call is enough to discover how many more there are, and
effectiveScale is the scale the page rendered at, which is
the requested one unless the pixel ceiling above lowered it.
katexToPng
const { png, width, height } = await window.latexto.katexToPng({
source: 'e^{i\\pi} + 1 = 0',
scale: 3,
})
Renders one piece of math with KaTeX, with no TeX VM involved, so it
returns in milliseconds even on a cold page. source is the
math source, scale multiplies the rendered size, defaults
to 2 and may not exceed 8, under the same 4096
pixel ceiling as pdfToPng. Resolves with
{ png, width, height }, where the two sizes are the image's
real pixels. Math KaTeX cannot parse rejects with KaTeX's own message
rather than baking red error text into the image.
Progress
A compile that streams packages can run for minutes. The page reports
what it is doing as a CustomEvent on
window.
window.addEventListener('latexto:status', (event) => {
console.log(event.detail.message)
})
A call refused before the engine runs (bad input, a busy page, a browser
without SharedArrayBuffer) emits nothing at all: it only
rejects. Once a compile starts, the first event says how many files were
staged, the engine's own lines follow, and the last event is either the
ready message or the failure's message.
Status messages and log are the engine's own output,
passed through as they are. Treat them like any other program output
on untrusted input: they carry whatever the document, its packages and
its file names say, so do not paste them into a shell, a prompt, or
anything that executes what it reads.
Playwright example
A persistent context is what makes the second run fast: the browser profile keeps the streamed TeX Live blocks, exactly like the CLI's cache.
import { chromium } from 'playwright'
import { writeFileSync } from 'node:fs'
const context = await chromium.launchPersistentContext('.latexto-profile', {
headless: true,
})
const page = await context.newPage()
await page.goto('https://latex.to')
await page.waitForFunction(() => window.latexto?.apiVersion === 1)
page.on('console', (message) => console.log(message.text()))
const base64 = await page.evaluate(async () => {
const { pdf } = await window.latexto.compile({
files: {
'main.tex':
'\\documentclass{article}\n\\begin{document}\nHello from an agent.\n\\end{document}',
},
main: 'main.tex',
})
let binary = ''
for (const byte of pdf) binary += String.fromCharCode(byte)
return btoa(binary)
})
writeFileSync('out.pdf', Buffer.from(base64, 'base64'))
await context.close()
evaluate cannot carry a Uint8Array across the
bridge, hence the base64 round trip. The first
evaluate can take minutes while packages stream in, so do
not wrap it in a short timeout.
Templates
The template catalogue is plain files:
https://latex.to/templates/index.json lists every
template with its slug, engine, bibliography and file names, and each
file is served at https://latex.to/templates/<slug>/<path>
(for example /templates/ieee/main.tex), ready to fetch
and hand to compile or to npx latexto pdf.
In a browser, https://latex.to/?template=<slug>
opens the template as a project.
Source
The skills and the CLI live in one repository: github.com/latexto/skills. Issues and pull requests are welcome there.