A security-audited, hardened build of rr, the CLI that lets an AI coding
agent push markdown to a reMarkable tablet as a native notebook.
This repository is a derivative of
hiteshjoshi/remarkable_rust
by Hitesh Joshi (MIT). The tool, its v6 notebook writer and its cloud sync
client are his work, and the full upstream commit history is preserved here
with original authorship. It is published as a standalone repository, not a
GitHub fork, so that the audit and hardening work is visible on its own.
What this repository adds is the layer around that tool: a source-level security audit, a fix for a fail-open data-loss path, a rebuilt release pipeline, and tighter guardrails for agent use.
| Change | Finding addressed | Author |
|---|---|---|
Index parser fails closed; server-supplied index lines are re-emitted byte-for-byte; entry-count invariant before the root swap; previous root hash printed as a rollback handle; rr push --dry-run shows the exact root-index diff before anything is written |
The parser silently skipped any index line it could not read, then rewrote the account-wide root index without it, which would drop that document from the library on the next push | Jeremiah Tims |
Plaintext tokens.json dump removed and cleaned up on auth and logout; config.toml written owner-only (0700 directory, 0600 file) |
Long-lived device token left in a world-readable debug file that survived logout | Matthew Miller (upstream PR #5, merged here) |
| Markdown image embedding confined to the source directory, 10 MiB cap | Local file inclusion through the legacy upload path, reachable by injected markdown in an agent context | Matthew Miller (upstream PR #5) |
| Signed-upload redirects require HTTPS and an allowlisted storage host | Document bytes were PUT to any host named in a redirect | Matthew Miller (upstream PR #5) |
| Installer aborts without a verified SHA-256; no longer writes into agent configuration directories | Best-effort checksum; unrequested writes to ~/.claude, ~/.opencode, ~/.codex |
Matthew Miller (checksums, upstream PR #5); Jeremiah Tims (opt-in skills) |
| Table images positioned from sourced line heights | Tables overlapped the following heading on the device | Matthew Miller (upstream PR #6, merged here) |
| GitHub Actions rebuilt: actions pinned to commit SHAs, read-only default token, tests gate the release build, native Intel macOS runner with a pinned deployment target | Upstream release workflow targeted a retired runner label and tag-pinned actions | Jeremiah Tims |
rr root-restore <hash>: a plan-only rollback of the root pointer to an earlier root; --yes performs it under the same generation guard as a push |
The previous-root hash a push printed could not be acted on without handling the bearer token by hand | Jeremiah Tims |
0.3.6-jt.4: a document-API 401 is reported as a refusal that points to rr status first; rr ls reports entries it could not read and exits non-zero; rr push strips YAML frontmatter, strictly; rr skills refuses to overwrite a differing SKILL.md without --force; the page splitter ignores dash lines inside code fences; blank lines no longer inflate the table-position estimate |
Every 401 was labeled "token expired"; a listing could silently omit entries; frontmatter landed as a stray first page; one command could replace a customized skill file; a fenced YAML example cut a page in two; uneven spacing around tables (fixed in part, the rest awaits on-device measurement) | Jeremiah Tims |
0.3.6-jt.5: table images are drawn in the space reserved for them (heading height, paragraph wrapping and the image offset re-measured on a Paper Pro); the table stripper follows the GFM delimiter rule and skips code fences; the text frame starts higher on the page; rr inspect <id> shows, read-only, how one item is stored |
A table's image sat at the end of its reserved space or past it, so the text after a table ran beside it or above it; a | - | - | table was drawn while its source rows stayed on the page, and shifted every later table; the top margin was wider than the page needs; nothing could show how a folder or a trashed item is stored before mkdir and rm are rebuilt |
Jeremiah Tims |
0.3.6-jt.6: rr mkdir writes a folder through the same sync v3 endpoints as push: one .metadata file in the shape the reMarkable software writes, one new root-index line, --dry-run first; it refuses a name already in use, a parent that is missing, not a folder or in the trash, and a listing it could not read in full; a push and mkdir share one write loop |
rr mkdir called the document API, which refuses a browser-paired device with HTTP 401 even with Connect, so the client could not create the folder it pushes into |
Jeremiah Tims |
Finding codes: commit and tag messages cite fixes as F10 to F16, the
numbers they carry in the source audit behind this build. In the order
of the 0.3.6-jt.4 row above: F10 the 401 message, F11 rr ls,
F13 frontmatter, F14 rr skills, F15 the page splitter, F12
table spacing (step 1). In 0.3.6-jt.5: F12 step 2 is the
re-measured table placement, and F16 is the delimiter rule.
Both upstream pull requests were unmerged upstream when they were merged
here (2026-09-17); each diff was read line by line first. Audit and patch
work was done with Claude Code as a pair programmer; commits carry a
Co-Authored-By trailer where that applies.
Standing caveat. rr speaks reMarkable's internal, undocumented sync
protocol. reMarkable can change it without notice. Keep your tablet synced
and treat any third-party cloud client, this one included, as something
that can break.
Tell your coding agent:
"Send this to my reMarkable."
A few seconds later a native reMarkable notebook (handwriting-editable, the yellow-icon kind, not a PDF) shows up on your tablet.
rr is a small Rust CLI that turns markdown into a native v6 reMarkable
notebook locally and uploads it via the device's own cloud sync API.
Upstream reports that it works on any reMarkable account, with or
without a Connect subscription; this build has only been tested on an
account with Connect active. Agents drive it through a SKILL file that rr installs for
Claude, OpenCode, and Codex.
# 1. Install the binary (see Install below; checksum-verified)
# 2. Pair with reMarkable (one-time, browser-based)
rr auth
# 3. Opt in to the agent skill for the agent you use
rr skills --target claude --dry-run # preview
rr skills --target claude # install
# 4. Just ask. Restart the agent only if it does not see the new skill.Claude Code watches its skills directory and normally picks a new skill up inside the running session. If the agent does not see it (another agent, or a skills directory that did not exist when the session started), restart the agent process: close and reopen the CLI / app.
rr skills never replaces a SKILL.md that differs from the one it
ships: it refuses and names both files. Pass --force only if you mean
to overwrite your own edits.
In Claude / OpenCode / Codex, things like:
- "Summarize this thread and push it to my remarkable."
- "Save these meeting notes for my tablet."
- "Send this research as something I can read on my reMarkable later."
- "Push the Q2 plan to my reMarkable in the background."
The skill activates, the agent writes a clean markdown file, runs
rr push, and reports the document id. You pick up the tablet and the
document is already there, properly formatted, with real tables (more on
that below) and the title at the top.
Tables render as real grids because rr rasterizes markdown tables to
PNG locally and embeds them as image blocks directly inside the v6
notebook page. Headings, paragraphs, and bullets ship as native typed
text. Split a document into pages by writing --- between sections.
The SKILL also tells the agent to stay in Latin script (no fonts ship on the device for CJK, Devanagari, Arabic, Cyrillic, so anything else renders as tofu boxes), avoid emojis and ASCII art (the typed-text engine drops or mangles them), and pick descriptive filenames with dates so you can find docs on the tablet.
This repository publishes one pre-built binary: Intel macOS
(x86_64-apple-darwin), built natively by GitHub Actions from a tree
whose tests passed on the same runner. Every other platform builds from
source. For the full upstream platform matrix, see the upstream project.
Preferred: download the archive and SHA256SUMS from the
latest release,
verify, then place the binary yourself:
shasum -a 256 -c SHA256SUMS
tar xzf rr-x86_64-apple-darwin.tar.gz
install -m 0755 rr-x86_64-apple-darwin/rr ~/.local/bin/rrOr read install.sh first and then run it. It refuses to install without
a matching SHA-256, drops the binary at ~/.local/bin/rr (override with
INSTALL_DIR=$HOME/bin), and does not touch any agent's configuration
directory. Skills are opt-in through rr skills.
git clone https://github.com/jtims/remarkable-agent-push.git
cd remarkable-agent-push
./install.sh --dev # builds release and installsThe table and the Windows notes below are upstream's statements about the upstream release binaries. This repository only builds and tests Linux x86_64 and Intel macOS in CI, and only publishes the Intel macOS binary. Upstream's own binaries do not contain the hardening described above.
| Platform | Arch | Status |
|---|---|---|
| macOS | Apple Silicon (aarch64-apple-darwin) |
working |
| macOS | Intel (x86_64-apple-darwin) |
working |
| Linux | x86_64-unknown-linux-gnu |
working |
| Linux | aarch64-unknown-linux-gnu |
working |
| Windows | x86_64-pc-windows-gnu |
binary builds + ships; rr auth flow tested via Git Bash / WSL, native PowerShell pairing is community-reported |
The release binary is statically linked from pure-Rust dependencies. No cairo, no librsvg, no ImageMagick, no headless Chrome.
The bash installer above doesn't run on native Windows. Two options:
Option A — manual download (PowerShell or File Explorer):
-
Go to the upstream latest release page (unhardened build; no Windows binary is published here).
-
Download
rr-x86_64-pc-windows-gnu.zip. -
Extract it. Inside is
rr.exeand askills/folder. -
Move
rr.exesomewhere on yourPATH(e.g.%USERPROFILE%\bin\after adding that folder to PATH via System Properties → Environment Variables). -
From a new PowerShell window:
rr auth rr skills --target all # installs SKILL.md into ~/.claude, etc.
Option B — WSL: install the Linux binary inside WSL with the regular
curl | bash one-liner. Agents running in WSL pick up the skill the
same way.
Verify with rr --version once it's on your PATH.
The v6 typed-text engine on Paper Pro doesn't have a table primitive —
just paragraphs and bullets. So rr watches for markdown tables in the
source, renders each one to a PNG locally, and embeds the PNG as an
image block in the page right below the typed text. The agent just
writes ordinary markdown:
| Item | Quantity | Price |
|------|---------:|--------:|
| Pens | 3 | $4.50 |
| Pads | 1 | $12 |
| Tags | 24 | $0.05 |…and the device sees a sharp 1400-pixel grid with proper borders, header weight, right-aligned numbers, and multi-line cell text. No config, no flags.
The full pipeline, all in pure Rust:
markdown table ─── pulldown-cmark events ───► rows + alignments
│
│ build_table_svg
▼
SVG with borders, text,
per-column widths, wrapping
│
│ usvg parses + lays out
│ text via system fonts
▼
usvg::Tree
│
│ resvg paints onto a
│ tiny-skia Pixmap
▼
1400×N PNG bytes
│
▼
ImageRegistry + ImageItem block in the
page's v6 stream (sibling .png on disk)
Soft-wrapping happens before rasterization so long cells don't blow out the grid. Column widths fit the longest cell, capped at 1400px to match the reMarkable Paper Pro's reading area. Header rows get bold weight and a thicker underline.
The same machinery is available for arbitrary diagrams: build the SVG
yourself, pipe it through rr::raster::svg_to_png, and rr will embed
the PNG as a page image.
rr auth # pair the machine with reMarkable cloud (browser-based)
rr status # verify auth + cloud connectivity
rr logout # forget credentialsrr push notes.md # markdown → native v6 notebook
rr push doc.md --title "Custom Title" # override inferred title
rr push doc.md --device paper-pro-move # also: paper-pro (default), rm2
rr push - --title "From stdin" # read markdown from stdin
rr push doc.md --parent <FOLDER_UUID> # land inside a folder (ids: rr ls --folders)
rr push doc.md --dry-run # show the root-index diff, upload nothingrr root-restore <hash> rolls the root pointer back to an earlier root,
such as the previous root a push printed. It is plan-only by default:
it fetches the target index, refuses if that index does not parse, and
prints which lines would disappear and which would return. Add --yes
to perform it. The swap is guarded by the root generation, so a root that
moved in the meantime is refused, not overwritten.
--dry-run fetches the current root index, builds the one a real push
would upload (through the same code path), and prints the line-level
difference: how many existing lines are kept byte for byte, which line
would be added, and whether the order of existing lines is preserved. It
exits non-zero if any existing line would be dropped. Run it before the
first push to a library you care about.
On success rr push also prints previous root and previous gen: the
cloud root pointer as it stood before the push. Blobs are
content-addressed and never overwritten, so that hash identifies the
prior library state if a push ever needs to be undone.
Pushes land at the root of the device unless --parent is given, and
produce a native v6 notebook
the device renders directly — no cloud-side conversion step. Split the
markdown into multiple pages with --- horizontal-rule lines.
rr ls # list documents in the cloud
rr ls --folders # only show folders
rr inspect <id> # how one item is stored (read-only; see below)
rr mkdir "Work" # create a folder (see the note below)
rr rm <doc-uuid> # delete by id (see the note below)rr ls reads the same sync v3 endpoints push uses, and from
0.3.6-jt.6 rr mkdir writes through them too. A folder is one new
line in the root index, written with the same fail-closed parser, the
same one-line check and the same guarded root swap as a push, and
rr mkdir <name> --dry-run shows that line before anything is written
(the folder id a dry run prints is not kept; a real run draws its own).
The folder copies the shape the reMarkable software itself writes (one
.metadata file, read from a real library with rr inspect). It takes
one name, not a path; --parent <FOLDER_UUID> nests it. Before it
writes, it reads a complete listing and refuses a name that is already
in use where the folder would go, a parent that is missing, not a
folder or in the trash, and a listing it could not read in full. A
change made on another device after that read is not checked again.
rr rm still calls the older document API (/doc/v2/files), which
answered HTTP 401 when this build was tested against a paired Paper Pro
account with Connect active (2026-09-17). Treat it as not working:
delete documents on the tablet or in the reMarkable app. Rebuilding it
on sync v3, as a move to the trash, is planned.
rr inspect <id> prints how one document or folder is stored: its line
in the root index, its own index, and its .metadata and .content
JSON. --root <hash> reads the same from an older root, for a before
and after comparison. It only downloads, and it prints names and
metadata from your library to your terminal.
There's also a hidden rr connect-push command that builds an EPUB
locally and posts it to the reMarkable cloud's EPUB → notebook converter
(the original v0.2 pipeline). It's kept only as a fallback; rr push
produces the same native notebook with no cloud-side conversion.
connect-push is also the one with --background plus rr jobs,
rr logs, rr cancel for detached uploads.
rr skills --target all # install SKILL.md into claude/opencode/codex
rr skills --target claude # one agent
rr skills --dry-run --target all
rr skills --target claude --force # replace a SKILL.md that differs (refused otherwise)The SKILL files document the upload pipeline plus what renders well on the device and what doesn't, so agents make the right choices when generating content.
your.md
│
│ pulldown-cmark events → typed text + tables
▼
RootText block (paragraphs / bullets / headings)
+ per-table PNG → ImageRegistry + ImageItem blocks
│
│ rr writes binary v6 streams
▼
Per-page .rm v6 files + .metadata, .content, .pagedata
│
│ SHA-256 content-address every blob
│ PUT /sync/v3/files/<hash> (per blob)
│ PUT /sync/v3/files/<doc-index>
│ PUT /sync/v3/root (412-retry on race)
▼
Notebook appears on every paired device on next sync
The binary v6 format is the same one the device writes to its own
filesystem, so the cloud has nothing to convert — it just stores and
hands the blobs back to the tablet. That is why, according to upstream, this
path works without a Connect subscription (not tested here): it's the
same sync protocol every reMarkable device speaks to
internal.cloud.remarkable.com.
Some details:
- Markdown tables are rasterized to PNG locally and embedded as v6 image
blocks. Pipeline: SVG →
usvg→resvg→tiny-skia→ PNG. --device {paper-pro|paper-pro-move|rm2}picks the page dimensions and text-frame geometry. Default is Paper Pro.- Splitting on
---produces a multi-page notebook with one chunk per page.
See skills/claude/SKILL.md for the record of
what renders well on the device.
- One-way. Local → cloud. No download path.
- No update-in-place. Every push creates a new document; re-pushing the same source makes a duplicate. Delete the old one first, on the tablet or in the reMarkable app.
- Folder targeting is by id, not by name:
rr push --parent <FOLDER_UUID>with ids fromrr ls --folders. rr rmdoes not work in practice. It uses the document API, which upstream source comments describe as requiring a Connect subscription and which returned HTTP 401 in testing even with one. From0.3.6-jt.4that 401 is reported as a refusal that points torr status, not as an expired token.rr mkdirno longer uses it (from0.3.6-jt.6).- No inline emphasis. The v6 typed-text engine on Paper Pro doesn't have inline bold/italic/code styling; the text arrives, just without the styling. Code blocks, images embedded in markdown, and footnotes are silently skipped today.
- No native fonts for non-Latin scripts on the device. Anything outside Latin renders as tofu.
Your reMarkable device and user tokens are stored locally at
~/Library/Application Support/rr/config.toml (macOS) or
~/.config/rr/config.toml (Linux), written owner-only (directory 0700,
file 0600). The user token is also mirrored to the OS keychain on macOS
and Windows. Nothing else holds credentials: the legacy tokens.json
debug file is no longer written and is deleted on rr auth and
rr logout if an older build left one behind.
The binary contacts reMarkable hosts only:
webapp-prod.cloud.remarkable.engineering: pairing and token refresh.internal.cloud.remarkable.com: sync v3, the endpoint every reMarkable device talks to. Pushes, listings and the root pointer go here.web.<region>.tectonic.remarkable.com: the regional document API used byrmand the legacy pipeline.- The storage host named in a signed-upload redirect, which must be HTTPS and on an allowlist.
No analytics, no telemetry, no third-party services. install.sh also
contacts GitHub to download the release.
MIT.
- The reMarkable team for shipping a great device and a usable cloud API.
- The
rmsceneproject, whose reverse-engineering of the v6 binary format made the native pipeline possible. - The "Read on reMarkable" Chrome extension for shipping source maps,
which sped up the original EPUB-pipeline reverse-engineering (now the
hidden
connect-pushfallback). - The
usvg/resvg/tiny-skiacrates — without them "tables to PNG" wouldn't be a 50-line module.