Point at a spot in the PDF, reach the source line.
English | 한국어
A sample paper in the viewer: three pins, each with the line it reaches in the source.
Limn (림, "to depict clearly") closes the revision loop between people and agents on a LaTeX
manuscript. Drag over a spot in the PDF, write what should change, and Limn turns it into a pin: the
.tex file and line range (recovered with SyncTeX, cross-checked against the text), plus your note.
Agents read the pins as a short work list (pins.md, ~35 tokens per pin instead of a 1,500-token
screenshot), edit the source, and close each pin with what they changed; a person confirms. Co-authors
on the same private network pin too, and every pin records who left it.
- Single stdlib-only Python server, browser viewer with vector PDF rendering (bundled PDF.js)
- Several documents per manuscript (manuscript, response letter, view-only reviewer PDFs, figure documents, Markdown notes, text files) as tabs
- Figure documents: a figure set drawn by code (one PDF page per figure plus a
limn-figure-map/1element map) opens as a tab; a drag names the element and the script lines that drew it, and the pin's mark follows the element when the figure is rendered again - Markdown and text documents: a
.mdfile is drawn from its source, and a plain-text or source-code file (.py,.bib,.yaml,.txtand 44 more suffixes) is shown line for line, never parsed; their pins are lines of that file, with no build. Characters that hide or reorder text (Trojan Source) are shown by name wherever a source or a suggestion is shown - Rebuilds on demand or when the upstream branch moves (
--git-pull) - Threads, questions, @mentions, assignees, review before done, a diff view per pin
- Verdict pins: a document may declare up to five verdict words next to it (
<name>.limnverdicts.json, say accept / hold / delete); a person picks one with one press - under the note for the selection, or under a page of a figure set or a view-only PDF for the whole page - and the pin carries it asverdict, fixed once made, and the repository's agent carries it out by that repository's own rules - One [Reply] on a closed pin: a person's reply sends it back to the agent (a line under the box says so first; undo after sending), and a 30-day Trash for deleted pins
- One long-running instance per manuscript as a systemd user unit, exposed with
tailscale serve
Requires Python ≥ 3.10, uv, a TeX distribution with SyncTeX
(latexmk/pdflatex) and Poppler (pdftoppm, pdfinfo).
uv tool install git+https://github.com/dartworklabs/[email protected]
limn versionWith SSH access to GitHub, git+ssh://[email protected]/dartworklabs/[email protected] works too.
For one person reviewing on their own machine, use local authentication. The browser is the local owner and can confirm an agent's completed work; agents connect with API tokens.
limn serve --manuscript ~/papers/paper2 --main main.tex --port 18300 --auth localOpen http://127.0.0.1:18300/. For collaboration on your tailnet, use the default tailscale authentication instead:
limn serve --manuscript ~/papers/paper2 --main main.tex --port 18300
# several documents as tabs:
limn serve --manuscript ~/papers/paper2 \
--doc 'ms=Manuscript:manuscript/main.tex' --doc 'rr=Response:submission/review_response/review_response.tex'It listens on 127.0.0.1 by default. Share the tailscale instance with
tailscale serve --bg --https=18200 http://127.0.0.1:18300 and open its tailnet HTTPS address so the browser receives
your person identity. With default authentication, opening the loopback address without identity headers uses the
legacy local-agent identity and cannot confirm reviews. limn serve --help lists all options;
state (pins, builds) goes to --state-dir or ~/.local/share/limn/serve/<manuscript>-<hash>.
Direct limn serve --max-connections N optionally caps each listener's admitted live TCP connections with a
positive integer N. It counts pending workers, active requests and idle keep-alive sockets until physical cleanup.
Excess sockets close before HTTP, without an HTTP error response. Omitting the option keeps the existing unlimited
admission and worker-start failure behavior.
When enabled, uncertain worker startup stops admission and exits nonzero after existing runtime cleanup; other requests, daemon builds or comparisons may be interrupted. Watcher joins do not set a total exit deadline, and this adds no drain, rollback or universal child cleanup. Operators choose activation and N later from their host limits, legitimate demand, proxy behavior and rollback budget; removing the option restores unlimited admission. It does not limit per-person work cost or provide complete DoS protection. See operations and ADR-0016.
Security: never expose Limn without an identity provider in front of it. The default (
--auth tailscale) binds127.0.0.1and trusts the identity headers oftailscale serve; everyone on your tailnet who reaches the port is a collaborator unless you narrow it (--members-only, roles vialimn member).--auth localis for one user on their own machine;--auth trusted-proxyis for running behind an authenticating reverse proxy, and is the only provider that may--binda non-loopback address. Agents use API tokens (limn token create). Never a public URL without a proxy, nevertailscale funnel. See SECURITY.md.
limn add paper2 --manuscript ~/papers/paper2 --git-pull # ports, config, systemd unit limn@paper2, tailscale serve
[--ui-lang <ko|en>] # the viewer's default language (a person's choice wins)
limn list # label, ports, state, open pins, documents
limn status paper2 · limn url paper2 · limn snippet paper2 # details · tailnet URL · block for AGENTS.md
limn update [--dry-run] [--ref vX.Y.Z] # reinstall via uv tool, restart running instances
limn stop paper2 · limn start paper2 · limn remove paper2
limn token create paper2 · limn member add paper2 <login> --role viewer # agent token (shown once) · roles
limn token create paper2 --save # token file ~/.config/limn/paper2.token for agents on this machineConfig lives in ~/.config/limn/<name>.env, state in ~/.local/share/limn/<name> by default.
Details (Korean): docs/handbook/instances.md.
An agent reads pins.md (curl -s <base>/pins.md) or GET /api/pins, claims one pin right before
editing it, and closes it with a reply and a ref (commit/PR); a person confirms, or replies with what is
wrong, which reopens the pin into the open table. It authenticates with
a token from limn token create <instance> (Authorization: Bearer …); on the machine that serves the
instance, it reads the token from ~/.config/limn/<instance>.token (limn token create <instance> --save). Agents never
confirm, and skip pins claimed by others, awaiting review, or assigned to a person. The full procedure
is skill/SKILL.md; the API contract is docs/handbook/api.md (Korean). The pins.md format and
the HTTP API are a stable contract — they do not change with the UI language.
| skill/SKILL.md | agent procedure (install it as a skill in your agent runtime) |
| docs/handbook/ | the System Handbook (Korean): purpose, architecture, pin domain, viewer, build and sync, HTTP API and pins.md format, operations, instances, verification, workflow, coding rules |
| docs/adr/ | architecture decision records |
| SECURITY.md · CONTRIBUTING.md · CHANGELOG.md |
Access control design: ADR-0002, access control, collaboration boundaries and sync (the v0.2 stage is implemented; later stages are proposed).
Limn is licensed under AGPL-3.0-only. Commercial licenses are available from Dartwork (contact via GitHub issues for now). Bundled third-party components: THIRD_PARTY_NOTICES.md. Name and logo: TRADEMARKS.md.
Limn started on 2026-09-21 as the manuscript-pin-picker skill of the writing-agent-playbook
repository (server scripts/pin_server.py), and its per-paper instance manager bin/pin-viewer.sh
(systemd units pin-viewer@<name>, from 2026-09-23) lived in a dotfiles repository. On 2026-09-25 both moved here with
their history (git filter-repo, merged with --allow-unrelated-histories) and were renamed Limn.
Personal data in the imported history was replaced with placeholders.
Nothing is deleted; ports, tailnet addresses and pins carry over.
uv tool install git+https://github.com/dartworklabs/[email protected]
limn migrate --dry-run # plan: ~/.config/pin-viewer/<name>.env -> ~/.config/limn/<name>.env
limn migrate # copy configs (idempotent; old files stay)
# per instance:
systemctl --user disable --now pin-viewer@<name>.service
limn start <name> # renders [email protected], starts limn@<name>, waits for 200; serve entry unchanged
limn status <name> # active, local 200, tailnet URL, same open-pin countState directories stay where they are (the new config points at them). To also move state from
~/.local/share/pin-viewer/<name> to ~/.local/share/limn/<name>, run
limn migrate --move-state <name> after stopping the old unit and before limn start. When every
instance runs as limn@, ~/.config/systemd/user/[email protected], ~/.config/pin-viewer/ and
the old ~/.local/bin/pin-viewer can be removed.