openleaf

API reference

OpenLeaf’s interface is a client of an ordinary HTTP API, which means anything you can do in the window you can also script.

Base URL: http://127.0.0.1:4173 (the port is printed at startup, and may be higher if 4173 was taken).

Before you start

No authentication. The server binds to localhost only and refuses requests carrying a foreign Origin header, which is the whole security model. Do not expose it. See security.

From a browser, omit Origin. curl does this naturally. From another page you cannot call this API, by design.

Errors are JSON with an error string, and a sensible status: 400 for a bad request or a path that escapes the project, 404 for a missing project or file, 409 for a conflict, 413 for too large.

Global

GET /api/health

{ "ok": true, "projectsRoot": "/Users/you/openleaf/projects", "version": 1 }

GET /api/engines

Engines detected on this machine, fastest first.

{ "engines": [
  { "id": "latexmk", "label": "latexmk + pdfTeX",
    "describe": "Fastest for editing. Reruns only what changed",
    "incremental": true, "version": "Latexmk, John Collins, 27 Jan 2024..." }
] }

GET /api/templates

{ "templates": [
  { "id": "article", "label": "Article", "describe": "Standard paper with..." }
] }

Ids: article, report, thesis, beamer, letter, empty.

GET /api/settings · POST /api/settings

Global settings. POST merges a patch, ignores unknown keys, and clamps numbers.

curl -X POST localhost:4173/api/settings \
  -H 'Content-Type: application/json' \
  -d '{"theme":"light","editorFontSize":15}'

GET /api/events

Server-sent events. Event names: tree, projects, compiled, settings.

curl -N localhost:4173/api/events

Projects

GET /api/projects

{ "projects": [ { "name": "My Paper", "mainFile": "main.tex", "mtime": 1785390000000 } ] }

Newest first.

POST /api/projects

{ "name": "My Paper", "template": "article" }

409 if it already exists. The name is sanitised to word characters, spaces, dots, hyphens and parentheses.

DELETE /api/project/:name

Removes the folder and everything in it. Not reversible.

POST /api/project/:name/duplicate

{ "name": "My Paper copy" }

Copies the whole folder. If the name is taken, a numeric suffix is appended, and the actual name is returned.

GET /api/project/:name/tree

{
  "project": "My Paper",
  "mainFile": "main.tex",
  "tree": [
    { "type": "dir", "name": "sections", "path": "sections", "children": [] },
    { "type": "file", "name": "main.tex", "path": "main.tex",
      "size": 813, "mtime": 1785390000000, "editable": true, "viewer": "text" }
  ]
}

viewer is text, image, pdf, or null for something with no viewer. Build artifacts and dotfiles are omitted.

GET /api/project/:name/settings · POST .../settings

{
  "settings": { "...": "global defaults overlaid with this project's overrides" },
  "project": { "main": "main.tex", "engine": "latexmk" },
  "mainFile": "main.tex"
}

POST accepts any setting key plus main.

Files

GET /api/project/:name/file?path=<rel>

Text files return JSON; anything else returns the bytes with a matching Content-Type.

{ "path": "main.tex", "text": "\\documentclass...", "mtime": 1785390000000 }

PUT /api/project/:name/file?path=<rel>

{ "text": "new contents" }

Written atomically. Records a version snapshot unless snapshotOnCompile is off.

{ "path": "main.tex", "mtime": 1785390000000, "bytes": 813,
  "snapshot": { "at": 1785390000000, "hash": "a730173750ba", "bytes": 813 } }

snapshot is null when the content is unchanged from the newest snapshot.

DELETE /api/project/:name/file?path=<rel>

Deletes a file, or a directory and its contents.

POST /api/project/:name/create

{ "path": "sections/intro.tex", "kind": "file", "text": "" }

kind is file or dir. Parent directories are created. 409 if it exists.

POST /api/project/:name/rename

{ "from": "old.tex", "to": "sections/new.tex" }

POST /api/project/:name/move

{ "from": "figures/plot.png", "to": "chapters" }

to is a directory; empty means the project root. Refuses to move a folder into itself, and refuses a destination that is not a directory. Returns the new path.

POST /api/project/:name/upload?path=<rel>

Raw body, any content type. Creates parent directories.

Compiling

POST /api/project/:name/compile

{ "mainFile": "main.tex", "engine": "latexmk" }

Both optional; omit for the project’s configured values. Requests arriving during a compile do not start a second engine: they wait and resolve with the result of the follow-up run.

