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. Checking apiVersion === 1 before calling anything else is the right guard.
window.latexto.engines
Array of the engine ids compile accepts: auto, pdflatex, xelatex, lualatex, latex-dvi, pdftex, xetex, luatex, context, platex, uplatex. Each is the name of the program that runs, except latex-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 compile accepts: 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',
})

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.

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',
  })

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.