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 (except a .latexmkrc in
the directory root), 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. Those stay in the
browser profile, kept per input path and main file, so compiling the
same project again takes fewer passes; --clean compiles
from scratch, which is what a path that now holds a different document
wants. 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 and
cdn.latex.to. 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 downloads the parts of TeX Live
your document needs, which can take a minute or more. 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 and the TeX Live image data
from cdn.latex.to as the document needs it. 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, latex-dvipdfmx, pdftex, xetex, luatex, context, platex, uplatex, context-mkiv. Each is the name of the program or the route that runs.latexis LaTeX through dvips and Ghostscript, the PostScript route that renders PSTricks, psfrag and EPS figures, and it is what Auto picks for a document loadingpstricks.latex-dvipdfmxis LaTeX producing DVI, converted to PDF with dvipdfmx, and is never auto-picked;latex-dviwas its id before 2026-09-05 and is now refused like any other unknown engine.contextis the current ConTeXt (LMTX, on LuaMetaTeX), which is also what a\starttextdocument auto-detects to;context-mkivis the frozen MkIV branch on LuaTeX and is never auto-detected, so name it here or with a% !TeX program = context-mkivcomment. 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. -
projectis optional: an id of your choosing, 1 to 64 letters, digits,-and_, naming the project the files belong to. The page keeps one build directory per id in the browser profile, so the next compile of that project converges in fewer passes, after a reload and in a later run on the same profile too, and two projects never read each other's auxiliary files. Without it the build directory lasts only as long as the page. -
cleanis optional.clean: truediscards the build directory 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 stopped
unexpectedly while compiling this document. Compile again to use a
fresh engine." The page is not reloaded under you and no cache is
cleared: the next
compilestarts a fresh engine by itself.
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 inside its delimiters ($...$, $$...$$,
\(...\) or \[...\], anything outside them
renders as plain text), 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 or a busy page) 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.
Engines
Every engine id has a page under /engines/
(for example /engines/xelatex/, /engines/context/,
/engines/biber/) with an example document that is known to
compile under it, served raw at
https://latex.to/engines/<slug>/main.tex and listed
in https://latex.to/engines/index.json with its
id, engine and bibliography
values. Fetch one to smoke-test an engine before compiling a real
project. In a browser, https://latex.to/?engine=<slug>
opens the example as a project.
Templates
The template catalogue is plain files:
https://latex.to/templates/index.json lists every
template with its slug, engine, bibliography, file names and
url, the folder its files are served from, and each file
is served at that url plus the file path (for example
/templates/ieee/main.tex and
/templates/github/jakes-resume/resume.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.