{
  "ok": true,
  "project": "My Paper",
  "mainFile": "main.tex",
  "engine": "latexmk",
  "engineLabel": "latexmk + pdfTeX",
  "passCount": 1,
  "pdfAvailable": true,
  "pdfUpdated": true,
  "version": 7,
  "durationMs": 718,
  "diagnostics": [
    { "severity": "error", "file": "sections/results.tex", "line": 12,
      "message": "Undefined control sequence", "context": "\\thisIsNotACommand",
      "source": "log" }
  ],
  "errorCount": 0, "warningCount": 0, "infoCount": 2,
  "hasErrors": false, "engineMissing": false, "timedOut": false,
  "rawLog": "...", "rawStderr": "...", "finishedAt": 1785390000000
}

ok is true when a PDF exists and there were no errors. Note that pdfUpdated: false with ok: true is normal and means nothing changed, so the PDF on disk is already current.

severity is error, warning or info. file is attributed to the file that actually caused the problem, not the root document.

GET /api/project/:name/status

The last compile result without compiling, or { "result": null }.

GET /api/project/:name/pdf

The compiled PDF. 404 before the first successful build. Add any ?v= to bust the browser cache.

SyncTeX

GET /api/project/:name/synctex/forward?file=<rel>&line=<n>

Source position to PDF rectangles, in big points from each page’s top-left.

{ "rects": [ { "page": 1, "x": 133.7, "y": 288.2, "width": 344.1, "height": 11.9 } ] }

{ "rects": [], "reason": "no synctex data" } if the project has not been compiled.

GET /api/project/:name/synctex/inverse?page=<n>&x=<bp>&y=<bp>

PDF position back to a source location.

{ "location": { "file": "sections/results.tex", "line": 12, "column": 0 } }

{ "location": null } when nothing maps to that spot.

Document graph

GET /api/project/:name/graph

Parses \input, \include, \subfile, \label, \ref, \cite, \includegraphics, \bibliography and \addbibresource across the project.

{
  "project": "My Paper",
  "mainFile": "main.tex",
  "nodes": [
    { "id": "file:main.tex", "kind": "file", "label": "main.tex",
      "path": "main.tex", "ext": ".tex", "lines": 42, "words": 380,
      "isMain": true, "reachable": true, "degree": 6,
      "headings": [ { "kind": "section", "depth": 2, "title": "Introduction", "line": 12 } ] },
    { "id": "label:sec:intro", "kind": "label", "label": "sec:intro",
      "definedIn": "main.tex", "line": 13, "missing": false, "degree": 2 },
    { "id": "cite:ghost", "kind": "citation", "label": "ghost",
      "missing": true, "degree": 1 }
  ],
  "edges": [
    { "from": "file:main.tex", "to": "file:sections/results.tex",
      "kind": "include", "line": 30, "macro": "input" }
  ],
  "stats": { "files": 3, "bibFiles": 1, "labels": 8, "citations": 12,
             "assets": 2, "edges": 31, "words": 4210,
             "brokenRefs": 1, "brokenCites": 0, "orphans": 1 },
  "issues": [
    { "kind": "undefined-label", "message": "\\ref{sec:nope} has no matching \\label",
      "node": "label:sec:nope" }
  ]
}

Node kinds: file, bib, label, citation, asset. Ids are namespaced by kind, so file:main.tex and label:main.tex cannot collide.

Edge kinds: include, ref, cite, graphic, bibliography, defined-in. broken: true marks an edge pointing at something missing.

missing: true means referenced but not found. reachable: false means the file is not pulled in from the main document, and is only meaningful for file, bib and asset nodes.

Issue kinds, in the order they are returned: undefined-label, undefined-citation, missing-file, orphan-file, unused-label, uncited-reference.

Version history

GET /api/project/:name/history?path=<rel>

{ "path": "main.tex",
  "snapshots": [ { "at": 1785390000000, "hash": "a730173750ba", "storedBytes": 412 } ] }

Newest first. At most 60 per file.

GET /api/project/:name/history?path=<rel>&at=<ms>

{ "at": 1785390000000, "hash": "a730173750ba", "text": "..." }

POST /api/project/:name/history?path=<rel>

{ "text": "optional; reads the file from disk when omitted" }

Forces a snapshot. Returns { "snapshot": null } if that content is already the newest one.

Bibliography

GET /api/project/:name/bib/files

{ "files": ["references.bib"], "default": "references.bib" }

GET /api/project/:name/bib/search?q=<query>&limit=<n>&sources=<csv>

Searches DBLP, Crossref, OpenAlex and arXiv concurrently. limit caps at 25. sources narrows to a subset.

