# Limestone documentation --- # Limestone # Limestone A local-first lab notebook you and your agents can both use. Your notes are Markdown files in a folder you own — on your disk, readable without Limestone, yours if you stop using it. ## Start here - **[Installing Limestone](installing-limestone.md)** — the macOS app, and what to expect on first run. - **[Writing notes](note-editor-guide.md)** — blocks, slash commands, `@` mentions, tables and images. - **[Searching](note-search-guide.md)** — finding things across a vault. ## Working with agents Limestone is an MCP server, so an agent — Claude Desktop, or the one built into the app — can read and write your vault directly. - **[The agent console](agent-console.md)** — running an agent against a vault. - **[Reference](reference/operations.md)** — every operation an agent can call, generated from the code that defines them. ## Reading this with an agent The whole of this site is available as plain Markdown: [`/llms.txt`](llms.txt) indexes it, [`/llms-full.txt`](llms-full.txt) is all of it in one fetch, and every page is served at its own `.md` address — this one is [`index.md`](index.md). Point an agent at any of those and ask it your question rather than reading a page to find the answer. ## Your vault - **[Syncing](syncing-your-vault.md)** — a vault on a shared drive. - **[Encrypted vaults](encrypted-vaults-guide.md)** — what encryption covers, and the key file that recovers it. - **[Trash](trash-guide.md)** — what deleting does, and how to undo it. --- # Installing Limestone (macOS beta) # Installing Limestone (macOS beta) Limestone runs on your own computer — your notes and data stay in your folders, and nothing goes to our servers. During the beta the desktop app is **macOS-only** (Windows and Linux are coming) and **unsigned**, so the first launch takes one extra click. ## 1. Download Get the app from **[app.limestone.bio](https://app.limestone.bio)** — the **Download for macOS** button — or directly: - **[Limestone-macOS.dmg](https://downloads.limestone.bio/Limestone-macOS.dmg)** (universal — works on both Apple Silicon and Intel Macs). ## 2. Install 1. Open the downloaded `Limestone-macOS.dmg`. 2. Drag **Limestone** into your **Applications** folder. 3. Eject the disk image. ## 3. First launch (the unsigned-app step) Because the beta build isn't signed by Apple yet, macOS Gatekeeper will warn you the first time. This is expected — do this **once**: - **Right-click** (or Control-click) **Limestone** in Applications and choose **Open**, then **Open** again in the dialog. - If you don't see an Open option, go to **System Settings ▸ Privacy & Security**, scroll to the message about Limestone, and click **Open Anyway**. After the first time, Limestone opens normally like any other app. ## 4. Get started Open the app and sign in with your Limestone account (the email your invitation was sent to). Then: - **Set your encryption passphrase** when prompted — this publishes your key so an admin can grant you access to shared vaults. See [encrypted-vaults-guide.md](encrypted-vaults-guide.md). - **Get your team's shared-vault folder onto your Mac** via your sync tool (Google Drive, Dropbox, Syncthing), then add it as a **sync folder** — Limestone attaches it automatically. See [syncing-your-vault.md](syncing-your-vault.md). - **Open the shared vault** and start working. ## Troubleshooting - **"Limestone is damaged and can't be opened."** This can appear on downloads that macOS quarantined. Remove the quarantine flag in Terminal: `xattr -dr com.apple.quarantine "/Applications/Limestone.app"`, then open it. - **The download says "coming soon" / "macOS-only."** The button is live once a beta release is published; on Windows/Linux the desktop app isn't available during the beta yet. --- # Platform differences (browser vs desktop) # Platform differences (browser vs desktop) A living log of every place the **desktop** shell intentionally differs from the **browser** build (ADR-0099). The rule: differences are **decisions recorded here**, never accidental drift. All _behavior_ should otherwise be identical — that's what the shared E2E suite enforces. If you add a platform branch or a `configure*` seam implementation, add a row. | Area | Browser | Desktop | Seam / mechanism | | ------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------- | --------------------------------------------------------------------------------- | | Local-API origin | Relative `/api` (dev proxy / same-origin); `VITE_API_ORIGIN` for the WebSocket | Absolute origin the Rust shell spawns + injects | `platform/apiOrigin.ts` (`configureApiOrigin`, `window.__LIMESTONE_API_ORIGIN__`) | | vault folder picker | Backend OS picker (`/api/vault/choose-folder`) or manual path | Native Tauri directory dialog | `configureNativeVaultFolderPicker` / `platform/desktopVaultFolderSelection.ts` | | API lifecycle | External (dev server / same-origin serve) | The Tauri shell spawns + supervises the local API | `apps/desktop/src-tauri` | ## Deliberately NOT yet different (deferred, ADR-0099) - **App distribution** — desktop is unsigned and still uses the workspace API; a self-contained signed installer (Python sidecar + notarization) is a later sprint. - **Windows / Linux** — macOS only for now. - **Multi-window / detachable panes**, native menus/tray beyond basics — not started. --- # Writing notes: blocks, slash commands & @ mentions # Writing notes: blocks, slash commands & @ mentions The note editor is a block editor that saves plain **Markdown** to your vault — what you see is what's on disk, so your notes stay portable. ## Inserting blocks — "/" or the "+" button Two ways to add a block (heading, list, quote, code, divider, **table**, **image**): - Type **`/`** at the start of a line to open the insert menu, then keep typing to filter — e.g. `/table` inserts a 3×3 table, `/h2` a heading. Arrow keys + Enter pick; Escape closes. - Or click the **+ Insert** button below the note for the same menu with the mouse. **Image** opens a file picker and embeds the file in the note's folder (you can also paste or drag images/PDFs/CSVs straight in). ## Mentions — "@" Type **`@`** anywhere to open the mention menu, then keep typing to filter: - **A note name** → inserts a **link chip** showing the note's title; click it to open that note. (Stored as `[[note]]`.) - **`@now`** → stamps the **current date & time**, shown in your time-format preferences. (Stored as `[[date:…]]`.) - **`@date`** → opens a **calendar**; pick a day to insert that date. (Stored as `[[date:YYYY-MM-DD]]`.) - **A person's name** → inserts a **user tag** showing their display name, storing their handle so it stays correct across devices. (Stored as `[[@handle]]`.) Everything round-trips to clean Markdown: chips, tags, and dates are just readable `[[…]]` tokens in the file, so opening the note in any Markdown app shows the source and Limestone re-renders the chips. --- # Searching your notes # Searching your notes Limestone can search the **full text** of your notes — titles _and_ bodies — from a dedicated search window (ADR-0096). Click the **magnifying-glass icon** at the top of the Collections pane (just left of the ⋯ menu) to open it. ## How it works - **Live results.** As you type, a ranked list of best matches updates in real time. Results are ordered by relevance (BM25). - **Preview on hover.** Point at any result to read that note's body in the pane on the right — no need to open it first. - **Open on click.** Click a result to jump straight to that note (its collection is selected for you). ## Advanced filters - **Title only vs All note text** — restrict matching to titles, or search everything. - **Collection** — limit to one collection, or search across all of them. - **Creator** — find notes created by a particular person. - **Date** — a from/to range, matched against either the note's **created** or **edited** date (your choice). By default the search covers **every note**, including ones you haven't filed into a collection yet. Setting the collection filter narrows it. ## Local and private Search is powered by a **local index** on your own machine that Limestone builds from your note files. It's a disposable cache — nothing about your notes is sent to any server, and the index rebuilds itself from your files if it's ever cleared. The `⌘K` (Ctrl-K) quick-find still works as before for fast **title** jumps; the search window is the place to go for full-text and filtered search. --- # Trash & deleted notes # Trash & deleted notes Deleting a note doesn't destroy it — Limestone moves it to a **Trash** collection (ADR-0052/0097), where it stays read-only until you restore it or it's purged. ## Where Trash lives Trash is pinned at the **bottom of the collections sidebar**, in the **System** group — Favorites, Skills, Memory, Trash — with Trash last. It only appears once you've deleted your first note. ## Restoring & viewing Open Trash and it looks like any collection, but **read-only** and decluttered to the essentials: each row shows the note's **title**, when it was **Deleted**, and its **Original collection**. Custom properties are hidden. - **Restore** a note with the **↩ button to the left of its name** — in the table row, and in the note header when you open it. It returns to its original collection (or, if that's gone, you're asked where to put it). - **Delete permanently** removes a single note for good (from its open view). ## Retention & emptying — Settings ▸ Trash **Settings ▸ Trash** controls how long deleted notes are kept: - **Retention** — pick how long notes stay in Trash before they're **permanently deleted automatically**: 7, 14, 30, 60, or 90 days (default 30). This is a **per-vault** policy, stored in the vault itself, so it's consistent across your devices and collaborators. - **Empty Trash now** — permanently delete every note currently in the Trash (after a confirmation). This can't be undone. The countdown on each trashed note ("removed in N days") reflects the retention you've set. --- # Limestone personalization # Limestone personalization Appearance, language, and time settings (ADR-0094). All three are **device-local** — stored in this browser/desktop install, never synced to your account or vault — and live under **Settings ▸ Preferences**. Clearing them is one click in **Settings ▸ General ▸ Reset Preferences**. ## Appearance - **Display theme** — System (follows your OS), Light, or Dark. - **Text size** — Small / Default / Large / Extra large. Because the whole interface is defined in relative (`rem`) units, one setting scales every screen proportionally. - **High contrast** — an independent switch that layers pure black/white grounds, stronger hairlines, and thicker focus rings over whichever theme you're in (light _or_ dark). Useful for bright rooms and low-vision needs. The three axes combine freely: light/dark × normal/high-contrast × four sizes. ## Time Limestone stores every timestamp in UTC and renders it in **your** conventions: - **Time zone** — any IANA zone; defaults to the one your device reports. - **Date format** — ISO (`2026-08-03`), Friendly (`Aug 3, 2026`), US (`08/03/2026`), or European (`03/08/2026`). - **Clock** — 24-hour or 12-hour. A live preview of the current time shows the effect. System timestamps across the app — table Created/Edited columns, the trash countdown, version-history entries — all follow these settings. Relative times ("2h ago") stay relative but gain an absolute-time tooltip in your zone. ## Language Pick the interface language: **System** (follows your browser), English, Español, Français, Deutsch, or 日本語. Switching takes effect immediately — no reload. Coverage this release is the **core chrome**: the Settings dashboard, the app header menus, and shared dialogs/toasts. Anything not yet translated falls back to English automatically, so nothing ever shows blank — more surfaces are translated over time. --- # Syncing your vault across devices # Syncing your vault across devices A Limestone vault is just a **folder** of Markdown notes, notebooks, and attachments. Limestone reads and writes that folder on your local disk — it has no built-in cloud accounts and talks to no sync service. So the way you sync a vault between machines is simple: **put the folder inside any tool that mirrors a folder to your other devices, and open it in Limestone like any local folder.** That means you're not locked into one provider. Google Drive, Dropbox, an open-source tool you run yourself — if it keeps a real folder in sync, it works. ## The one thing that makes this safe Limestone keeps its search **index (a SQLite database) outside the vault**, on each machine, and rebuilds it from your files. So the folder you sync is only text and attachments — there's no database file riding the sync, which is the usual cause of "cloud sync corrupted my notes." Nothing for you to configure; it's just how vaults are built. ## Three rules for smooth syncing These apply to **every** tool below — they're the nature of folder sync, not a Limestone limitation: 1. **Mirror, don't stream.** Some clients can keep files "online-only" as placeholders and download them on demand. Set the vault folder to be kept **available offline / mirrored** so Limestone can scan every file quickly. 2. **One machine at a time.** Sync tools can't merge two simultaneous edits to the same note — they keep both as a "conflicted copy". Edit on one device, let it sync, then switch. (Limestone helps you resolve conflicts when they do happen — see below.) 3. **Let it finish syncing** before opening the vault on another machine (wait for the client's "up to date" / green-check state). ## Let Limestone find synced vaults automatically You don't have to hunt for a synced shared vault's folder. Tell Limestone where your sync tool writes — a **sync folder** — and any vault that lands there is attached and shown as **Open** on its own: - In the shared-vault picker, expand **Sync folders** and **Add sync folder**, then pick your Google Drive / Dropbox / Syncthing directory. - Anything you keep under **`~/Limestone`** is always scanned — point your sync tool there and there's nothing else to configure. Limestone matches a synced folder to your organization's vaults by the id inside its `.limestone/vault.json`, so it only ever auto-attaches vaults your org already lists. (Sync folders are per-device, like the rest of your local settings.) ## Tools that work You only need **one** of these. ### Google Drive, Dropbox, OneDrive, iCloud Drive Install the provider's **desktop app**, let it create its synced folder, and put (or open) your vault inside it. Point Limestone at that path. Use the provider's **mirror / "available offline"** setting for the vault folder rather than online-only streaming. ### Syncthing — open-source, no server, no third party [Syncthing](https://syncthing.net) syncs a folder **directly between your own devices** over the network — there's no cloud account and no server in the middle, which makes it a great fit if you care about data sovereignty. Add the vault folder as a shared folder on each device and pair them. Syncthing keeps a real local copy on every device (no streaming to worry about). ### Nextcloud / ownCloud — self-hosted Run your own [Nextcloud](https://nextcloud.com) (or ownCloud) server and install its **desktop sync client**, which mirrors a folder to your machine just like Dropbox. Keep the vault folder set to sync locally (not "virtual files"). This gives you a server _you_ control, with web access, on infrastructure you own. ### Seafile — self-hosted [Seafile](https://www.seafile.com) works the same way via its desktop **sync** client (use the syncing client, which keeps a normal local folder, rather than a drive-mount). > **Power-user note.** Raw network/FUSE mounts (rclone `mount`, WebDAV, SMB/NFS) > can also present the folder, but they add latency and quirks around atomic file > writes, so they're not recommended as your everyday setup. `rclone bisync` (a > two-way _folder_ sync) is a better fit if you go the rclone route. ## When two devices collide: conflicted copies If the same note is edited on two devices before they sync, your sync tool keeps both files, renaming one — for example: - `Notes/Paper (conflicted copy 2026-07-26).md` (Google Drive, Dropbox, Nextcloud) - `Notes/Paper.sync-conflict-20260805-120000-AB12CD3.md` (Syncthing) Limestone **recognizes these** across the tools above, so a conflicted copy never shows up as a stray duplicate note. Instead it's surfaced for **resolution**: Limestone can fold non-overlapping edits together and let you pick a winner for anything that truly clashes. Resolved siblings are tidied into the vault's Trash (ADR-0056). If you use a tool whose conflict filenames don't match the shapes above, the copy will still be safe on disk — it just won't be auto-detected. Let us know the naming and we can add it. ## What _not_ to sync Nothing extra to exclude. Your provider syncs the whole vault folder, and the per-machine index lives outside it, so there's no database or cache to keep out of sync. Your API keys and encryption unlock material also live **outside** the vault (on each machine), so syncing the folder never carries your secrets to the cloud. --- # Limestone encrypted vaults # Limestone encrypted vaults Encryption for shared vaults (ADR-0091, key custody per ADR-0125). A local vault stays plaintext and fast; a **shared** vault is encrypted so access is controlled by membership of your organization, not by who a Drive folder happens to be shared with. **What Limestone can and cannot see.** Your vault's files never reach us — they live in your own folder and sync through your own Drive or Dropbox. What we _do_ hold is each shared vault's key, so that signing in is enough to open the vaults shared with you. That means **Limestone can decrypt a shared vault's contents**, and so could anyone who compromised or compelled us. This is a deliberate trade, and it replaced an earlier design in which we held no keys at all. That design cost every user a second passphrase, a first-run setup step, a recovery code, and — for organizations — a shared escrow passphrase, all because we had made ourselves unable to answer "is this person allowed in?". All four are now gone. What you keep either way: - your files are ciphertext at rest and everywhere they sync, so **Dropbox and Google Drive still see nothing**; - filenames stay opaque, so a folder listing reveals no titles or structure; - sharing a Drive folder still grants nobody access; - and your organization holds a **key file** we never see, so you can open and export your vaults even if Limestone is unreachable, or gone. ## What it protects (and what it doesn't) - **Encrypted at rest, everywhere it syncs.** Note bodies, data files, notebooks, attachments, the members list, collection schema, and version history are all ciphertext on disk. Whoever the Drive/Dropbox folder is shared with — including the storage provider — sees only ciphertext. - **Opaque filenames (ADR-0092).** Files and folders are named by internal ids, so a folder listing reveals **no titles, collection names, or structure** — the readable names live only in the encrypted content. What _still_ leaks on any synced store: the **number** of files/folders, their **sizes** and **modification times**, and the file-**type** mix (`.md`/`.csv`/…) — "metadata shape," not content or its names. The local search index (on your own machine, never synced) stays plaintext; encrypting it is a separate follow-up. - **Membership is access.** You can open a shared vault if you are a member of the organization that owns it. Removing someone stops us releasing the key to them straight away — but a key already on their laptop is beyond our reach, so rotating the vault's key is what actually locks them out of anything written afterward. ## How the keys work Each vault has one **data key**. There are three ways it reaches you, tried in this order, and none of them asks you to type anything: 1. **This device.** A key your machine has already been given is kept locally, so a vault you have opened here opens again with no network at all. 2. **Your account.** Limestone holds the key and releases it to members of the owning organization. This is why signing in is enough, and why a new member needs no admin to act for them. Keys are stored encrypted, so a database dump alone opens nothing — but we hold the means to decrypt them, which is the trade described above. 3. **Your key file.** Every organization gets one when it's created, downloaded once and kept by you — in a password manager or a safe. It is wrapped into every vault your organization owns, so it opens them with **no Limestone server involved**. This is what makes an outage an inconvenience rather than a loss, and it is why we ask you to save it before you can continue. Limestone does not keep a copy of your key file and cannot reissue it. ## Encrypting a vault 1. Open the vault, then **Vault ▸ "Encrypt this vault…"** (you must be signed in). 2. Confirm. There is nothing to choose and nothing to write down. Existing files are re-encrypted in place and their names become opaque. The vault's key is wrapped to your organization's key file and published to your account, so every member can open it. You cannot encrypt a vault before your organization has saved its key file. That is deliberate: a vault encrypted with no key file could not be opened if Limestone were unreachable. ## Unlocking Usually you don't. Opening a shared vault gets its key from this device or from your account, and the vault simply opens. You see **"This vault is encrypted"** only when neither worked — you are offline on a machine that has never opened this vault, or you are not a member. Then the gate offers **recovery with your key file**, described below. If you are a member and this happens on a vault encrypted before ADR-0125, an admin needs to open it once — that is what publishes its key to the organization. Try that before recovering anything. ## Decrypting a local vault A **personal** vault can be turned back into a plain, unencrypted vault — useful if you encrypted it to try the feature or no longer need it. 1. Open the vault (it unlocks on its own if you have opened it here before). 2. **Settings ▸ Vault ▸ Encryption ▸ "Decrypt this vault…"**, then confirm. Every file is decrypted in place and readable filenames are restored (attachments keep their internal names — the original was discarded when the vault was encrypted). The lock badge next to VAULT disappears. **Decrypting is blocked for a vault owned by an organization.** Its key is held for every member, so un-encrypting it would strip protection from other people's data as well as yours. Move it out of the organization first. You can also click the lock badge left of the VAULT label at any time to jump to these controls. If the reason you want it decrypted is that Limestone is unreachable, that is what recovery is for — see below. ## Your key file, and recovery Every account and organization gets one key file when it is created, and saving it is not skippable. It is the one thing that gets your data out with no Limestone server at all. **It is a fire escape, not a second way to sign in.** Using it does one thing: it **recovers** the vault — decrypts every file in place, restores readable names, and makes the vault local. An organization's vault stops being shared. That pairing is deliberate rather than a limitation. "Shared" means the account service releases the key to members; if that service is reachable you did not need the key file, and if it is not, no sharing can be honoured anyway. Handing back an unlocked-but-still-shared vault would be promising something nobody can keep. So recovery converts the vault into the thing it can actually be on its own: a plaintext folder you hold outright. **To recover a vault:** open it, and when the gate appears choose **"Recover with a key file…"**, read what it costs, pick your key file, and confirm. It touches no network. Afterwards the folder is ordinary files you can read, copy, or re-encrypt. It cannot be undone from inside Limestone — if you want the vault protected and shared again, encrypt it again once you can sign in. - Keep it where you keep a password-manager export or a safe-deposit item. It will still matter in five years. - **We do not have a copy and cannot reissue it.** Lose it and you lose the offline path — not your data, as long as your account still works. - If it leaks, an owner generates a new one and **rotates** each vault's key (below). The old file then opens nothing written afterwards. ## Sharing an encrypted vault 1. Encrypt the vault first (sharing an unencrypted vault is blocked). 2. **Vault ▸ Share** ▸ _Connect this vault to your account_, then invite members by handle or email. 3. That is the whole of it. A member opens the vault by signing in — there are no keys to hand out, nothing for them to set up first, and no admin who has to be present for it. 4. Share the underlying **folder** (Drive/Dropbox/…) with them too — that just distributes the ciphertext; membership is the real access control. **Removing a member** takes effect the next time their Limestone can reach us: before opening a shared vault it asks whether the person is still entitled to the key, and on being told no it deletes the copy held on that device and shows them that their access was removed. The vault's files stay on their disk, encrypted. Being offline delays it, but no longer indefinitely. A device that has not had your membership confirmed for **24 hours** stops opening the vault from its cached key until it can ask us again. So a removal takes effect within a day whether or not the person cooperates, and no vault has to be re-encrypted for it. **Owners are exempt, deliberately.** An owner holds the account key file and can already recover the data with no Limestone in existence, so expiring their cache would take away convenience without taking away any power — and it would turn an outage into apparent data loss for the one person who most needs it not to be. If we ever have to switch the servers off, owners keep working. The remaining limit is honest and unavoidable: this is a **cooperating-client** control, not a cryptographic one. Someone who deliberately copied the key out of their own machine keeps whatever had already synced to them, and no message from us reaches it. **Rotating the key** is the answer to that. It mints a new key, re-encrypts every file to it, and republishes — so anything written afterwards is unreadable to the old key, wherever that key ended up, and every other member's device has to come back to us for the new one. Anyone who had the vault open at the time is told it was re-keyed and reopens. It cannot un-read what someone already copied, which is unavoidable once bytes have left your machine. Rotation is a **deliberate** act in **Settings ▸ Vault ▸ Encryption**, not something removing a member does automatically: re-encrypting an entire vault is not a thing to trigger by accident, and the check-in deadline already handles the ordinary departure. ## Rotating a vault's key **Settings ▸ Vault ▸ Encryption ▸ "Rotate key to revoke…"** (admins, on an open vault). It mints a new key, re-encrypts every file to it, wraps it to your organization's key file, and republishes it — so everyone still in the organization keeps access without doing anything. Rotate when you remove someone who should not keep reading, or when a key file may have leaked. It protects **future** content only. ## Where to store a shared vault Because the vault is end-to-end encrypted, the ciphertext is safe to put **anywhere** — the storage choice is now about sync convenience, not trust: - **Google Drive / Dropbox / OneDrive / iCloud Drive** — a desktop-sync folder mirrored to each collaborator; point Limestone at the local mirror. Drive is fine for ~10 people editing mostly their own notes (asynchronous; it is not real-time co-editing, and concurrent edits to the _same_ note may create merge conflicts that Limestone reconciles). - **Syncthing** — peer-to-peer, no third party ever holds even the ciphertext. The strongest privacy option; devices sync directly when online. - **Your cloud node** (ADR-0065) — your own always-on box can double as a private sync peer. Limestone deliberately has **no Google Drive API integration** — it treats a shared vault as "an encrypted folder some external tool syncs," so you stay in control of where your data lives. ## Gotchas - **Losing your key file is survivable; losing your account access is what to worry about.** As long as you can sign in, your vaults open. The key file is the answer to us being unreachable, not to you being locked out. - **A vault nobody has opened since this changed** still carries only its old keyring. An admin opening it once publishes its key; until then, new members cannot get in. - **Removing a member is instant at our end and not at theirs.** Rotate the key if that distinction matters for the vault in question. - A shared _Drive folder_ being over-shared is no longer a leak — non-members just get unreadable bytes. --- # The agent console (TUI) # The agent console (TUI) The Limestone agent runs in a terminal-style console in the bottom dock (ADR-0100). It opens on the `[[limestone]]` banner and renders a turn as structured output: - `> your prompt` — the turn you sent. - `⏺ answer` — the agent's answer. - **tool-use blocks** — `⏺ tool(args)` then `⎿ output` (pretty-printed structured output). - **thinking** — the agent's intermediate reasoning, dimmed and collapsible. - a **status line** with a braille spinner while a turn runs (`thinking… / running {tool}…`). While the model streams, its text types out live with a **blinking cursor**, and **Stop** interrupts the turn. It's a theme-aware terminal (mono + the `--ground` surface + hairline frames), so it follows light/dark like the rest of the app — not a hardcoded dark panel. ## Testing streaming + interrupt Token streaming (B3) and the interrupt (B4) have both **automated** and **manual** coverage, because the test **fake agent** returns whole messages near-instantly (no token deltas, no long turn to cancel mid-flight): **Automated (CI):** - Streaming — `tests/unit/agent/test_service.py::test_run_turn_streams_content_deltas` scripts a smolagents stream (content deltas → a final) and asserts `run_turn` emits `assistant_delta` + `final`. Frontend: `AgentChat.test.tsx` asserts deltas merge into one live message. - Interrupt — `test_interrupt_stops_the_turn_cleanly` interrupts **mid-turn** via a fake model that calls `interrupt()` after step 1, asserting a clean stop (no answer, no error). `test_agent_ws.py::test_chat_survives_an_interrupt_frame` proves the socket survives an `{"interrupt": true}` frame and stays usable. Frontend: `Stop sends a real interrupt frame`. **Manual (needs a real model — configure a provider first):** 1. `make desktop-dev` (or the web dev server), open the agent dock. 2. Ask something substantive ("summarize my Experiments collection and suggest a next step"). **Streaming:** watch the reasoning text type out token-by-token with the cursor. **Interrupt:** on a long turn, click **Stop** — it should halt at the next step boundary and return to an idle prompt, socket still live; send another message to confirm the session continues. If a provider's model has no `generate_stream`, streaming falls back to whole-step text (correct, just no cursor) — that's expected. --- # Organizations & the active organization # Organizations & the active organization A Limestone account can belong to several **organizations**. One of them is your **active organization** — the org that every org surface follows at once: the header **Organization** menu, Settings ▸ **Organization** / **Invite Members** / **Permissions**, and the vault **encryption** controls. ## Switching organizations - **Header ▸ Organization** lists your orgs when you belong to more than one; pick one to make it active (the active org is marked). Settings ▸ Organization has the same switcher. - The choice is remembered per device. Every screen tracks it — no more each tab quietly showing a different (first) org. ## Roles & the encryption gates Roles are strictly hierarchical: **owner > admin > user > read-only**, each inheriting everything below it (ADR-0088). Settings ▸ **Permissions** shows the full role→capability matrix; owners can retune the **adjustable minimums**. Vault encryption is governed by three adjustable capabilities (ADR-0101), shipped with these defaults: | Capability | Default minimum role | | ------------------------------ | -------------------- | | **Encrypt a vault** | Admin | | **Create a shared vault** | Admin | | **Decrypt / un-share a vault** | Owner | So by default an **admin** can encrypt a vault, but only an **owner** can decrypt it. The Encryption settings tab disables the Encrypt / Decrypt buttons (with a reason) when your role in the active org isn't high enough. A **personal** vault — one not tied to an organization — keeps the simple behaviour: any signed-in user can encrypt it, and its owner can decrypt it. > **Enforcement note.** In this first step the role gates are **advisory** — a UI > guard, like note-locking. The _real_ access control for a shared vault is > cryptographic: only members whose key wraps the vault key can read it. Wiring > membership to the keyring (grant-on-join, rotate-on-remove) is the next step in > the shared-vaults epic (ADR-0101, Sprint 3). ## Shared vaults An organization can own **shared vaults** — always encrypted, and accessible to every member of the org (your org role governs what you can do inside). They appear under a **Shared vaults** heading in the "Open an existing vault" picker on the landing screen when the owning org is active — alongside your personal vaults, which are listed under **Local vaults**. **Creating one (admins).** In the Shared vaults list, choose **Create shared vault…**, give it a name and an encryption passphrase, and pick an empty folder. Limestone creates and encrypts the vault, registers it to your org, and grants the key to members who've already set an encryption passphrase. You'll get a **one-time recovery code — save it** — and a reminder to **share the folder** with your org through your sync tool (Google Drive, Syncthing…). Members who haven't set a passphrase yet show as **pending** and can't open it until they do. **Opening one (members).** The vault must be **on your device** — get its folder from your organization via the shared sync tool. Then pick it under **Shared vaults** in the "Open an existing vault" picker: Limestone opens it straight away if it already knows the folder, or shows **Locate folder…** so you can point it at the synced copy the first time. Unlock with your passphrase as usual. **How files travel.** Limestone never uploads your vault — the account service only knows _which_ vaults an org has and _who's_ in it. The files reach you through your own sync tool, exactly like a personal vault (see `docs/syncing-your-vault.md`). ## Joining, onboarding & removing (the key lifecycle) Because vaults are end-to-end encrypted, access is a **key** wrapped to you — and only an admin's _unlocked_ device can hand one out. So the lifecycle is **reconciled when an admin opens the vault** (ADR-0103): - **Set an encryption passphrase first.** Until you do, you're **pending** — an admin can't grant you a key. In the Shared vaults list (or Settings ▸ Encryption) choose **Set an encryption passphrase**; it publishes your public key so admins can grant you. - **Joining after a vault exists.** When an **admin** opens a shared vault, Limestone auto-**grants** the key to any member who needs it and shows a short summary ("granted N; M pending"). There's also a **Re-sync members** button. - **Removing someone.** Removing a member stops all _future_ grants immediately. To cryptographically revoke them, an admin uses **Rotate key to revoke** (Settings ▸ Encryption) — this re-encrypts the vault to a new key and issues a new recovery code. **Rotation protects future content only:** a removed member keeps whatever already synced to their device — you can't un-send bits. See `planning/adr/0101-org-shared-vaults.md`, `…/0102-org-owned-shared-vaults.md`, and `…/0103-shared-vault-key-lifecycle.md`. --- # Inviting members # Inviting members Bring teammates into your Limestone organization from **Settings ▸ Invite Members** (ADR-0098). You need to be signed in to a Limestone account and be an **admin or owner** of the organization. ## Sending an invite 1. Open **Settings ▸ Invite Members**. 2. Check the **seat count** — "N of M seats used". Each active member and each pending invite takes a seat. If you're out of seats, add more from **Settings ▸ Organization** first. 3. Enter the person's **email address**, pick a **role** (Read-only / User / Admin — you can't grant a role above your own), and **Send invite**. They get an email with a link. The invite reserves a seat while it's pending. ## What the invitee does The email links to a page where they **create their Limestone account** (using the email you invited) and get a **download link** for the app. Once they accept, they're a member of your organization and the pending invite is consumed. - Invites **expire after 14 days** (configurable). An expired link is refused — just send a fresh invite (it also refreshes the window). - After accepting, the invitee gets a **get-started checklist** (download, sign in, set a passphrase, sync the vault folder, open it). During the beta the app is a **macOS-only, unsigned** download — see [installing-limestone.md](installing-limestone.md) for the one-time Gatekeeper step. On Windows/Linux the download honestly says the app is coming. ## Managing members Full member management — changing roles, removing people, adjusting seats and billing — lives in **Settings ▸ Organization**. Invite Members is just the quick "add someone by email" path. ## Notes - **Seats are enforced by the account service**, so you can't over-invite past your seat count (the invite is refused with a clear message). - Access to a shared vault's _files_ is still governed by how the vault's storage (e.g. a Google Drive folder) is shared — see the shared-vault setup. Org membership sets identity, roles, and seats. --- # Limestone cloud setup — accounts, organizations & billing # Limestone cloud setup — accounts, organizations & billing Operator guide for the hosted backend that powers **accounts, organizations, and seat billing** (ADR-0078). It builds on the multi-user control plane (ADR-0053): the Account service (`apps/account`, FastAPI) owns identity, orgs, and memberships and **never stores vault content** — vault files live in each user's own storage (Google Drive today). **Design goal: keep backend ops on as few platforms as possible.** The backend is Python/FastAPI + SQLAlchemy + Postgres, so we host it on **Render**, which runs the API, its Postgres, and the static web build in one place. Two things are specialist services you plug in on _any_ platform — **payments** (Stripe) and **transactional email** (Resend) — so those stay external. DNS stays with **Porkbun**, your registrar. That's the whole footprint. ## 1. Architecture **Local-first (ADR-0084):** the **local app is the product and the only front-end**. It's free and works without an account; account creation/login, org management, and invites happen **inside the local app**, which calls the hosted control plane. `app.limestone.bio` is **not** a second app — it's a minimal **portal**: a landing page and the download CTA. Identity pages (join, signup, login, reset, verify) are served by the account service at `accounts.limestone.bio` (ADR-0122). **Administration is NOT here** — it lives in a separate repo (`limestone-admin-internal`) behind Tailscale, calling the account service's locked `/internal/*` API (ADR-0085). ``` Local app (apps/web + apps/api) ── the product; talks to ──┐ Log in / orgs / invites, inline │ VITE_ACCOUNT_URL Porkbun (registrar + DNS) ──► accounts.limestone.bio ──────▼ (Render Web Service: FastAPI limestone_account) │ │ │ │ │ └─► Render Postgres │ └─► Stripe (per-seat subs) └─► Resend (invite / verify / reset email) app.limestone.bio (Render Static Site, VITE_DEPLOY_TARGET=web) = landing + email deep-links only (portal); admin is a separate repo (ADR-0085) Local app validates account JWTs offline via JWKS ─────────┘ ``` | Concern | Service | One platform? | | ------------------------- | ---------------------------------------------- | ---------------------------- | | Account+Org API (FastAPI) | **Render Web Service** | ✅ Render | | Relational DB | **Render Postgres** | ✅ Render | | Web static (`apps/web`) | **Render Static Site** | ✅ Render | | Domain + DNS | **Porkbun** (registrar you already use) | — | | Payments / per-seat subs | **Stripe Billing** | external (any host needs it) | | Transactional email | **Resend** (or Postmark) | external (any host needs it) | | Signup bot protection | **Cloudflare Turnstile** (standalone — no DNS) | external, optional | | Backups / object storage | **Cloudflare R2** or AWS S3 (S3-compatible) | external, optional | > **Naming:** the product was renamed wetline → limestone. The service package is > `limestone_account` and all env vars are `LIMESTONE_ACCOUNT_*`. Any older doc > saying `wetline_account` / `WETLINE_ACCOUNT_*` / `.wetline/` is stale. ## Deploy it agentically (Blueprint as code + Render API/MCP) The goal is minimal dashboard clicking. Render supports two layers of control, both used here: 1. **`render.yaml` (Infrastructure as Code)** — committed at the repo root, it _is_ the deployment: the account **Web Service**, the **Static Site**, and the **Postgres** database. Edit it + push and Render syncs the topology. The DB URL wires itself (`fromDatabase`, normalized by `config.py`), so there's no connection string to copy. `Dockerfile` (repo root) builds the service. 2. **Render API / MCP / CLI** — for the few imperative bits: setting the `sync: false` secret _values_, triggering deploys, and reading logs. Add the **Render MCP server** to your agent so it can drive all of this: ```bash claude mcp add render -- npx -y @render/mcp-server # then export your Render API key (Account Settings → API Keys) for it: # RENDER_API_KEY=rnd_... ``` With that, an agent can create/update services, set env vars, deploy, and tail logs via natural language — no dashboard. **The irreducible manual steps** (can't be automated away): create the Render account, generate one **API key** (unlocks everything above), authorize Render's GitHub app on the repo once (or create the Blueprint through the API), and obtain the third-party **secret values** (Google client secret, Stripe keys, the JWT PEM) from those providers — then hand them to the agent to set via the API. **First deploy, two ways:** - _Dashboard (one time):_ **New → Blueprint → pick this repo** → Render reads `render.yaml` and provisions everything. Then the agent sets the `sync: false` secrets via MCP. - _Fully agentic:_ ask the agent (Render MCP) to create the Blueprint from the repo and set the secrets — zero dashboard. Sections 2–8 below are the underlying details (what each service/secret is); `render.yaml` already encodes 2–5. ## 2. Render — the Account service (`apps/account`) Render runs long-running Python natively. Deploy from the repo with a Dockerfile (so the build is reproducible and the `prod` group's Postgres driver is present): ```dockerfile # Dockerfile (repo root) FROM python:3.12-slim RUN pip install uv WORKDIR /app COPY pyproject.toml uv.lock ./ COPY apps/account ./apps/account # prod group adds the Postgres driver (psycopg) RUN uv sync --frozen --group prod --no-dev EXPOSE 8100 CMD ["uv","run","uvicorn","limestone_account.main:app","--app-dir","apps/account","--host","0.0.0.0","--port","8100"] ``` In Render: **New → Web Service → from the repo**, runtime **Docker**. Render sets `$PORT`; either bind to it (`--port $PORT`) or set Render's health-check and container port to `8100`. Add the env/secrets from §8. **Health check path** `/api/health`. Once live: `GET https://accounts.limestone.bio/api/health` → `{"service":"limestone-account"}` and `/.well-known/jwks.json` returns a key. Optionally define both services + the database as code in a **`render.yaml`** blueprint (web service, static site, and a `databases:` entry) so the whole backend deploys from one file. ## 3. Render Postgres — the database Render has managed Postgres in the same dashboard (no external DB provider): 1. **New → Postgres**, name `limestone-accounts`, pick a region/plan. 2. Copy the **Internal Database URL** and convert the scheme to SQLAlchemy's: `postgresql+psycopg://USER:PASSWORD@HOST/limestone_accounts`. Use the internal URL so DB traffic stays on Render's private network. 3. Set it as the `LIMESTONE_ACCOUNT_DATABASE_URL` env var on the web service. Schema is created on boot (`create_all`). **Before the first production data, add an Alembic baseline** — the `db.py` docstring flags this; altering columns later (as this epic did) needs migrations once real accounts exist. ## 4. Render Static Site — the web client (`apps/web`) 1. **New → Static Site → from the repo.** 2. Build command `pnpm --filter @limestone/web build`, publish directory `apps/web/dist`. Node 24. 3. **Environment variable** `VITE_ACCOUNT_URL = https://accounts.limestone.bio` — switches on account sign-in (unset ⇒ Local-only). Build-time. 4. Add a rewrite rule `/* → /index.html` (SPA fallback). Render serves it over its CDN with automatic TLS. ## 5. Porkbun — domain & DNS Keep the domain and DNS where you already are. In Porkbun's DNS editor, point the hostnames at Render (Render then auto-issues the TLS certs): | Host | Type | Value | | --------------------------------- | --------- | ------------------------------------------------------ | | `app.limestone.bio` (web) | CNAME | the Render Static Site target (`…onrender.com`) | | `accounts.limestone.bio` (API) | CNAME | the Render Web Service target (`…onrender.com`) | | `limestone.bio` (root, optional) | **ALIAS** | the Render target (Porkbun supports ALIAS at the apex) | | verification TXT (if Render asks) | TXT | the value Render shows | Add each hostname as a **custom domain** on the matching Render service so Render provisions the certificate. No Cloudflare needed; if you ever want edge CDN/WAF/DDoS, you can move the nameservers to Cloudflare later (a 15-minute change, no code impact). ## 6. Google OAuth (existing) + RS256 signing key 1. Google Cloud Console → OAuth consent screen (External), scopes `openid email profile` — **no Drive scopes**. 2. Create an **OAuth client → Web application**; redirect URI `https://accounts.limestone.bio/auth/google/callback`. Note client id/secret. 3. Generate a stable RS256 key (rotating it invalidates live sessions): `openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048`. Store the PEM as `LIMESTONE_ACCOUNT_JWT_PRIVATE_KEY` (paste as a Render secret / secret file). Email + password signup works with **no Google setup** — handy for local/staging: bring up only the Account service + Postgres (§2–3) and use the email/password flow. ## 7. Externals — Stripe, Resend, Turnstile These aren't Render-specific; any backend needs them. **Stripe — per-seat billing ($2/seat/month).** Cloudflare/Render/etc. have no payments product. 1. Create a Stripe account; enable **Stripe Tax** (with Stripe you're the merchant of record and owe sales tax/VAT — Paddle would remove that burden at higher fees, the trade-off from planning). 2. **Product** "Limestone seat" → a **recurring Price**, $2.00 USD / month, **per-unit** (quantity = seat count). Note the `price_…` id. 3. API keys → `STRIPE_SECRET_KEY`. **Webhook** → `https://accounts.limestone.bio/billing/webhook` for `checkout.session.completed`, `customer.subscription.updated/deleted`, `invoice.payment_failed`; copy the signing secret → `STRIPE_WEBHOOK_SECRET`. 4. Seat model: an org's seat count = the subscription **quantity**. The service ships a **billing stub** today (`billing.py`, `SeatBilling`); the billing slice swaps in a `StripeBilling` impl behind the same interface — no route changes. **Resend — transactional email** (org invites now; address verification / password reset later). Render has no email; Cloudflare Email Routing is inbound-only. **Wired** as of ADR-0079 — an admin inviting an email with no account yet sends an accept link. Without a key the service still records the invite; it just doesn't deliver it. 1. Create a Resend account → **Domains → Add domain** `mail.limestone.bio` (a subdomain keeps the apex DNS free for the website). Resend shows a set of DKIM/SPF/DMARC records — add them in **Porkbun** DNS, then click **Verify**. 2. **API Keys → Create** (sending permission) → the `re_…` value goes to **BWS** as `RESEND_API_KEY` (never pasted into Render by hand — Claude sets it on the Render service from BWS). 3. The sender/from-address is `LIMESTONE_ACCOUNT_EMAIL_FROM` (default `Limestone ` in `render.yaml`) — its domain must match the verified domain above. Identity links — invite, password reset, email verification — point at `LIMESTONE_ACCOUNT_ACCOUNTS_URL` (`https://accounts.limestone.bio`) → `/join?token=…`, `/reset-password?token=…`, `/verify-email?token=…`, served by the account service itself (ADR-0122). `LIMESTONE_ACCOUNT_APP_URL` now carries only links that just open the product. **Cloudflare Turnstile — signup bot protection (standalone).** Turnstile needs only a Cloudflare account for widget keys — **no DNS or proxy**, so it works with Porkbun DNS + Render hosting. Create a widget → `TURNSTILE_SITE_KEY` (client) + `TURNSTILE_SECRET_KEY` (server verify); the service verifies the token before creating a user. (Wired with the email/password UI slice.) ## 8. Environment variable reference **Account service (Render Web Service env / secrets):** | Variable | Purpose | | ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | | `LIMESTONE_ACCOUNT_DATABASE_URL` | Render Postgres URL (`postgresql+psycopg://…`); dev default = SQLite file | | `LIMESTONE_ACCOUNT_ISSUER` | public base URL `https://accounts.limestone.bio` — JWT `iss` + Google redirect base | | `LIMESTONE_ACCOUNT_GOOGLE_CLIENT_ID` / `_SECRET` | Google OAuth client (§6) | | `LIMESTONE_ACCOUNT_JWT_PRIVATE_KEY` | RS256 PEM, **stable across deploys** | | `LIMESTONE_ACCOUNT_ACCESS_TTL` / `_REFRESH_TTL` | token TTLs (default 900s / 30d) | | `STRIPE_SECRET_KEY` / `STRIPE_PRICE_ID` / `STRIPE_WEBHOOK_SECRET` | billing (§7) | | `RESEND_API_KEY` | transactional email — Resend send key (§7); unset ⇒ invites persist but don't send | | `LIMESTONE_ACCOUNT_EMAIL_FROM` | invite sender, e.g. `Limestone ` (Resend-verified domain) | | `LIMESTONE_ACCOUNT_APP_URL` | portal base for emailed deep-links (default `https://app.limestone.bio`) | | `LIMESTONE_ACCOUNT_CORS_ORIGINS` | browser origins allowed to call the API — the local app + portal (ADR-0084) | | `LIMESTONE_ACCOUNT_REDIRECT_ORIGINS` | OAuth token-handback allowlist (local app + portal); blocks open-redirect (ADR-0084) | | `LIMESTONE_ACCOUNT_INTERNAL_TOKEN` | service token gating the internal admin API `/internal/*` (ADR-0085); held only by the admin plane | | `TURNSTILE_SECRET_KEY` | signup verification (§7) | **Local app (`apps/api`)** — validates account JWTs offline: `LIMESTONE_ACCOUNT_JWKS_URL = /.well-known/jwks.json` and `LIMESTONE_ACCOUNT_ISSUER` (must match). Unset ⇒ Local-only mode. **Web (`apps/web`, build-time):** `VITE_ACCOUNT_URL = ` (the local app defaults to `https://accounts.limestone.bio` in code — set this only to override / self-host / disable with `""`) and `VITE_TURNSTILE_SITE_KEY`. The **app.limestone.bio static build** additionally sets `VITE_DEPLOY_TARGET=web`, which makes it a **portal** (landing + email deep-links only) and never the app (ADR-0084); local builds leave it unset. ## 9. Secrets & key rotation - Never commit secrets; use Render **Environment** / **Secret Files**. - The **JWT signing key** must be stable and shared by every instance — rotating it signs out everyone. To rotate safely, publish both keys in the JWKS during an overlap window before retiring the old one. - Restrict Stripe/Resend keys to least privilege; verify the Stripe webhook signature on every call (`STRIPE_WEBHOOK_SECRET`). ## 10. Smoke test 1. `GET https://accounts.limestone.bio/api/health` → `ok`; `/.well-known/jwks.json` returns a key. 2. Web: **Create account** with email + password → you're signed in as `@handle`. 3. Settings → **Organization** → **Create organization**; you're its admin (1 seat, used 1). 4. **Add a seat** (stubbed/free today), then **invite** a teammate by email → they get a pending invite; "used" increments; the (seats+1)-th invite is refused. 5. Teammate signs up with that email and **accepts** → becomes an active member. 6. Sign in with **Google** still works; both land you signed in. ## Optional — Cloudflare edge later You don't need Cloudflare to launch. If you later want DDoS scrubbing, a WAF, or edge caching, move the nameservers from Porkbun to Cloudflare and turn on the proxy in front of the Render services. Cloudflare then sits at the front door; Render still runs all the code + data. No application changes. (Turnstile, above, needs none of this.) ## What's next (billing slice — see ADR-0078) Wire the live Stripe path behind the existing `SeatBilling` seam: Checkout for adding seats, the webhook to sync subscription quantity ↔ `Account.seats`, proration, and the failed-payment/downgrade flow. Then Turnstile + Resend for signup/invite email, and (later) encryption-gated vault keys (`VaultKey`). --- # Operations # Operations Everything an agent can ask Limestone to do, whether it is the agent in the app or one connected over MCP. Each is exposed as `limestone_`. Generated from `contract/operations.py`, which is the same registry the MCP server builds its tools from — so this page cannot describe an operation that does not exist, or miss one that does. ## `limestone_attach_file` **writes** · needs an unlocked vault Attach a base64-encoded file to a note, linked via a vault_file property. FOR CONTENT THAT IS NOT IN THE VAULT YET. To link a file the vault already holds — anything limestone_create_notebook or limestone_write_note_file returned a path for — use limestone_set_property with that path instead. Sending the bytes back through here re-writes a file that is already there, and puts its whole contents through your context on the way. | parameter | meaning | | --- | --- | | `collection` | A collection id or name. | | `note_path` | The note's vault-relative path. | | `filename` | The attachment's file name (allowed: .pdf, .csv, .ipynb, images). | | `content_base64` | The file's contents, base64-encoded. | ## `limestone_capture_environment` **writes** · needs an unlocked vault Write down what an environment contains, so another machine can rebuild it. Writes requirements.txt + requirements.lock.txt into the vault's Environments collection. Pass `interpreter` to record an environment Limestone did NOT build (a conda env the user already had): its packages are read and written down, and nothing is installed into it. | parameter | meaning | | --- | --- | | `name` | The environment to record. | | `interpreter` | Optional absolute path, to capture an environment we did not build. | ## `limestone_create_collection` **writes** · needs an unlocked vault Create a collection (a vault sub-folder) with an optional property schema. | parameter | meaning | | --- | --- | | `name` | The collection name. | | `properties` | Optional list of {"name": …, "kind": …} property definitions. | ## `limestone_create_environment` **writes** Create a new, empty Python environment for this vault. Use this when the user's work needs packages that no existing environment has — build one for the task rather than installing into theirs. Then add packages with limestone_install_packages and select it with limestone_set_environment. | parameter | meaning | | --- | --- | | `name` | A short name of your choosing, e.g. "rna-seq" — letters, digits, dashes and underscores only. This is a *new* name, not one from limestone_list_environments: to use an environment that already exists, pass its `environment` value to limestone_set_environment instead. | ## `limestone_create_notebook` **writes** · needs an unlocked vault Create a notebook (.ipynb) in a note's folder. Returns the notebook's `path`, the note's `note_path` — which MOVES if this is the first file in a flat note's folder — and the cell count. To make the app open it in the notebook editor, link it with limestone_set_property: pass the returned `path` as the value of the collection's vault_file property (often "Attachment"). No bytes, no re-upload — the file is already in the vault. Then run it with limestone_run_notebook. | parameter | meaning | | --- | --- | | `note_path` | The vault-relative path of the note whose folder to create it in. | | `filename` | A bare file name ending in .ipynb, e.g. 'analysis.ipynb'. | | `cells` | Ordered list of {"cell_type": "code"|"markdown", "source": str}. | ## `limestone_create_row` **writes** · needs an unlocked vault Add a row (a note) to a collection with an optional Markdown body. Use `body` for prose deliverables (protocols, write-ups) — the content lives in the note itself, not a notebook. | parameter | meaning | | --- | --- | | `collection` | A collection id or name. | | `title` | The row's title. | | `body` | Optional Markdown body (the note's prose content). | | `values` | Optional {property_name: value} map of typed properties. | ## `limestone_delete_account` REFUSED — deleting an account cannot be undone, so no agent does it. Calling this returns the place the user goes instead: Settings ▸ Danger Zone ▸ Delete account, which asks them to type the name first. It exists so that asking has an answer (ADR-0227 §3). A capability that is merely absent invites a way round it. ## `limestone_delete_environment` **writes** · needs an unlocked vault Delete an environment Limestone built, reclaiming its disk. Managed environments only — never one found on the machine, which belongs to the user. The spec stays in the vault, so it can be rebuilt with limestone_recreate_environment. Reports which notebooks are now bound to something absent. | parameter | meaning | | --- | --- | | `name` | The managed environment's name. | ## `limestone_delete_organization` REFUSED — deleting an organization cannot be undone, so no agent does it. Calling this returns the place the user goes instead: Settings ▸ Danger Zone ▸ Delete organization, which asks them to type the name first. It exists so that asking has an answer (ADR-0227 §3). A capability that is merely absent invites a way round it. ## `limestone_delete_row` **writes** · needs an unlocked vault Delete a note (row) by moving it to Limestone's Trash — a reversible soft-delete, NOT a permanent removal. The note keeps its identity and the user can restore it from the Trash in the app (a 30-day window). Use this when the user asks to delete or remove an entry; never tell the user to delete files by hand or through another app — the vault is worked through Limestone. Refuses a locked note and a note already in Trash. | parameter | meaning | | --- | --- | | `path` | The note's vault-relative path. | ## `limestone_delete_vault` REFUSED — deleting a vault cannot be undone, so no agent does it. Calling this returns the place the user goes instead: Settings ▸ Danger Zone ▸ Delete vault, which asks them to type the name first. It exists so that asking has an answer (ADR-0227 §3). A capability that is merely absent invites a way round it. ## `limestone_edit_note_file` **writes** · needs an unlocked vault Replace an exact snippet in a file in a note's folder. `find` must appear EXACTLY ONCE. If it is missing or appears more than once, the file is left untouched and the error says which — a refusal is recoverable and a wrong edit in a synced folder is not. Read the file first (limestone_read_file) so `find` matches its text exactly, including indentation. | parameter | meaning | | --- | --- | | `note_path` | The vault-relative path of the note. | | `filename` | The file in that note's folder. | | `find` | The exact text to replace. Must occur once. | | `replace` | What to put in its place. | ## `limestone_edit_notebook_cell` **writes** · needs an unlocked vault Replace one cell's source in a notebook, leaving every other cell alone. This is how a notebook is edited. Do NOT rewrite one with limestone_create_notebook — it refuses a path that exists, and serializing .ipynb JSON by hand loses cells, outputs and ids. Cells are numbered from 0. The write carries the revision just read, so it cannot overwrite an edit made in the app in between. | parameter | meaning | | --- | --- | | `notebook_path` | The vault-relative path of the .ipynb. | | `cell_index` | Which cell to replace, numbered from 0. | | `source` | The cell's new source. | ## `limestone_file_attachment` **writes** · needs an unlocked vault _Documented with its handler rather than here: this operation is declared for classification and its surfaces are hand-written._ ## `limestone_get_note` needs an unlocked vault Read a note's frontmatter facts and body. | parameter | meaning | | --- | --- | | `path` | The note's vault-relative path. | ## `limestone_get_notebook_environment` needs an unlocked vault Where a notebook will run, and why there. Ask this before concluding anything about why a notebook failed. A notebook runs in the environment it is bound to; one bound to nothing runs in the vault default, which is a different environment and a common surprise. `source` says which: notebook / vault / fallback. `unowned` means no note owns the file, so it cannot hold a binding at all. | parameter | meaning | | --- | --- | | `notebook_path` | The vault-relative path of the notebook. | ## `limestone_import_notebook` **writes** · needs an unlocked vault Import a notebook-app export into a new collection, returning a summary to finalize. Accepts a file (Evernote ``.enex``), a folder (an Obsidian vault), or an archive (a Notion ``.zip``). Does the whole bulk conversion in one call — never import notes one at a time. Load the ``importing-from-`` skill first, then finalize the ``unconverted`` items. | parameter | meaning | | --- | --- | | `collection_name` | Name for the new collection (defaults to the source name). | | `path` | Absolute path to the export file or folder on this machine. | ## `limestone_install_packages` **writes** Install Python packages into an environment you created. Only environments Limestone manages can be installed into — not the system Python, not a conda environment, and not the app's own runtime. That is deliberate: those belong to the user or are replaced on update. Create one with limestone_create_environment first. | parameter | meaning | | --- | --- | | `environment` | The managed environment's name. | | `packages` | Package specs, e.g. ["pandas", "scipy==1.14.1"]. | ## `limestone_list_agents` Which agent runs in Limestone's dock, and which others this machine has. Each says whether it is installed and runnable right now; one that is not installed is still a legal choice. ## `limestone_list_collections` List every Limestone collection with its property schema. ## `limestone_list_environments` List the Python environments available on this machine for running notebooks. Use this before running a notebook, to see whether the current environment can actually run it. Two separate facts, and you need both. `can_run_notebooks` is whether the environment has a kernel (`ipykernel`) — an environment without one runs nothing at all, so never select it. `has_data_science` is whether numpy and pandas import, the usual proxy for a real analysis environment. An environment can have a kernel and no analysis packages, which is fine and is what a fresh Limestone environment looks like; install into it. `is_current` marks the one this vault is configured to use. ## `limestone_list_note_folder` needs an unlocked vault The files in a note's own folder, by name. A note's folder is where its files live — attachments, images it embeds, and anything a run saved. Read this before changing anything in it. A note with no folder returns an empty list rather than an error. | parameter | meaning | | --- | --- | | `note_path` | The vault-relative path of the note. | ## `limestone_list_settings` List Limestone's user-level settings, their values and choices. ## `limestone_load_skill` needs an unlocked vault Load one skill's full instructions on demand (progressive disclosure). The agent's brief advertises each skill's name + description; when a task matches one, call this to read its full `SKILL.md` procedure before acting. Returns the skill's instructions plus the paths of its bundled files (scripts/references), which you can reference by path. | parameter | meaning | | --- | --- | | `name` | The skill's advertised name (or its folder name). | ## `limestone_lock_note` **writes** · needs an unlocked vault Lock a note so it can no longer be edited through Limestone — protect data once it's recorded, so a later mistake (yours included) can't change it. A locked note rejects all further edits. You can lock but CANNOT unlock; unlocking is a deliberate user action. | parameter | meaning | | --- | --- | | `path` | The note's vault-relative path. | ## `limestone_move_note` **writes** · needs an unlocked vault Move a note into another collection. A note belongs to the collection whose folder holds its file, so this is the ONLY correct way to change it — never move a note's file with a shell command or another app. A bare `mv` looks like it worked and is not the same thing: a note holding attachments lives in its own folder and moving only the `.md` strands that folder, its attachment references keep naming where they used to be, and the checks below are skipped. Properties the destination has no column for do not disappear. By default they are written into the top of the note's body as plain `Name: value` lines; with create_missing_properties they become hidden columns on the destination instead. Ask the user which they want when it matters. Refuses a locked note, a move into or out of the Trash (delete and restore own those), a read-only collection, and the collection the note is already in. | parameter | meaning | | --- | --- | | `path` | The note's vault-relative path. | | `collection` | The destination collection's name or id. | | `create_missing_properties` | Add the note's unmatched properties to the destination as hidden columns, instead of writing them into the note's body. Default false. | ## `limestone_query_records` needs an unlocked vault Query a collection's rows (title, path, properties, tags, links). | parameter | meaning | | --- | --- | | `collection` | A collection id or name (e.g. "Experiments"). | | `filter` | Optional {"property": name, "op": "equals"|"contains", "value": str}. | ## `limestone_read_file` needs an unlocked vault Read a vault file's contents by its vault-relative path. | parameter | meaning | | --- | --- | | `path` | The file's vault-relative path. | ## `limestone_recreate_environment` **writes** · needs an unlocked vault Build an environment on this machine from what the vault recorded. For a vault opened somewhere new: the spec travels with the folder and the venv does not. Reports which file it built from — the lock reproduces exact versions, the requirements file does not. | parameter | meaning | | --- | --- | | `name` | The environment to rebuild. | ## `limestone_remember` **writes** · needs an unlocked vault Keep a durable fact about the user or their work in the vault's Memory, so you still have it in a later session. For things that stay true — how they like to work, what their project is, a decision they have made. Not for the contents of this conversation, and not for anything you can read from the vault when you need it. A title already in Memory is REPLACED, so reuse one to revise a fact. | parameter | meaning | | --- | --- | | `title` | A short, stable name for the fact — the memory's identity. | | `fact` | The fact itself, in a sentence or two. | ## `limestone_run_notebook` **writes** · needs an unlocked vault Run a vault .ipynb locally; returns status + summary. Inline outputs stay in the notebook; files the script saves become attachments on the notebook's note. | parameter | meaning | | --- | --- | | `notebook_path` | The vault-relative path of the notebook to run. | ## `limestone_search` needs an unlocked vault Search note titles and bodies, best matches first, each hit with a snippet. | parameter | meaning | | --- | --- | | `query` | Text to match. | | `scope` | Where to match: "all" (titles and bodies) or "title". | | `limit` | Maximum number of hits to return. | ## `limestone_set_agent` **writes** Hand Limestone's dock to another agent — including this one's own replacement. Undone by choosing again, which is what makes it something an agent may do (ADR-0227). | parameter | meaning | | --- | --- | | `agent_id` | An `id` from `limestone_list_agents`, e.g. "limestone" or "claude-code". Anything else is refused with the list. | ## `limestone_set_environment` **writes** Set the vault-wide DEFAULT environment, used by notebooks that are not bound to one of their own. To give a single notebook its own environment — usually what is wanted — use limestone_set_notebook_environment. | parameter | meaning | | --- | --- | | `name` | A managed environment's name (one you created with limestone_create_environment), or an absolute path to a Python interpreter from limestone_list_environments. The choice is written to the vault, so it travels with the folder. | ## `limestone_set_note_body` **writes** · needs an unlocked vault Overwrite a note's Markdown body; frontmatter is preserved. | parameter | meaning | | --- | --- | | `path` | The note's vault-relative path. | | `body` | The new Markdown body (the note's prose content). | ## `limestone_set_notebook_environment` **writes** · needs an unlocked vault Bind ONE notebook to an environment, leaving every other notebook alone. Not limestone_set_environment, which sets the vault-wide default. Use this when a particular notebook needs particular packages. | parameter | meaning | | --- | --- | | `notebook_path` | The vault-relative `.ipynb`. | | `environment` | A managed environment's name, or an absolute interpreter path for one already on the machine. Empty clears the binding. | ## `limestone_set_property` **writes** · needs an unlocked vault Set one typed property value on a note. | parameter | meaning | | --- | --- | | `collection` | A collection id or name. | | `path` | The note's vault-relative path. | | `name` | The property name. | | `value` | The new value. | ## `limestone_set_setting` **writes** Change one of Limestone's user-level settings, e.g. the display theme. | parameter | meaning | | --- | --- | | `name` | The setting, exactly as `limestone_list_settings` names it (e.g. "theme"). | | `value` | Its new value — one of that setting's choices, or true/false. | ## `limestone_show_note` **opens it in your window** · needs an unlocked vault Open a note in the person's window. Changes nothing. Use this when they ask to *see* something — limestone_get_note reads a note to you, this one shows it to them. Before this existed the only way to make a note open was to write to it, which is a destructive way to answer 'show me that'. It does nothing visible unless they have turned on Follow agent activity, which is theirs to decide and not worth asking about: the call is harmless either way. | parameter | meaning | | --- | --- | | `note_path` | The note's vault-relative path. | ## `limestone_speak` **writes** Read something aloud on this machine, in the voice the user chose. Only while the user has Read aloud switched on. | parameter | meaning | | --- | --- | | `text` | What to say. Plain words — it is spoken, not displayed. | ## `limestone_write_note_file` **writes** · needs an unlocked vault Write a text file into a note's folder, creating or replacing it. Use this for a NEW file. To change part of one that exists, use limestone_edit_note_file — rewriting a whole file from memory is how content gets dropped. The file is NOT attached to the note. The attachment property is a claim about which file matters, not an inventory of the folder: a project has many files and one of them is the point. Attach that one deliberately with limestone_attach_file. | parameter | meaning | | --- | --- | | `note_path` | The vault-relative path of the note whose folder to write in. | | `filename` | A bare file name, e.g. 'helper.py' — one level, no directories. | | `content` | The file's full text. | --- # Settings # Settings Every preference Limestone stores, with its default. Preferences are per device: they live beside the app, not in your vault, so two machines opening the same vault can look different. Generated from `preferences.py`. | setting | choices | default | an agent may set it | | --- | --- | --- | --- | | `clock` | `24h`, `12h` | `24h` | yes | | `contrast` | `normal`, `high` | `normal` | yes | | `date_format` | `iso`, `friendly`, `us`, `euro` | `iso` | yes | | `follow_agent_activity` | `true`, `false` | `False` | yes | | `hide_note_icons` | `true`, `false` | `False` | yes | | `language` | `system`, `en`, `es`, `fr`, `de`, `ja` | `system` | yes | | `reduce_motion` | `true`, `false` | `False` | yes | | `show_favorites` | `true`, `false` | `True` | yes | | `speech_enabled` | `true`, `false` | `False` | no — Turning speech on is the person's decision, and theirs to take back. An agent that could set it would make the off switch meaningless. With it on, an agent may speak whenever it judges it useful. | | `speech_rate` | `slow`, `normal`, `fast` | `normal` | yes | | `speech_voice` | | `` | yes | | `text_size` | `small`, `default`, `large`, `xlarge` | `default` | yes | | `theme` | `system`, `light`, `dark` | `system` | yes | | `time_zone` | | `` | yes | --- # Skills # Skills Short instructions an agent reads before working in a vault. These ship inside Limestone, so an agent connected to a vault already has them — they are published here so you can see what yours has been told. - [Attaching a file](attaching-a-file.md) - [Importing from Dendron](importing-from-dendron.md) - [Importing from Evernote](importing-from-evernote.md) - [Importing from Google Keep](importing-from-google-keep.md) - [Importing from Joplin](importing-from-joplin.md) - [Importing from Logseq](importing-from-logseq.md) - [Importing from a Markdown folder](importing-from-markdown.md) - [Importing from Notion](importing-from-notion.md) - [Importing from Obsidian](importing-from-obsidian.md) - [Importing from Roam Research](importing-from-roam.md) - [Importing from Standard Notes](importing-from-standard-notes.md) - [Importing from TextBundle / TextPack (Bear, Craft)](importing-from-textbundle.md) - [Limestone vault conventions](limestone-vault-conventions.md) - [Working with collections](working-with-collections.md) --- # Attaching a file # Attaching a file ## When to use When a research file — a PDF, CSV, notebook, image — should live in the vault attached to a note: importing a paper, capturing a figure, filing a dataset. ## Use the tool ``` limestone_attach_file(collection, note_path, filename, content_base64) ``` - `collection` — the collection's id or name. - `note_path` — the note's vault-relative path, as returned by `limestone_create_row` or `limestone_get_note`. - `filename` — what the file should be called, with its extension. - `content_base64` — the file's bytes, base64-encoded. Attach to a note that already exists. Create one first with `limestone_create_row(collection, title)` if you need to; it returns the `path` to pass here. Allowed: PDFs, CSVs, notebooks and images. ## Do not place the file yourself **Never write the bytes into the vault and hand-link them from frontmatter.** Where an attachment lives, what its property is called, and how its name is recorded are decisions the tool makes, and they have changed — a note is promoted to its own folder and the file is stored beside its `.md` (ADR-0037, ADR-0072). A file placed by hand ends up somewhere Limestone does not look, and the user sees a filename where an image should be. A file you place yourself is also invisible to the vault's activity view, which shows what an agent did by watching the operations, not the filesystem. Work that never went through a tool did not happen as far as the user can see. If a file is already on disk and the user asked you to keep it — something they dropped into the conversation, or output from a run — read its bytes, base64 them, and attach them with the tool. ## If the tool fails, stop Say what failed and stop. **Do not fall back to writing the file yourself.** A hand-written attachment looks finished and is not: it lands where Limestone does not look, the user sees a filename instead of their image, and nothing appears in the vault's activity view. Reporting "done, but I used a different method" hands the user a broken result dressed as a working one. A refusal they can act on is worth more. ## Older notes may show a different layout A vault accumulates history. You may find attachments under `/assets/` with a `vault_file` wikilink in frontmatter — that layout is retired (ADR-0072) and Limestone no longer reads it. Do not copy a pattern because you found it in the vault; what the vault contains is a record of what happened, not a specification. ## Example ``` row = limestone_create_row(collection="Visuals", title="Smiley") limestone_attach_file( collection="Visuals", note_path=row["path"], filename="smiley.svg", content_base64=, ) ``` The note appears as a row in `Visuals`, and opening it previews the attachment. ## Tips - One note per attached file. To replace the file, attach it again with the same `filename` on the same note. - The tool returns the updated record; read the `path` from it rather than constructing one. --- # Importing from Dendron # Importing from Dendron ## When to use When a user asks to import a Dendron workspace (a folder with `dendron.yml` and flat Markdown files whose names are dot-hierarchies like `project.tasks.md`). You own it end to end. Read `limestone-vault-conventions`, `working-with-collections`, and `importing-from-obsidian` (the shared mapping) first. ## Run the import (one tool call) Call **`limestone_import_notebook(path, collection_name)`** with the workspace (or vault) **folder**. Returns the usual summary + `unconverted` list. ## What the tool does (Dendron specifics) Imported like Obsidian, with these differences: | Dendron | Limestone | | --------------------------------------------------- | ----------------------------------------------------------------- | | dot-hierarchy filename (`project.tasks.task1.md`) | title = frontmatter `title` (else the last dot-segment) | | auto frontmatter `created`/`updated` (epoch **ms**) | `Created`/`Updated` date properties (ms auto-detected) | | frontmatter `id`, `desc`, `nav_order` | preserved back into the note's frontmatter (a warning lists them) | ## Finalizing (your job) Same playbook as `importing-from-obsidian`. Dendron-specific: - The **dot-hierarchy** is flattened away — every note lands in one collection. If the user wants the hierarchy back, use it to group rows or split collections (the leading dot-segments name the parent topics). - `desc` (Dendron's summary) is preserved in frontmatter; promote it to a `text` property if useful. ## Evolving this skill If a recurring Dendron structure imports badly, propose an update here or flag `importers/markdown_folder.py` (`DendronImporter`). Keep the two in sync. --- # Importing from Evernote # Importing from Evernote ## When to use When a user asks to import an Evernote export (`.enex`). The Import tab hands the task straight to you: **you own it end to end** — run the import, then make the result feel native and resolve whatever couldn't be converted, asking the user when you need a decision. Read `limestone-vault-conventions` and `working-with-collections` first. ## Run the import (one tool call) Call **`limestone_import_notebook(path, collection_name)`**. It runs the deterministic importer (below) — the whole bulk conversion at once — and returns a summary: `collection`, `notes_imported`, `attachments_imported`, `attachments_skipped`, and an **`unconverted`** list. **Never** import notes one at a time yourself; that's what this tool is for. Then finalize (see below). ## How the import works (what the tool does) An `.enex` is one XML file of many ``s, each with metadata, an ENML `` body, and base64 `` attachments referenced inline by `` (the hash is the MD5 of the resource's bytes). The importer streams the file (bounded memory, whatever its size) and writes: | Evernote | Limestone | | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | | the notebook (the `.enex`) | **one collection** (named for the file) | | a `` | a **record** (row + Markdown note); duplicate titles get a numeric suffix so their folders don't collide | | `` | the record title | | `<created>` / `<updated>` (`20211210T120811Z`) | `Created` / `Updated` **date** properties, ISO-8601 (`2021-12-10T12:08:11+00:00`) | | `<note-attributes><author>` | `Author` **text** property | | `<note-attributes><source-url>` | `Source URL` **text** property | | ENML `<h3>` / `<div>` / `<ul>` / `<b>` / `<a>` … | Markdown headings / paragraphs / lists / marks / links | | ENML `<table>` | a `limestone-table` fenced block (see below) | | `<en-media>` → its `<resource>` | the file, stored beside the note, embedded inline as `![[filename]]` (images render; other files show as a clickable chip) | ### The `limestone-table` fence A table is a fenced block whose body is JSON — exactly what the editor's serializer (`apps/web/src/editor/markdown/serializer.ts`) reads: ```` ```limestone-table {"headers": [], "rows": [["**Reagent**", "**Vol**"], ["Glucose", "20 ml"]]} ``` ```` Evernote tables have no header row, so `headers` is `[]` and every row is data; each cell is inline Markdown (bold/links survive). ## Finalizing an import (your job) After a run you get an **`ImportResult`** with counts and an `unconverted` list. Make the collection feel native: 1. **Retype properties.** `Author` imports as `text`; if the vault has matching members, consider a `user` property. Confirm `Created`/`Updated` render as dates. 2. **Split or rename** the collection if one notebook clearly holds several distinct record types (e.g. protocols vs. results) — create the extra collections and move rows. Ask the user before large restructuring. 3. **Resolve `unconverted` items.** Each names a note and a reason: - `attachment … Unsupported file type` — a file type the vault doesn't yet accept (e.g. Mathematica `.nb`). Decide with the user: add the extension to `VaultService.ARTIFACT_EXTENSIONS` (and re-run for those notes), convert it, or leave it out with a note. - `attachment … unresolved en-media hash …` — an inline reference whose file was skipped above; it disappears from the body. Usually resolved by fixing the attachment type, then re-importing that note. ## Evolving this skill (formats change) Evernote (and other apps) keep changing their exports. When the importer reports an **unknown ENML element** or an unhandled **MIME type**, work out the right mapping, apply it to the affected notes with the normal vault tools, and **propose an update**: either extend this `SKILL.md`'s mapping table (so the next person knows) or, for a recurring structural change, flag that `importers/evernote.py` / `enml.py` needs a code change. Keep this file and the importer in sync — the importer's module docstring points back here. ## Tips - Never try to re-do the _bulk_ import yourself by calling tools per note — that's what the deterministic importer is for. Your value is judgment on the margins. - The importer is idempotent only at the collection level (it creates a fresh collection); re-running makes a second collection. Prefer fixing in place. --- # Importing from Google Keep # Importing from Google Keep ## When to use When a user imports Google Keep notes exported via **Google Takeout** (unzip it and pass the `Takeout` or `Keep` folder). You own it end to end. Read `limestone-vault-conventions`, `working-with-collections`, and `importing-from-obsidian` first. ## Run the import (one tool call) Call **`limestone_import_notebook(path, collection_name)`** with the folder (the one holding `Keep/`, or `Keep/` itself). Returns the usual summary + `unconverted` list. ## How the import works (what the tool does) Takeout writes a `.json` (authoritative) + `.html` (ignored) per note, plus media: | Google Keep | Limestone | | ---------------------------- | --------------------------------------------------- | | each note JSON | a **record** (title, else its first line) | | `textContent` | the note body | | `listContent` (checklists) | `- [ ]` / `- [x]` checkboxes | | `labels` | real note **tags** | | microsecond timestamps | `Created` / `Updated` date properties | | `attachments` (images/audio) | stored beside the note, embedded as `![[filename]]` | Trashed notes are skipped; archived notes are kept. ## Finalizing (your job) The `importing-from-obsidian` playbook applies. Keep-specific: Keep's `color`, `pinned`, and `archived` flags aren't mapped — add properties for any the user tracks. Resolve `unconverted` items (unsupported attachment types) as usual. ## Evolving this skill If the Takeout schema shifts, work out the mapping and propose an update here or to `importers/google_keep.py`. Keep the two in sync. --- # Importing from Joplin # Importing from Joplin ## When to use When a user imports a Joplin **JEX** file (`.jex`) or a **RAW** export folder. (Joplin can also export **Markdown + Front Matter** — if they did that, use `importing-from-markdown` instead.) You own it end to end. Read `limestone-vault-conventions`, `working-with-collections`, and `importing-from-obsidian` first. ## Run the import (one tool call) Call **`limestone_import_notebook(path, collection_name)`** with the `.jex` (or the RAW folder). Returns the usual summary + `unconverted` list. ## How the import works (what the tool does) A JEX is a tar of flat `<id>.md` items (notes, notebooks, tags, links) plus a `resources/` dir. The importer reassembles them: | Joplin | Limestone | | ---------------------------------------- | -------------------------------------------------------------- | | each note item (`type_: 1`) | a **record** (title = the item's first line) | | `created_time` / `updated_time` (ISO ms) | `Created` / `Updated` **date** properties | | the note's notebook (`parent_id`) | a `Notebook` **text** property | | `source_url` | a `Source URL` **text** property | | tags (a separate `type_: 5`/`6` join) | real note **tags** | | `![](:/<resourceId>)` / `[x](:/<id>)` | the file from `resources/`, embedded inline as `![[filename]]` | ## Finalizing (your job) The `importing-from-obsidian` playbook applies. Joplin-specific: 1. **Group or split by notebook.** Every note lands in one collection with a `Notebook` property. If notebooks are really distinct record types, split them into their own collections; otherwise group the table by `Notebook`. 2. **To-dos.** Joplin to-dos import as plain notes (the `is_todo`/`todo_completed` metadata isn't mapped). Add a checkbox/date property if the user tracks tasks here. 3. Resolve `unconverted` items (unsupported resource types, missing resources) as usual. ## Evolving this skill If a Joplin structure recurs badly, work out the mapping, apply it, and propose an update here or to `importers/joplin.py`. Keep the two in sync. --- # Importing from Logseq # Importing from Logseq ## When to use When a user asks to import a Logseq graph (a folder with `journals/`, `pages/`, and a `.logseq/` config). The Import tab hands the task straight to you: **you own it end to end**. Read `limestone-vault-conventions` and `working-with-collections` first. The core mapping is the same as `importing-from-obsidian` (read that too); this note only covers Logseq's differences. ## Run the import (one tool call) Call **`limestone_import_notebook(path, collection_name)`** with the graph **folder**. It returns the usual summary + `unconverted` list. Never import notes one at a time. ## What the tool does (Logseq specifics) Logseq is a folder of Markdown, imported like Obsidian, with two extra transforms: | Logseq | Limestone | | -------------------------------------------- | -------------------------------------------------------------------- | | every line is an outline `- ` block | top-level blocks flatten to prose; nested blocks stay Markdown lists | | page metadata as `key:: value` (first block) | lifted into the same properties/tags as YAML frontmatter would be | | `journals/YYYY_MM_DD.md`, `pages/*.md` | one **record** each (journals and pages land in the one collection) | | `#tag`, `[[Page]]`, `((block-ref))` | `#tag`/`[[Page]]` map as usual; `((block-ref))` is left as-is | ## Finalizing (your job) Same as `importing-from-obsidian`: promote preserved frontmatter, retype properties, split/rename if needed, and resolve `unconverted` items. Logseq-specific cleanup: - **Block references `((uuid))`** don't resolve to anything in Limestone. If a note leans on them, replace with a real `[[Page]]` link or inline the referenced text. - **Leftover `key:: value`** lines deeper in a body (block properties, not page properties) are left in place — lift any the user cares about into properties. - If **journals** should be their own collection separate from **pages**, split them. ## Evolving this skill If a recurring Logseq structure imports badly, work out the mapping, apply it, and propose an update to this file or flag `importers/markdown_folder.py` (`LogseqImporter`). Keep the two in sync. --- # Importing from a Markdown folder # Importing from a Markdown folder ## When to use The generic fallback for **any folder of Markdown + YAML frontmatter** that isn't a recognized app — a Zettlr project, a static-site content dir, a hand-rolled export, or the Markdown output of an app's exporter (Bear, Apple Notes, Craft, OneNote). You own it end to end. Read `limestone-vault-conventions`, `working-with-collections`, and `importing-from-obsidian` (the exact same mapping) first. ## Run the import (one tool call) Call **`limestone_import_notebook(path, collection_name)`** with the **folder** (or a `.zip` of it). Returns the usual summary + `unconverted` list. ## What the tool does Identical to `importing-from-obsidian`: each `.md` → a record; frontmatter `created`/`updated`/`author`/`source`/`aliases` → properties; `tags:` + inline `#tags` → real tags; `![[...]]`/`![](...)` attachments copied beside the note; `[[links]]` pass through; any other frontmatter key preserved back into the note's YAML. ## Finalizing (your job) The `importing-from-obsidian` playbook applies verbatim: promote preserved frontmatter, retype properties, split/rename if one folder holds several record types, and resolve `unconverted` items (unsupported attachment types, unresolved embeds). ## Evolving this skill If a particular exporter's Markdown recurs with the same problems, consider giving it its own `importing-from-<app>` skill (and, if the structure needs code, an adapter on `importers/markdown_folder.py`). Keep skill and importer in sync. --- # Importing from Notion # Importing from Notion ## When to use When a user imports a Notion export made with **Export → Markdown & CSV** (a `.zip`, or its unzipped folder). Turn on **Include subpages** when exporting so pages and database rows come through. You own it end to end. Read `limestone-vault-conventions`, `working-with-collections`, and `importing-from-obsidian` first. ## Run the import (one tool call) Call **`limestone_import_notebook(path, collection_name)`** with the `.zip` (or folder). Returns the usual summary + `unconverted` list. ## How the import works (what the tool does) Notion names every file `Title <32-hex-id>`; databases export a CSV (prefer the `…_all.csv`, which has all rows/columns). The importer flattens the whole export into **one collection**: | Notion | Limestone | | ------------------------------------------ | ---------------------------------------------------------------- | | each page `.md` | a **record** (title = filename with the hex id stripped) | | each database CSV row | merged onto its page by title, or a new record if it has no page | | CSV columns | the collection's (union) **text** properties | | a `Tags`/`Tag` column | real note **tags** | | `![](Page%20id/img.png)` (percent-encoded) | the file, stored beside the note, embedded as `![[filename]]` | | `[Label](Other%20Page%20id.md)` page links | `[[Label]]` wikilinks | ## Finalizing (your job) This importer is deliberately coarse — Notion is rich, so expect cleanup: 1. **Split merged databases.** If the export had **several databases**, they're merged into one collection (a warning says so). Split them into separate collections by record type — that's the biggest win. 2. **Retype properties.** Every CSV column imports as `text`. Retype dates (`Created`, `Due`), selects (`Status`), numbers, and people to their real kinds. 3. **De-duplicate the body preamble.** Notion repeats a database row's properties as text at the top of its page body. Trim those if the columns already carry them. 4. **Callouts / toggles / equations.** Callouts arrive as blockquotes or HTML, toggles as plain paragraphs, equations as raw LaTeX. Reformat the important ones. 5. **Linked file attachments** (`[📎 name](file.pdf)`) aren't pulled in (only image embeds are) — attach them with `attaching-a-file` if the user needs them. ## Evolving this skill Notion changes its export often. When a structure recurs badly, work out the mapping, apply it, and propose an update here or to `importers/notion.py`. Keep the two in sync. --- # Importing from Obsidian # Importing from Obsidian ## When to use When a user asks to import an Obsidian vault — or any plain **folder of Markdown + YAML frontmatter** (Zettlr, generic exports). The Import tab hands the task straight to you: **you own it end to end** — run the import, then make the result feel native and resolve whatever couldn't be converted, asking the user when you need a decision. Read `limestone-vault-conventions` and `working-with-collections` first. ## Run the import (one tool call) Call **`limestone_import_notebook(path, collection_name)`** with the path to the vault **folder** (or a `.zip` of it). It runs the deterministic importer (below) — the whole bulk conversion at once — and returns a summary: `collection`, `notes_imported`, `attachments_imported`, `attachments_skipped`, `warnings`, and an **`unconverted`** list. **Never** import notes one at a time yourself. Then finalize (see below). ## How the import works (what the tool does) An Obsidian vault is a folder tree of `.md` files. Limestone's own note syntax **is** Obsidian's (`[[wikilinks]]`, `![[embeds]]`, YAML frontmatter), so bodies mostly pass through; the importer's work is metadata and attachments: | Obsidian | Limestone | | -------------------------------------------------- | -------------------------------------------------------------------------------------------------- | | the vault (the folder) | **one collection** (named for the folder) | | each `.md` file | a **record** (row + Markdown note); duplicate titles get a numeric suffix so folders don't collide | | frontmatter `title` (else the filename) | the record title | | frontmatter `created`/`date`, `updated`/`modified` | `Created` / `Updated` **date** properties (ISO-8601) | | frontmatter `author`, `source`/`url`, `aliases` | `Author` / `Source URL` / `Aliases` **text** properties | | frontmatter `tags:` + inline `#tags` | real note **tags** | | any other frontmatter key | preserved back into the note's own frontmatter (a warning lists the keys) | | `![[image.png]]` / `![](attachments/x.png)` | the file, stored beside the note, embedded inline as `![[filename]]` | | `[[Note]]` / `[[Note#Heading]]` links | left as-is (same syntax); resolve to the imported notes | `.obsidian/`, `.trash/`, and other dotfolders are ignored. Remote image URLs (`![](https://…)`) are left as links. ## Finalizing an import (your job) After a run you get an **`ImportResult`** with counts, `warnings`, and an `unconverted` list. Make the collection feel native: 1. **Promote preserved frontmatter.** The `warnings` list names frontmatter keys the importer kept in each note's YAML but didn't type (e.g. `status`, `rating`). If the user wants any as a real column, add the property and set values. 2. **Retype/confirm properties.** `Author` imports as `text`; consider a `user` property if the vault has matching members. Confirm `Created`/`Updated` render as dates. 3. **Split or rename** the collection if one vault clearly holds several distinct record types. Ask the user before large restructuring. 4. **Resolve `unconverted` items.** Each names a note and a reason: - `attachment … Unsupported file type` — a type the vault doesn't yet accept. Decide with the user: add the extension to `VaultService.ARTIFACT_EXTENSIONS` and re-run, convert it, or leave it out. - `embed … unresolved reference` — an `![[…]]` whose target file wasn't found in the vault (moved/renamed/external). Locate it with the user or drop the embed. ## Evolving this skill (formats change) If the importer reports many `unconverted` items of a new shape, work out the right mapping, apply it with the normal vault tools, and **propose an update**: extend this `SKILL.md`'s mapping table, or — for a recurring structural change — flag that `importers/markdown_folder.py` needs a code change. Keep this file and the importer in sync (the importer's module docstring points back here). ## Tips - Never re-do the _bulk_ import by calling tools per note — that's the deterministic importer's job. Your value is judgment on the margins. - Re-running creates a second collection (import is collection-level idempotent only); prefer fixing in place. --- # Importing from Roam Research # Importing from Roam Research ## When to use When a user imports a Roam **JSON** export (Export All → JSON — unzip it first and pass the `.json`). You own it end to end. Read `limestone-vault-conventions`, `working-with-collections`, and `importing-from-obsidian` first. ## Run the import (one tool call) Call **`limestone_import_notebook(path, collection_name)`** with the `.json`. Returns the usual summary + `unconverted` list. ## How the import works (what the tool does) Roam is a graph of pages, each an outline of blocks: | Roam | Limestone | | -------------------------------------- | ------------------------------------------------- | | each page | a **record** (title = the page title) | | the block tree | nested Markdown `- ` bullets (headings preserved) | | `#tag`, `#[[multi word]]` | real note **tags** | | `[[Page]]` links | left as-is (they resolve to the imported pages) | | `create-time` / `edit-time` (epoch ms) | `Created` / `Updated` date properties | ## Finalizing (your job) The `importing-from-obsidian` playbook applies. Roam-specific: 1. **Block references `((uid))`** don't resolve in Limestone — replace with a `[[Page]]` link or inline the referenced text where it matters. 2. **Images are remote Firebase URLs**, left as links (a warning counts them). Download the important ones and re-attach with `attaching-a-file`. 3. **Daily notes** import as normal pages titled by date; group or tag them if useful. ## Evolving this skill If a Roam structure recurs badly, work out the mapping and propose an update here or to `importers/roam.py`. Keep the two in sync. --- # Importing from Standard Notes # Importing from Standard Notes ## When to use When a user imports a Standard Notes backup. **It must be the _decrypted_ JSON** (the encrypted backup is unreadable ciphertext). You own it end to end. Read `limestone-vault-conventions`, `working-with-collections`, and `importing-from-obsidian` first. ## Run the import (one tool call) Call **`limestone_import_notebook(path, collection_name)`** with the decrypted `.json`. Returns the usual summary + `unconverted` list. ## How the import works (what the tool does) The backup is one JSON with an `items` array of Notes, Tags, and components: | Standard Notes | Limestone | | -------------------------------------- | ------------------------------------------------- | | each `Note` item | a **record** (title = `content.title`) | | `content.text` | the note body | | tags (each Tag lists its Notes' UUIDs) | real note **tags** (resolved via a two-pass join) | | `created_at` / `updated_at` | `Created` / `Updated` date properties | Trashed notes are skipped. ## Finalizing (your job) The `importing-from-obsidian` playbook applies. Standard-Notes-specific: 1. **Editor formats vary.** Plaintext/Markdown notes import clean; **legacy Rich Text** notes store **HTML** and the **Super** editor stores a JSON/HTML document — both land in the body verbatim. Convert the important ones to Markdown. 2. **Nested tags** (Standard Notes uses `parent.child` dotted titles) import as flat tag names — re-nest if the user relies on hierarchy. 3. **File attachments** aren't in a notes backup (they're separate encrypted blobs); re-attach any the user needs with `attaching-a-file`. ## Evolving this skill If a backup shape recurs badly, work out the mapping and propose an update here or to `importers/standard_notes.py`. Keep the two in sync. --- # Importing from TextBundle / TextPack (Bear, Craft) # Importing from TextBundle / TextPack (Bear, Craft) ## When to use When a user imports notes exported as **TextBundle** — a `.textbundle` folder, a `.textpack`, or a Bear `.bear2bk` backup. Bear and Craft are the common sources. You own it end to end. Read `limestone-vault-conventions`, `working-with-collections`, and `importing-from-obsidian` (the shared attachment/tag model) first. ## Run the import (one tool call) Call **`limestone_import_notebook(path, collection_name)`** with the `.textbundle` / `.textpack` / `.bear2bk`, or a folder containing several bundles. Returns the usual summary + `unconverted` list. ## How the import works (what the tool does) A TextBundle is a folder of `text.markdown` + `assets/` + `info.json`: | TextBundle | Limestone | | ------------------------------------------- | -------------------------------------------------------------------- | | each bundle | one **record** (row + Markdown note) | | the first `# heading` (else the first line) | the record title | | `info.json` creation/modification dates | `Created` / `Updated` **date** properties (when present) | | inline `#tags` (Bear) + `info.json` tags | real note **tags** | | `![](assets/x.png)` references | the file, stored beside the note, embedded inline as `![[filename]]` | ## Finalizing (your job) The `importing-from-obsidian` playbook applies. TextBundle-specific: - **Dates may be missing.** Plain `.textbundle`/`.textpack` often carries no dates (they live in Bear's database, preserved only in `.bear2bk`). If the user needs accurate dates, ask them to re-export as `.bear2bk`. - **Bear multi-word tags** (`#multi word#`) aren't captured by the inline `#tag` scan; add them by hand if the user relies on them. - Resolve `unconverted` items (unsupported asset types, unresolved embeds) as usual. ## Evolving this skill If a bundle emitter (a specific app's `info.json` shape) recurs with lost metadata, work out the mapping and propose an update here or to `importers/textbundle.py`. Keep the two in sync. --- # Limestone vault conventions # Limestone vault conventions ## When to use Read this before creating or editing any note in a Limestone vault. It explains how a plain folder of Markdown files becomes a structured, queryable notebook. ## Files-as-truth (the one rule that matters) A note's facts live in its `.md` **frontmatter**, not in a database. Limestone's SQLite index is rebuilt from the files on every read. So: **any valid `.md` you write into the vault becomes a note, and it appears in the app automatically.** To create or edit a note, just write the file — no API call is required. The vault is a valid **Obsidian** vault and follows Obsidian Properties / Dataview conventions. ## Frontmatter schema Frontmatter is YAML between `---` fences at the top of the file. Use native YAML types (strings, numbers, booleans, dates, lists). Reserved, app-managed keys: - `limestone_id` — the note's stable identity (a UUID). Leave it alone; Limestone assigns it. A note you create without one is given an id on first index. - `limestone_type` — the kind of note (usually `note`). - `title` — the human title (falls back to the filename stem if absent). - `icon`, `cover` — optional display tokens. - `tags` — a YAML **list** of tag names, e.g. `tags: [assay, pichia]`. - `links` — app-managed outgoing links; prefer editing links as wikilinks. Any other key you add is a free-form property (surfaced as `extra`) unless the note's collection defines a typed column with that name (see `working-with-collections`). ## Tags, links, and relations - **Tags:** a list under `tags:`. - **Links / relations / file references:** Obsidian `[[wikilinks]]`. A relation or a `vault_file` reference is stored in frontmatter as a `[[wikilink]]` to the target note or asset (by filename stem). Backlinks are computed for you. ## Layout - Each **collection** is a top-level folder; the notes inside it are its rows. - An attached file lives in its note's own folder, beside the note's `.md` (ADR-0037, ADR-0072). Attach with `limestone_attach_file` rather than placing bytes yourself — where the file goes is the tool's decision and it has changed before (see `attaching-a-file`). - Limestone's own data lives under `.limestone/` — never edit or write there. ## Example note ```markdown --- title: Assay 05 limestone_type: note tags: [assay, pichia] Status: Reading --- Growth assay notes go in the body. ``` --- # Working with collections # Working with collections ## When to use When you need to add a row to a Limestone collection, set a note's typed properties, or find notes across the vault. Read `limestone-vault-conventions` first. ## How notes map to rows A **collection** is a top-level vault folder. Every `.md` note inside it is one **row** of the collection's table — including a note that has been promoted to its own folder to hold an attachment, which is still one row. A note's typed **property values** are frontmatter keys whose names match the collection's columns; the value must fit the column's type (text, number, boolean, date, select, etc.). ## Create a note in a collection Write a Markdown file into the collection's folder. Give it frontmatter with the `title` and any property values. Example — a new row in the `Experiments` collection with a text `Status`: ```markdown --- title: Assay 05 limestone_type: note Status: Reading tags: [pichia] --- Body notes. ``` Save it as `Experiments/assay-05.md`. It appears as a row immediately (files-as-truth). You do not need to invent a `limestone_id` — one is assigned. ## Set or change a property Edit the note's frontmatter and re-save. Add the key if it's missing; change the value to update the cell. Use native YAML (`Done: true`, `Count: 42`, `Due: 2026-08-01`). Removing the key clears the cell. ## Move a note to another collection Use **`limestone_move_note`**. Do not move the file yourself. A note belongs to the collection whose folder holds it, so a shell `mv` of the `.md` looks like it worked and is not the same thing: - a note that holds attachments lives in its own folder (`<collection>/<slug>/<slug>.md`), and moving only the `.md` strands that folder and everything in it; - its attachment references keep naming where the folder used to be; - and none of the refusals apply — into or out of the Trash, a read-only collection, a locked note. Properties the destination has no column for are not lost either way. By default they are written into the top of the note's body as plain `Name: value` lines; pass `create_missing_properties` to add them to the destination as hidden columns instead. Ask the user which they want when it matters. ## Query with the Limestone MCP tools The Limestone MCP server exposes read/query tools (namespaced by your harness, e.g. `mcp_limestone_*`). Prefer them over guessing at files: - **`list_collections`** — every collection and its property schema. Start here to learn the exact column names and types before writing values. - **`query_records`** — a collection's rows (title, path, properties, tags, links, backlinks). Optional filter `{property, op: equals|contains, value}`. - **`get_note`** — one note's frontmatter facts + body, by vault path. - **`search`** — title search across the vault's filed notes. Typical loop: `list_collections` to learn the schema → write or edit the `.md` → `query_records` to confirm the row and its values. --- # Reporting bugs in Limestone # Reporting bugs in Limestone Limestone can send us bug reports and crash logs so we can fix problems fast — **without ever sending your notes** (ADR-0095). Open **Settings ▸ Report a Bug**. ## What's in a report — and what isn't A report is built from a fixed **allowlist** of fields. It includes: - what you wrote in the form (summary, what happened, what you expected, steps), - the app version and build revision, your platform (web/desktop), and locale, - for a crash: the error type/message/stack, **scrubbed** of any file paths, and - a short trail of recent **action kinds** (e.g. "opened-settings") — never their contents. It **never** includes note bodies or titles, collection names, file listings, vault paths, encryption keys, or your passphrase. Before you send, the **"What we'll send"** panel shows the exact JSON — so you can see for yourself that nothing private is in it. This is the same promise as the rest of the app: no vault content ever reaches a Limestone server (ADR-0091). ## Anonymous by default You don't need an account to report a bug. If you're signed in, you can tick **"Include my account so we can follow up"** to attach your handle and email; leave it off and the report is fully anonymous. Nothing is ever sent automatically — a report goes out only when you click **Send**. ## Crashes If the app hits an unexpected error, you'll see a recovery screen with **Reload** and **Report this problem**. Choosing to report opens the form prefilled with the (scrubbed) error — you still review and send it yourself. ## No connection? Send it yourself If no report service is configured, or the send fails, Limestone offers to **download** the report, **copy** it to the clipboard, or open a prefilled **email** — so you're always in control of exactly what leaves your device.