{
  "results": [
    { "title": "Attention Is All You Need",
      "authors": ["Ashish Vaswani", "..."],
      "venue": "NIPS", "year": 2017, "type": "inproceedings",
      "doi": null, "citations": 98234,
      "sources": ["dblp", "openalex"], "source": "dblp" }
  ],
  "errors": [ { "source": "crossref", "message": "timeout" } ]
}

Ranked by title similarity, with corroboration across sources as a tiebreaker. A source failing is reported in errors rather than failing the request.

POST /api/project/:name/bib/bibtex

{ "record": { "...": "a result from search" } }

Fetches authoritative BibTeX from DBLP or the DOI registrar, never reconstructing it from search results, and generates a non-colliding key.

{ "via": "dblp", "key": "vaswani2017attention",
  "entry": { "type": "inproceedings", "key": "...", "fields": {} },
  "bibtex": "@inproceedings{vaswani2017attention,\n  ...\n}",
  "original": "the upstream text, before tidying" }

POST /api/project/:name/bib/insert

{ "path": "references.bib", "bibtex": "@book{...}", "key": "knuth1984" }

Appends. 409 if the key is already there, so a double-click cannot corrupt the file. path defaults to the project’s first .bib.

POST /api/project/:name/bib/parse

{ "text": "@inproceedings{...}" }

Parses pasted BibTeX, handling @string macros, # concatenation, nested braces and LaTeX accents. Returns tidied entries with generated keys where absent.

POST /api/project/:name/bib/verify

{ "path": "references.bib" }

Checks every entry against the published record.

{
  "path": "references.bib",
  "summary": { "total": 12, "verified": 9, "minor": 1, "mismatch": 1,
               "incomplete": 0, "unverified": 1, "error": 0 },
  "entries": [
    { "key": "vaswani2017", "title": "Attention Is All You Need",
      "status": "mismatch", "via": "title",
      "reference": { "source": "dblp", "title": "...", "authors": [], "year": 2017 },
      "fields": {
        "author": { "verdict": "mismatch",
                    "detail": "author list is incomplete (8 expected, 2 present)",
                    "localValue": "Vaswani, Ashish and Shazeer, Noam",
                    "suggested": "Vaswani, Ashish and ..." }
      },
      "missingFields": [ { "field": "doi", "suggested": "10.xxxx/yyy" } ] }
  ]
}

status: verified, minor, mismatch, incomplete, unverified, error. Field verdict: match, minor, mismatch, missing, unknown.

This endpoint makes several network requests per entry, so it is slow on a large bibliography. Upstream lookups are rate-limited and retried with backoff.

POST /api/project/:name/bib/fix

{ "path": "references.bib", "key": "vaswani2017",
  "accept": ["author", "doi"], "result": { "...": "the entry from verify" } }

Rewrites only the accepted fields of that one entry, preserving surrounding comments. 409 if the entry changed on disk since verification.

Import

POST /api/import/archive?name=<filename>

Raw .zip body. Strips a redundant top-level folder, skips .git, node_modules and build artifacts, detects the root document.

{ "name": "Thesis", "mainFile": "main.tex", "fileCount": 42 }

Limits: 4,000 files, 400 MB. A name collision gets a numeric suffix. An entry whose path escapes the root fails the whole import with 400.

POST /api/import/folder?name=<name>

A whole directory in one request, using a length-prefixed bundle so no multipart parser is needed:

[4-byte big-endian header length][JSON header][file bytes, concatenated]

header = { "files": [ { "path": "Thesis/main.tex", "size": 813 }, ... ] }

Same response and limits as the archive endpoint.

Example: a compile-on-change watcher

#!/usr/bin/env bash
# Recompile whenever anything in the project changes, and report the result.
PROJECT="My Paper"
BASE="http://127.0.0.1:4173/api/project/$(python3 -c "
import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$PROJECT")"

fswatch -o ~/openleaf/projects/"$PROJECT" | while read -r _; do
  curl -s -X POST "$BASE/compile" -H 'Content-Type: application/json' -d '{}' \
    | python3 -c 'import json,sys; r=json.load(sys.stdin);
print("ok" if r["ok"] else f"{r[\"errorCount\"]} errors")'
done

Example: fail CI on a broken reference

#!/usr/bin/env bash
# Exit non-zero if the document graph reports any broken link.
curl -s "http://127.0.0.1:4173/api/project/My%20Paper/graph" | python3 - <<'PY'
import json, sys
g = json.load(sys.stdin)
bad = [i for i in g["issues"]
       if i["kind"] in ("undefined-label", "undefined-citation", "missing-file")]
for i in bad:
    print(f"::error::{i['message']}")
sys.exit(1 if bad else 0)
PY