npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@squirrelnotes.app/mcp

v2.5.0

Published

MCP stdio CLI for Squirrel Notes — bridges AI agents to the encrypted note-taking API

Readme

Squirrel Notes MCP Server

An MCP (Model Context Protocol) server that lets AI agents (Claude Desktop, Cursor, etc.) read and write your Squirrel Notes, with full end-to-end decryption.


⚠️ Security Caveat — Read This First

The MCP server necessarily decrypts your notes — that is its job. It runs only as a local process on your machine (the stdio transport); there is no hosted MCP server.

The passphrase lives in claude_desktop_config.json on your machine and is passed as an env var to the local process. It never leaves your machine, and decryption happens locally — similar to the standard Squirrel Notes app, where the passphrase is entered locally in the browser and not sent to the server. The cost is that the passphrase is stored in a local config file.

| Risk | Mitigation | |------|------------| | Passphrase in config file | Stored on disk in a local config file; protect it with OS user permissions and full-disk encryption. Anyone who can read this file has your passphrase. | | Passphrase and key in process memory | The process derives your data key once, on its first tool call, and holds it in memory until the process exits, even while it is idle between requests. The MCP server never writes it to a file or a log. The operating system can still put it on disk, through swap or a crash dump, and a debugger running as your user can read it, as all of these could already read the passphrase in the process's environment. | | Decrypted content visible to AI | This is the intended behaviour — AI agents need to read your notes |

Ending a running session. Because the stdio process holds your data key for its whole life, changing your passphrase in the app does not stop a session that is already running. A passphrase change only re-wraps the key; it does not change it. To end a running session, revoke or rotate the API key (Settings → API & Data). An idle process keeps the key until its next request that reaches the API-key guard. On that request the server rejects the key and the process drops it (the reference is released, not zeroed). A request that fails before the guard (a 400 or 413 from the body parser, an API Gateway 429, or a 5xx) does not drop the key, but serves no data. The process drops the key when the server answers 401 "Invalid API key" (a missing, unknown, revoked or rotated key), or 403 "API access requires a Pro subscription" (the key is valid but the account is no longer Pro). After a revoked or rotated key, every later call fails until the process is restarted with a valid key. After a Pro lapse the key is still valid, so every call fails only while the account is not Pro: once you subscribe again, the next call derives your data key afresh and the session works again, without a restart. A 403 that a route raises for another reason, such as a shared note you do not own, leaves the key held. Quitting the MCP client also ends the process.


Tools

Notes

| Tool | Description | |------|-------------| | list_notes | Returns all notes with decrypted titles, tags, and metadata. Supports optional collectionId, tagId, pinned filters. | | get_recent_notes | Returns the N most-recently updated notes (default 10). Fast — titles only. | | get_note | Returns a single note with decrypted title + full content. | | create_note | Creates a note with encrypted title + content. | | update_note | Updates title, content, or pinnedAt on a note. | | delete_note | Moves a note to trash (recoverable for 30 days). Its public share link is revoked at once. | | pin_note | Pins or unpins a note (appears at top of sidebar). | | append_to_note | Appends markdown content to an existing note. Saves the agent a get → update, but it is not race-free: it reads the note and writes it back whole, and the server has no version check, so an edit made elsewhere in between is overwritten. Optional expectedUpdatedAt refuses the write if the note changed since it was read, as on the large-note writes; the answer then also carries the new updatedAt. | | set_note_tags | Replaces all tags on a note. | | search_notes | Fast title-only search across all notes. | | search_note_content | Full-content search (slow — decrypts all notes). Use only when title search fails. Returns {total, notes} with up to 5 matching lines per note (snippets: [{line, text}], each cut to 240 characters) and up to limit notes (default 20, at most 50). includeContent: true returns the full content of every match instead, as an array, as it did before. | | move_note_to_collection | Moves a note to a collection (or to scratchpad if collectionId is null). | | get_note_outline | A note's headings, with each section's line span and size (chars, approxTokens), the preamble before the first heading, and updatedAt. No body text. At most 500 headings (omitted counts the rest). | | read_note | One section by heading (+ occurrence when the text repeats), or startLine/endLine. Numbered lines, at most 400 lines or 20,000 characters per call; truncated and nextStartLine say how to continue. | | edit_note | Replaces the one exact occurrence of oldText with newText. Zero or several matches write nothing. Everything outside the match, line endings included, is kept. With heading (+ occurrence), only matches lying wholly inside that heading's section count, so text that repeats elsewhere can still be changed in one place. | | replace_section | Replaces everything under a heading, subsections included; the heading line stays. | | insert_in_section | Adds lines under a heading: position: "start" (at the start of the heading's text, after the blank line under the heading) or "end" (after the heading's own text, before its first subheading). | | delete_section | Deletes a heading and everything under it, subsections included, leaving the note spaced as if the section had never been there. | | insert_at_line | Inserts lines before a 1-based line (totalLines + 1 appends). expectedUpdatedAt is required, because line numbers go stale; a line out of range is refused with minLine and maxLine. |

Large notes

For a note over about 8,000 characters (about 2,000 tokens), an agent should not read or rewrite the whole note. get_note_outline lists the headings with their line numbers and sizes, read_note reads one section or a range of lines, and edit_note, replace_section, insert_in_section, delete_section and insert_at_line change only what they name. Each call is one GET /public/notes/:id (and, for a write, one PATCH of the encrypted content, as append_to_note sends); the note is decrypted, changed and encrypted on your machine.

  • Lines are 1-based and endLine is inclusive. A \r\n line break counts as one break and is never shown; a lone \r is an ordinary character. When every line break in a note is \r\n, the text a write adds uses \r\n too; otherwise \n.
  • Headings are exactly the ones the app's Outline panel shows: # to ###### headings, none inside fenced code, setext (underlined) headings not counted. Two quirks are kept so the two agree: a trailing # with no space before it is dropped (## C# reads as C), and in a note with \r\n line endings a fenced code block is not recognised, so a # line inside one is listed.
  • edit_note matches exactly: case, spaces, accents, Unicode form and line breaks all count, and overlapping matches count (aa occurs twice in aaa). When the text repeats, widen oldText or pass heading to look only in that section (subsections included); there is no "replace the Nth match", because a position goes stale like a line number.
  • Spacing. replace_section and insert_in_section keep the blank lines after a heading and before the next heading as the note has them, so repeated edits do not wear a note's spacing down, and writing a section's own body back changes nothing. A section with no lines at all gets the note's usual spacing (one blank line on each side unless most of its headings are followed directly by text). The content's own leading and trailing blank lines are dropped. An insert next to a list continues the list when it is a list item, and is separated from it by one blank line when it is text, so neither joins the other. delete_section leaves the spacing the note would have had without the section. insert_at_line adds or removes no blank line, except one to keep a heading's own blank line above or below it.
  • expectedUpdatedAt (optional on every write, required on insert_at_line): pass the updatedAt from your last read or write. If the note's differs, nothing is written and the answer is {error: "stale", updatedAt}. It is checked by the MCP between its read and its write, so it is not a lock, and a pin, tag or move also changes updatedAt, so it refuses after those too: read again and retry.
  • Refusals carry a code, a fixed sentence and numbers only (counts, line numbers, updatedAt), never note text or what you passed. Nothing is logged.

Trash

| Tool | Description | |------|-------------| | list_trash | Lists trashed notes with decrypted titles and when each was trashed. Trashed notes are hard-deleted automatically 30 days after trashing. | | restore_note | Restores a trashed note to its last collection. Public share links are not recreated. | | permanently_delete_note | Permanently deletes one trashed note, its attachments and its encrypted content. Irreversible; the note must already be in trash. | | empty_trash | Permanently deletes every trashed note and its attachments. Irreversible; requires confirm: true. |

Collections

| Tool | Description | |------|-------------| | list_collections | Lists regular collections (folders) with decrypted names and parentId for nesting. Smart collections are left out: their notes are computed, so nothing can be placed in them. | | get_collection | Returns one collection with its decrypted name. | | create_collection | Creates a collection from a plaintext name (encrypted before the API call), optionally nested under parentId, with an optional iconColor hex code. | | update_collection | Changes a collection's name, parent or icon colour. Its kind cannot be changed. | | delete_collection | Deletes a collection. Its notes move to the scratchpad; they are not deleted. |

Tags

| Tool | Description | |------|-------------| | list_tags | Lists tags with decrypted names and colours. Use it to find tag IDs for set_note_tags. | | get_tag | Returns one tag with its decrypted name. | | create_tag | Creates a tag from a plaintext name (encrypted before the API call) and an optional color hex code. | | update_tag | Changes a tag's name or colour. | | delete_tag | Deletes a tag and removes it from every note that had it. |

Sharing

| Tool | Description | |------|-------------| | share_note | Publishes a note as a public link protected by a share passphrase, with optional expiry and allowDownload (default off). The note is decrypted here and re-encrypted under a key derived from the share passphrase, so the server never sees plaintext. Returns the share URL and token. The share passphrase is a tool argument, so it passes through your agent and its model provider; send it to recipients separately from the link. | | get_share_status | Returns a note's share URL, view count, expiry and settings, or null if it is not shared. | | republish_share | Re-encrypts an existing share with the note's latest content. Needs the share passphrase (the same one or a new one). | | update_share_settings | Changes a share's expiry and/or allowDownload without changing its content. | | revoke_share | Revokes a share link permanently; it stops working at once. The note is not affected. |

Saved Searches (Pro)

| Tool | Description | |------|-------------| | list_saved_searches | Returns saved searches with decrypted names and smart-query strings. | | create_saved_search | Creates a saved search from plaintext name + query (encrypted before API call). | | update_saved_search | Updates saved search name and/or query. | | delete_saved_search | Deletes a saved search (soft-delete server-side). |

Attachments

| Tool | Description | |------|-------------| | list_attachments | Lists all attachments for a note with decrypted filenames and metadata. | | get_attachment_download_url | Returns a presigned S3 download URL (expires in 5 minutes). | | delete_attachment | Permanently deletes an attachment from S3 and the database. | | upload_attachment_from_path | Uploads a file from a local path — no file bytes in context. Use after write_file (Filesystem MCP) to attach Claude-generated files. See Filesystem MCP companion below. |

Memory (Pro, feature 0031)

Short, atomic memories about you that an agent can recall in later sessions. Each memory is encrypted on your machine under your data key, bound to its id and revision (AES-GCM with AAD sqn-memory-v2:<id>:<revision>), so the server stores only ciphertext. Someone holding only your API key cannot put an older version back, or another memory's ciphertext in its place: it does not open. What this does not stop: the server, or whoever operates it, returning a whole older row (its ciphertext and its older revision). That opens, and undoes later changes, because this MCP keeps no record of the revisions it has seen. The app (PR 1b) will notice a revision lower than the one it cached; the MCP does not (R-038).

| Tool | Description | |------|-------------| | search_memory | Finds memories by the words in their text, entity, tags and keywords: BM25 over the decrypted set, times a recency decay per category (half-life 30 days for project_state, a year for preferences and mental models, two years for entity facts, decisions and references). Word matching reads every script: a hyphenated word is one word (pihole and Pi-hole both find "Pi-hole"), and a hyphenated query word also half-matches its parts written apart, in order (end-to-end finds "end to end"); Chinese, Japanese, Thai, Lao, Khmer and Myanmar text, written without spaces, is matched by pairs of characters. Common English words and contraction fragments (a built-in list of 144: "what", "does", "he", "is", the "s" of "what's"…) are dropped from the query unless it has nothing else. The response names its ranking (lexical here; hybrid with the embeddings package; none for a listing) and total (matches before limit). Each hit has score, which orders that response only, and two scores that compare across searches: lexicalScore (0–1, the share of the query's content words the memory holds; a part of a hyphenated word counts half, as does a query word that begins a word of the memory's text, tags or keywords at most twice its length: four or more characters, or three Latin letters only as the plural (a three-character word in another script keeps the half-word rule; penicil half-matches "penicillin" and tag "tags", but use never matches "user", end never matches "ended" or "endpoint", and ad never matches "admin"); an entity's words match only whole or by a hyphenated part (feature 0058)) and semanticScore (cosine similarity; always null in this package). Hits come in rank order, and nearMisses: true (with the companion) adds up to 3 of the closest memories that did not match, after the matches, each with nearMiss: true and score 0 (every other hit has nearMiss: false; none in this package); the tool suggests no cut-off: a low score means lower confidence, not a wrong answer, and no score is a threshold (with the companion's gte-small model most memories score between about 0.75 and 0.95, related or not, so scores are compared within one response), so an agent is told to check low-scoring hits against the question before dismissing them (ADR-0444, amended 2026-10-13). lexicalScore is delivered rounded to two places. Each result also carries validUntil, validUntilZone, stale and overdueDays (see "Stale memories are flagged, never demoted" below), and createdAt, createdBy, modifiedBy, origin and sourceRef (see "Who wrote a memory" below); they never change the order or the scores. Each result also carries keywords (see "Keywords" below), which the tag and entity filters never read. Optional tags keeps only memories carrying at least one listed tag (any of); optional excludeTags leaves out memories carrying any listed tag, and wins when a tag is in both lists. Each list takes at most 20 tags of 1–50 characters; tags match whole, ignoring case, accents on Latin letters (cafe and café are one tag here) and surrounding spaces, and an empty list is the same as leaving it out. The tag filters narrow which memories are ranked or listed; they do not change scores. They are a convenience, not access control: an agent that leaves them out sees every memory (scope enforced by the server is a separate, later piece of work). Optional decisionStatus (one to five of proposed, discussed, decided, reversed, disputed, default ["decided"]) picks which decisions are returned; it applies to decisions only, so every other memory passes, and a decision whose stored status cannot be read counts as proposed. References are returned only with category: "reference"; to fetch one by its key, pass category: "reference" and the key as entity, with no query (see "Decisions and references" below). decisionStatus always has a value, so it never turns a call into a listing on its own. Optional disputed (true or false) keeps only memories in an open conflict, or only those in none (see "Conflicts between memories" below). Optional entity keeps only memories filed under that entity, matched whole, ignoring case, accents on Latin letters and surrounding spaces (Zoe finds "Zoë"; the same rule fetches a reference by its key). query is optional when category, entity, tags, excludeTags or disputed is given: the matching memories are then listed, newest first, limit at a time; pass nextCursor back as cursor for the next page (null on the last, and always null for a search). A memory edited during a walk can be missed, so compare the count against total or start again. With neither a query nor a filter, the call is refused. The decrypted list is reused for up to 60 s, and re-read after any write from this process. Reads every page of GET /public/memory and searches the decrypted set on your machine; it never asks the server for one memory, so the server cannot tell which ones were consulted. Leaves out superseded and invalidated memories unless asked; searched counts the memories that state filter let through (active, plus superseded or invalidated when asked for), before any category, reference, decision-status, entity, tag or disputed filter; total counts after them. A record that fails to decrypt or verify is skipped and counted (unverifiable), never shown; the tool description tells the agent that a non-zero count means some memories could not be read (a wrong key or tampering, for example) and to ask you to check them in the app. | | remember_memory | Saves one memory (text, category, entity, optional tags, keywords, sourceNoteId, validUntil, timezone, origin, sourceRef). origin says where the claim came from: user_stated, agent_inferred, agent_suggested_user_accepted or external_source; left out, none is stored (there is no default). sourceRef (1–500 characters after trimming) points at the source: a URL, ticket, channel or note id, beside sourceNoteId. Anything else is refused and nothing is written. validUntil is the last day the memory holds, written YYYY-MM-DD, for a fact that expires (a deadline, a project's current state); anything else, such as 2026-02-30, 31/10/2026 or a date-time, is refused and nothing is written. A past date is accepted, and the memory is stale at once. Without it, no date is stored. The date is a calendar day in timezone, an IANA zone name such as Australia/Sydney (any letter case; stored in its canonical spelling). Without timezone, the default zone is stored with the date: SQUIRREL_TIMEZONE if it is set, else this machine's zone. Abbreviations (AEST, EST), UTC offsets (+10:00), fixed Etc/GMT±N zones and unknown names are refused with a fixed message that does not repeat the value, and nothing is written. timezone without validUntil is refused too (nothing is sent). With category: "decision", decisionStatus is required, and decisionRationale (1–500 characters), decisionDate (YYYY-MM-DD, past or future, no zone) and decidedBy (1–100 characters) are optional; with any other category the four are refused. Either refusal, and a malformed decisionDate, sends nothing. keywords takes at most 10, each 1–50 characters after trimming (see "Keywords" below); more, a longer or blank one, is refused before any request, and spellings that differ only by case, accents on Latin letters or surrounding spaces are stored once. It is never pinned: only you pin, in the app. Returns near-duplicates (references and decisions of every status included) so the agent can supersede instead of adding a copy; they are found by text, entity and tags, never by keywords. | | supersede_memory | Replaces an active memory (id, text, reason): the old one is kept, marked superseded and linked to the new one. Guarded by the memory's revision, so a concurrent change is refused (409), not overwritten. Optional entity, category and tags refile it: the new memory takes the values given, each one omitted keeps the old value, and tags: [] clears the tags; the limits are remember_memory's (entity 1–200 characters after trimming, one of the six categories, at most 20 tags of 1–50 characters). The decision inputs: an omitted decisionStatus, decisionRationale, decisionDate or decidedBy keeps the old decision's value, a value replaces it, and null clears a detail (not the status). Refiling into decision needs a decisionStatus unless the old memory is a decision; refiling out drops the status and details; decision inputs with another category are refused, before any request when that category is given, after the one read of the old memory when it is not. The old memory keeps where it was filed, so its history shows the move; the note it came from (sourceNoteId) carries over and cannot be changed. The reason is stored on both memories: on the old one as why it was replaced, on the new one as why it changed. The old memory keeps its author, provenance, createdBy, createdAt, origin and sourceRef exactly as stored; this call is recorded in its modifiedBy. Optional origin describes the new text and is stored on the new memory only (left out, the new memory has none). Optional sourceRef: omitted keeps the old memory's, a value replaces it, null clears it. Optional keywords (remember_memory's limits): omitted keeps the old memory's readable keywords, a list replaces them, [] clears them; the old memory keeps its own. Optional validUntil and timezone (as in remember_memory): with validUntil omitted, the new memory keeps the old one's date and its stored zone (a date with no zone stays without one, and a zone this machine does not know is not carried); a date replaces it, in timezone or else the default zone (never the old memory's zone); null clears the date and the zone. timezone with validUntil omitted or null is refused. The old memory keeps its own date and zone. Changing the date alone does not lift the unrelated-text check below. Both records are written unpinned, even if the old one was pinned: only you pin, in the app. A disputed memory's open conflicts move to the new memory, with their reasons and times, so it is disputed with the same memories; the old one keeps its own record of them. A new text that shares almost no words with the old one (relatedness below 0.25: the share of one text's words, stopwords aside, found in the other, a half-word prefix or a part of a hyphenated word counting half, the larger of the two directions) is refused and nothing is written: the message names the memory's id only and tells the agent to use remember_memory for a separate fact, or to call again with confirmUnrelated: true if the text really replaces it. Refiling does not lift this check: it applies whatever entity, category or tags you pass. Accents on Latin letters are ignored first ("Zoë" compares as "Zoe"), and text in any script is compared (Greek and Cyrillic by words, Chinese and Japanese by pairs of characters); only text with no letter or digit in any script (most emoji, or punctuation, alone) is not checked. The check runs on your machine on the decrypted texts, the same way with or without the embeddings package, and logs a fixed line (supersede_memory refused (unrelated)) with no memory content. | | invalidate_memory | Marks an active memory as wrong. The text is kept; the memory is written unpinned. It keeps its author, provenance, createdBy, createdAt, origin and sourceRef; this call is recorded in its modifiedBy, and the answer's author is the memory's stored author. | | record_conflict | Records that two active memories (id, withId) make competing claims, with a reason (1–500 characters): for when the agent cannot yet tell which is right. Both stay active, searched, ranked and filtered as before; each stores an entry { with, reason, recordedAt } for the other inside its encrypted payload, and both then read as disputed: true. Both are flagged for review and written unpinned. Writes id first, then withId. Refused, with nothing written, for the same memory twice (before any request), a memory that is not active, a conflict already recorded on both, a memory already in 10 open conflicts, or a memory that would be too long to store once encrypted with the entry added and room left for any later single write (the widest reason, state, supersededBy and modifiedBy an invalidate, a resolve or a supersede adds), so those can still write it (its conflicts count toward the size, and a reason outside the Latin alphabet takes more room, so this can come before 10). Both sides are checked before anything is written. If the second write fails, the answer is an error that says the conflict is already in effect (the first side lists it) and that calling again completes it; calling again writes only the missing side. Answers { ids, written, disputed, needsReview }. | | resolve_conflict | Settles an open conflict: supersedeId becomes superseded by keepId, with evidence (1–500 characters) as its reason; its text and its record of the conflict are kept. keepId stays active, drops its entry for the other memory, keeps its other open conflicts, and is flagged. No new memory is written. Refused, with nothing written, for the same memory twice (before any request), a memory that is not active, or two memories with no open conflict between them. Writes the loser first; if the kept memory's write then fails, the answer is not an error: keptUpdated is false, with a note saying the resolution took effect. Answers { keptId, supersededId, keptUpdated, needsReview, note }. | | memory_history | Read-only. The server's change history of your memories: one entry per write (create, update, supersede, delete), oldest first, from GET /public/memory/history. Optional since (an ISO 8601 date, read as UTC midnight, or a date-time with seconds and Z or an offset; inclusive), id (one memory, deleted ones included), entity (case-insensitive, matched on either snapshot on your machine and never sent; a delete has no snapshot, so filter by id to see one), limit (1–50, default 20) and cursor (the previous call's nextCursor, with the same filters). Answers { entries, nextCursor, unverifiable }; each entry has at, operation, actor ({ kind: "app", keyPrefix: null } for a write from the app, { kind: "api-key", keyPrefix } for a write through an API key, whose keyPrefix is the key's first 12 characters as shown in Settings → API & Data, or null if the server could not read it), id, relatedId (a supersede's other memory), revisionBefore, revisionAfter, and before/after. See "Change history" below. | | get_context | Read-only. One bounded briefing for the start of a session, which the server's instructions now tell an agent to call first. Optional entity (trim + lower-case, as search_memory), tags and excludeTags (search_memory's rule), decisionStatus (default ["decided"], the decisions section only) and since (memory_history's shape, and a real instant from year 0001; default the last 7 days; a malformed value is refused before any request). Answers { notice, since, profile, conflicts, decisions, due, changes, approxTokens, unverifiable }: profile is exactly get_memory_profile's answer without its notice (with a tag filter, only the pinned memories that pass); conflicts lists active memories in an open conflict, oldest conflict first, each with its conflicts; decisions the active decisions in decisionStatus, newest first; due has stale (most overdue first), expiringSoon (last valid day today to 7 days ahead in the memory's zone, soonest first) and needsReview ({ count, oldestFlaggedAt } only, since only you review); changes lists each memory written since since, newest last write first, with writes, operations, lastAt, lastActor, deleted and the memory as it is now (null when deleted or not loaded). since is echoed. See "Session briefing" below. | | get_memory_profile | No inputs. The memories you have pinned (payload field pinned; only you pin, in the app), active ones only, most recently updated first (ties by id), cut at whole memories once text plus entity plus keywords passes about 300 tokens (characters ÷ 4); the first is always included. References, and decisions that are not decided, are left out even when pinned, before the budget is counted. Each item carries keywords, needsReview, reviewedAt, provenance, decision, createdAt, createdBy, modifiedBy, origin, sourceRef, validUntil, validUntilZone, stale, overdueDays, disputed and conflicts as search_memory's results do. With nothing pinned it says that only you pin, in the app, and to list your preferences with search_memory (category: "preference", no query) instead; the tool description and the server's instructions say the same, since a new store has nothing pinned. Also the resource memory://profile, with the same content. The server's instructions point an agent at get_context at the start of a session, which includes this profile, and say the profile alone still works. Reads only the list, like search_memory. |

  • Every agent write is live and flagged. It sets needsReview: true, author: 'agent' and flaggedAt inside the ciphertext. flaggedAt (ISO 8601 UTC) is when the memory was flagged: the app's review badge counts 24 hours from it (ADR-0419 §4) and clears it to null when you review. A new memory gets now. Superseding or invalidating a memory that is already flagged keeps its time, and a supersede's new memory inherits it, so an agent cannot restart the clock. It is optional in the payload (v stays 1), so older memories without it still open. A flagged memory is still searched and used: the flag is for you, not a gate (ADR-0418 §5). The flag is set by the MCP, so a modified client could leave it off (R-038).
  • Stale memories are flagged, never demoted (features 0037 and 0039). Every memory the tools return (search results, listings, near-duplicates, profile items) carries validUntil (the stored date, or null when there is none or it cannot be read), validUntilZone (the IANA zone the date is read in: the zone stored with it, else the default zone; null with no date), stale (true once today's date in that zone is after validUntil) and overdueDays (calendar days past it in that zone, null when not stale). A memory dated 2027-04-20 in Australia/Sydney is not stale until midnight starting 21 April in Sydney (2027-04-20T14:00Z, since Sydney is +10 that day), and is 1 day overdue from then. Only calendar dates are compared, so a daylight-saving change cannot make it a day out. Dates written before zones existed have no stored zone and are read in the default zone. stale is worked out on your machine at read time, for any category and any state, and is never stored. It does not change the ranking or the scores, so a stale memory can still come first: the descriptions tell the agent to check stale before relying on it. The date and zone are inside the encrypted payload (v stays 1), so the server never sees them, and they are never logged. A stored date that is not a real YYYY-MM-DD date is ignored (null), and a stored zone that is not a known IANA name is read as no zone; the memory is still returned. The app shows the date (with its zone when it is not the browser's), shows "Out of date" on an active memory whose date has passed, and lets you set, change or clear it.
  • Decisions and references (feature 0043). Two categories join the four. A decision is a choice with a status (proposed, discussed, decided, reversed or disputed) and, optionally, a rationale, a day and who reached it. By default search_memory returns decided decisions only, alongside every other memory, and the profile includes a pinned decision only when it is decided. A reference is a lookup value, such as a cloud ID or a URL, filed under its key as the entity; it stays out of every search and listing that does not ask for category: "reference", and out of the profile even when pinned. Every memory the tools return carries decision (null unless the memory is a decision; otherwise { status, rationale, date, by }, any field null when not set or unreadable). The status and details are inside the encrypted payload, so the server never sees them, and none is logged. MCP versions before this one, and app tabs on an older build, cannot read either category: they count such memories as unverifiable.
  • Who wrote a memory, and where its claim came from (feature 0044). Every memory the tools return carries createdAt and createdBy ({ author, via, client, tool }: who created it, written once and never changed by a later write), modifiedBy (same shape: who last superseded, invalidated, edited or restored it; updatedAt is the last write's time), origin and sourceRef. A stored time (createdAt, updatedAt, reviewedAt, flaggedAt) that is not an ISO instant, which only a writer outside these builds and the app can store, reads null rather than being passed on. Superseding or invalidating a memory, here or in the app, no longer rewrites its author and provenance. Memories written before this version carry null for the four new keys, and their author and provenance may name whoever last changed them. Origin, writer and source are self-asserted by whoever writes the memory, not verified; an agent write is still flagged for review. A sourceRef is data, never fetched or opened by the MCP or the app, and the descriptions tell agents not to follow it on a memory's say-so. All of them are inside the encrypted payload (v stays 1) and none is logged. A value of the wrong shape reads as null; the memory is still returned. Older MCP versions and app tabs keep the new keys on the memories they rewrite, but still overwrite author and provenance, and a supersede or edit there loses sourceRef on the new memory (R-087).
  • Keywords (feature 0061). A memory can carry keywords: other words someone might search with that its text does not use, such as synonyms, abbreviations or the wording of a question (DNS and ad blocking on a memory about Pi-hole). search_memory matches them as it matches tags (whole words, a hyphenated word's parts, and the half-word rule), and the embeddings package embeds them with the memory, after the tags. They are not a filter: tags, excludeTags and entity never read them, nor does remember_memory's near-duplicate check or supersede_memory's unrelated-text check. At most 10, each 1–50 characters, with no comma (the app edits them as one comma-separated field) and no character that draws nothing (zero-width, direction, tag, variation-selector, filler or control characters; so an emoji written with a variation selector, such as ❤️, is refused); the descriptions tell agents they are not a summary and not to stuff them, since each one makes the memory come up for that word. Every memory the tools return carries keywords ([] when none), except get_context's compact items. They are inside the encrypted payload (v stays 1) and never logged; a stored value of the wrong shape reads as [], or as its readable entries, and the memory is still returned. A memory with no keywords is embedded exactly as before; the vector file's header records how the embedded text is built, so the first searches after updating re-embed every memory once (a vector an older companion built without a memory's keywords is never reused). The app shows them ("Also found by") and lets you change them. Older MCP versions and app tabs keep them on every memory they rewrite, except the new memory of a supersede or edit, which loses them (R-125); they neither search nor show them.
  • Conflicts between memories (feature 0048). Every memory the tools return carries disputed (true when it is in an open conflict) and conflicts (its open conflicts, oldest first: { with, reason, recordedAt }, with being the other memory's id). A conflict is open while both memories are active and either one lists the other, so a half-written record still counts, and it ends when it is resolved or when either memory is superseded or invalidated (a supersede_memory moves it to the new memory). A memory that is not active is never disputed. Disputed memories stay live: ranking and filters are unchanged. This is not a decision's disputed status, which is a status on one decision; neither conflict tool changes it. The entries are inside the encrypted payload (v stays 1) and no reason or evidence is logged. A malformed entry is ignored; the memory is still returned. Older MCP versions and app tabs ignore the entries and show nothing disputed; their supersede or edit writes the new memory without them, which ends the conflict (R-088).
  • Change history (feature 0051). The server records every successful memory write, from the app or through an API key, in the same transaction: who (app, or api-key with the key's 12-character prefix, which names a key, not who used it; the client and tool names stay in the encrypted createdBy/modifiedBy), when, which operation, and the memory's ciphertext before and after. memory_history decrypts each snapshot on your machine under its own id and revision, so a snapshot moved to another memory or revision does not open: it is null and counted in unverifiable, as is an entry the server sent in the wrong shape. A snapshot is the memory's stored fields (text, category, entity, tags, the readable keywords, state, needsReview, pinned, author, reason, supersedes, supersededBy, sourceNoteId, provenance, createdBy, modifiedBy, origin, sourceRef, validUntil and its stored validUntilZone, decision, the stored conflicts and updatedAt), with nothing computed from the clock or other memories (no stale, no disputed). before and after are also null when there is none (a create has no before, a delete neither) and once the memory is deleted in the app, which erases its content from its history (the entries stay). Only the snapshots are authenticated: actor, operation, relatedId and at are asserted by the server, not authenticated by the encryption, so a server that lied about who made a change or when would not be caught. at is when the write's transaction started, so an entry can commit after one with a later at and be missing from a page read just before: to read new entries later, start since a little before the last at you read (a minute, say) rather than resuming from a saved cursor, and skip the entries you already have. The server also limits history reads to 60 a minute per account (per server container), answered with a 429; the monthly API call limit's 429 gets its own text (see "Monthly API calls" below). History entries are never returned as memories: search_memory and the profile never read them, and no tool writes, changes or deletes them. With entity, a call makes at most five requests, so fewer than limit entries with a nextCursor means more may match; without it, one. History is always fetched, never served from the 60 s list cache. The server keeps an account's newest 1,000 entries and every entry from the last 30 days, up to the newest 10,000; an older entry is removed by later writes. Writes before it began recording have none. The log line carries the three counts only (no id, entity, since, cursor, prefix or snapshot field).
  • Session briefing (feature 0052). get_context puts together what an agent would otherwise fetch in five or more calls at the start of a session: the pinned profile, then the flags (open conflicts, decisions in effect, memories past or near their validUntil, and how many wait for your review), then what changed since since (pass the end of the last session; the default is the last 7 days). Each list section holds at most 10 memories (changes 20), and outside the profile, which keeps its own 300 tokens, all sections share about 2,000 tokens (each item's text, entity, tags, a decision's rationale and by, and each of its conflicts, reason, other id and time, and its updatedAt, characters ÷ 4), filled in the order conflicts, decisions, stale, expiring, changes and cut at whole memories: a memory too large for what is left is skipped and counted in omitted, and the next one is tried. Each section says how many matched (total) and how many it left out (omitted); a memory in two sections counts twice. Items are compact (id, text, category, entity, tags, state, needsReview, pinned, decision, validUntil, validUntilZone, stale, overdueDays, disputed, updatedAt); search_memory returns every field. entity narrows every section but the profile; tags narrow every section and are a convenience, not access control; there is no scope filter yet. References are never included, in any section. decisionStatus narrows only the decisions section, so a proposed decision that is disputed or out of date is still flagged. changes reads the server's history (at most five pages of 50, oldest first): complete is false when there was more, which means the most recent changes are the ones missing, so call again with a later since. If the history cannot be read (a network error, a server without the history route, a 5xx), available is false with a fixed note and the rest of the briefing is still returned; a rejected key (401), an account no longer Pro, or the monthly API call limit (see "Monthly API calls" below) fails the call, as every memory tool does. It writes nothing, uses the 60 s list cache, and logs one line of counts (no text, entity, tag, since, id, key prefix or reason).
  • Agents cannot delete or pin memories. No tool sends a DELETE, and /public/memory has no delete route. No agent write is pinned, so a pinned memory that an agent supersedes or invalidates leaves your profile until you pin it again (product owner, 2026-10-12). You delete and pin them in the app.
  • Search ignores case and accents on Latin letters, as the app's search does: cafe finds "Café", strasse finds "Straße", and near-duplicate detection compares them the same way. Word matching works in any script: Greek, Cyrillic or Korean by words, and Chinese, Japanese, Thai, Lao, Khmer and Myanmar, written without spaces, by pairs of characters (a one-character query counts half for each pair holding it), so a memory in any of them can be found by its words and is compared for near-duplicates. A hyphenated word matches whole (pihole or Pi-hole), and each of its parts counts half; a hyphenated query word also counts half, once per memory, where its parts are written apart as consecutive words in the same order (end-to-end finds "end to end tests", ranked below "end-to-end tests", but not "moved to Leeds at the end of June"), and the same holds for near-duplicates and the supersede check; a query word of one or two letters matches only that whole word, or a hyphenated word's part of the same spelling (half). Still not matched: other forms of a word (тёмная does not find "тёмную", live finds "lives" only by the half-word rule, and a two-syllable Korean query such as 서울 does not find "서울에", where a particle is attached), and accents on letters of other scripts still count (σκυλος does not find "σκύλος"). A query with no letter or digit in any script says so. search_notes and search_note_content are unchanged: they also ignore case and Latin accents, and other scripts match there as typed.
  • Up to 60 s stale. Search, the profile and get_context reuse the decrypted list for up to 60 s, so a change made in the app, or a revoked key, can take up to 60 s to show in a running session (product owner, 2026-10-12; R-036). Writes from this process, and a rejected key, clear it at once.
  • Memory text is data, not instructions. The server instructions and every memory tool description tell the agent so, and to treat a memory as unverified while needsReview is true. Results carry author, provenance (via, the client's self-asserted name, capped at 100 characters, and the tool) and reviewedAt.
  • Limits. An account holds up to 5,000 memories and 4 MiB of memory ciphertext; past either, writes return a clear "limit reached" error. Writes are limited to 120 a minute per user, per route, per Lambda container (ADR-0418 §9b, R-042), so not an exact account-wide limit.
  • Errors and logs never contain memory text. Log lines carry the tool name, ids, counts and HTTP status only.
  • stdio only. The memory tools exist only in this stdio package (ADR-0421 removed the hosted HTTP server).

Semantic memory search (opt-in)

This package's search_memory is lexical. The separate package @squirrelnotes.app/mcp-embeddings is this same server (built from the same source and published from the same commit) with a local embedding model, so search_memory also finds memories by meaning. To opt in, change args to ["-y", "@squirrelnotes.app/mcp-embeddings"] and add "ONNXRUNTIME_NODE_INSTALL": "skip" to env.

  • The model runs in the MCP process on your machine. Memory text is not sent anywhere to be embedded. Search results still go to your agent.
  • Vectors are kept only in an AES-256-GCM file in your OS cache directory, mode 0600. They are never sent, logged or returned; each hit carries only its similarity, one rounded number, as semanticScore.
  • Tools, inputs and output keys are identical (ranking says hybrid when meaning was used). If the model is refused or cannot load, search is this package's lexical search.
  • A hit holding every query word still ranks above every hit that does not, and age discounts only the meaning part of a hit's score; the companion's README gives the formula.

This package has no ML dependency: onnxruntime-node is installed only by the companion (ADR-0442).


Filesystem MCP Companion (STDIO only)

Combining the Squirrel Notes MCP server with the MCP Filesystem server enables a two-step pipeline for attaching files generated by Claude — without putting file bytes in the context window:

Claude generates content
        │
        ▼  write_file (Filesystem MCP) ──► file lands on disk
                                                  │
                                                  ▼  upload_attachment_from_path (Squirrel MCP)
                                                     reads bytes, encrypts, uploads to S3
                                                     (only a path string in context)

Claude Desktop configuration

Add both servers to claude_desktop_config.json:

{
  "mcpServers": {
    "squirrel-notes": {
      "command": "npx",
      "args": ["-y", "@squirrelnotes.app/mcp"],
      "env": {
        "SQUIRREL_API_BASE_URL": "https://api.squirrelnotes.app",
        "SQUIRREL_API_KEY": "sqn_your_api_key",
        "SQUIRREL_PASSPHRASE": "your-passphrase",
        "SQUIRREL_UPLOAD_ROOT": "/tmp"
      }
    },
    "filesystem": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
      "env": {}
    }
  }
}

SQUIRREL_UPLOAD_ROOT restricts which directories upload_attachment_from_path may read from. Defaults to os.tmpdir() — /tmp on macOS/Linux, %TEMP% equivalent on Windows. Set to a comma-separated list for multiple allowed roots (e.g. "/tmp,/Users/you/Downloads"). This is a security guardrail — without it, a prompt-injection attack could trick Claude into uploading files from anywhere on disk (e.g. ~/.ssh/id_rsa). Note: symlinks inside an allowed root that point outside it are not followed during the check — use OS-level controls if that is a concern.

Example workflows

Attach a Claude-generated CSV report to a note:

"Create a monthly expense summary as a CSV and attach it to my Finances note."

  1. Claude generates the CSV content → write_file("/tmp/expenses.csv", content) (Filesystem MCP)
  2. Claude calls upload_attachment_from_path with localPath="/tmp/expenses.csv" (Squirrel MCP)

Attach an xlsx file generated by a script:

"Write a Python script to generate an xlsx report and attach it to my Analytics note."

  1. Claude writes the script to /tmp/generate_report.py → executes it → report.xlsx is written to /tmp/
  2. Claude calls upload_attachment_from_path with localPath="/tmp/report.xlsx"

Bulk-import Markdown files from disk:

"Import all the markdown files from /tmp/obsidian-export/ as notes in my Archive collection."

  1. Claude calls list_directory("/tmp/obsidian-export/") (Filesystem MCP) to enumerate files
  2. For each .md file: read_file(path) → create_note with the content (Squirrel MCP)

Export a note to disk:

"Export my project roadmap note as a Markdown file to /tmp/roadmap.md."

  1. Claude calls get_note(id) (Squirrel MCP) to read the note
  2. Claude calls write_file("/tmp/roadmap.md", content) (Filesystem MCP)

The server needs an API key and your passphrase, both set as env vars in your client's config (see Setup). The API key is sent to the Squirrel Notes API in the Authorization header; the passphrase is never sent anywhere.

Generate an API key in Squirrel Notes → Settings → API & Data (requires Pro tier).


Setup

Setup — Claude Desktop, Cursor and other MCP clients (stdio)

The stdio transport runs the MCP server as a local process on your machine. No OAuth, no network headers, no AWS account needed. Your passphrase is set as an env var in the config file and never leaves your machine.

Option A — npx (zero install, recommended)

No download or build step required. npx fetches the latest published version automatically on first run and caches it locally.

1. Configure Claude Desktop

Open ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or %APPDATA%\Claude\claude_desktop_config.json (Windows) and add:

{
  "mcpServers": {
    "squirrel-notes": {
      "command": "npx",
      "args": ["-y", "@squirrelnotes.app/mcp"],
      "env": {
        "SQUIRREL_API_BASE_URL": "https://api.squirrelnotes.app",
        "SQUIRREL_API_KEY": "sqn_<your_api_key>",
        "SQUIRREL_PASSPHRASE": "<your_passphrase>"
      }
    }
  }
}

2. Restart Claude Desktop

Quit and reopen Claude Desktop. The Squirrel Notes MCP server will start automatically.

3. Verify

Ask Claude: "List my Squirrel Notes" — you should see your note titles.

4. Recommended: install the agent skill

Recommended: give your agent the Squirrel Notes skill from github.com/squirrel-notes/skills. It teaches the agent to start each session with get_context, search before answering (with a near-miss retry), save memories that will be found again, and correct them without losing history. In Claude Code, copy skills/squirrel-notes into ~/.claude/skills/; the repository's README covers other agents.

Option B — build from source (local development / pinned version)

Use this if you want to run a specific local build or contribute to the server code.

cd mcp
npm install
npm run build:stdio
# Produces dist/stdio-handler.js

Then reference the built file directly:

{
  "mcpServers": {
    "squirrel-notes": {
      "command": "node",
      "args": ["/absolute/path/to/squirrel-notes/mcp/dist/stdio-handler.js"],
      "env": {
        "SQUIRREL_API_BASE_URL": "https://api.squirrelnotes.app",
        "SQUIRREL_API_KEY": "sqn_<your_api_key>",
        "SQUIRREL_PASSPHRASE": "<your_passphrase>"
      }
    }
  }
}

Windows: Replace the path with C:\\path\\to\\mcp\\dist\\stdio-handler.js (double backslashes).


Common Workflows

Once configured, you can speak to Claude naturally. Some examples:

| What you say | What Claude does | |---|---| | "What have I been working on lately?" | get_recent_notes → lists last 10 updated notes | | "Show me everything in my Work collection" | list_collections → list_notes(collectionId: ...) | | "Search my notes for 'project roadmap'" | search_notes (title match) or search_note_content | | "Create a note called 'Meeting Notes' in my Work collection" | list_collections → create_note(collectionId: ...) | | "Add today's standup to my Daily Log note" | search_notes("Daily Log") → append_to_note(id, ...) | | "Fix the install command in my Setup guide" | search_notes("Setup guide") → get_note_outline → read_note(heading: "Install") → edit_note(oldText, newText, expectedUpdatedAt) | | "Pin my TODO note" | search_notes("TODO") → pin_note(id, true) | | "Tag my 'Project Roadmap' note as urgent" | list_tags → search_notes → set_note_tags | | "Show me all my pinned notes" | list_notes(pinned: true) | | "Show me all notes tagged 'urgent'" | list_tags → list_notes(tagId: ...) |

Tip for Claude Desktop: Add a system prompt in your Claude Project describing your note structure (e.g. "I use a collection called 'Work' for work notes and 'Personal' for personal notes, and tags like 'urgent' and 'later' for priority"). This reduces the number of lookup calls Claude needs to make.


Local Development

npm --prefix mcp install
npm --prefix mcp run build      # builds dist/stdio-handler.js
npm --prefix mcp test

Point a client at mcp/dist/stdio-handler.js with SQUIRREL_API_BASE_URL=http://localhost:3001 (the local backend), as in Option B above. Or open the MCP Inspector on it — set the three env vars in the Inspector's UI:

make mcp-inspect

Debugging / Getting Logs

stdio transport (Claude Desktop)

The MCP protocol uses stdout for JSON-RPC messages, so all diagnostic output goes to stderr instead. The MCP server writes errors and key upload steps to stderr automatically.

Where to find the logs

macOS / Linux Claude Desktop captures stderr from each MCP server in a dedicated log file:

~/Library/Logs/Claude/mcp-server-squirrel-notes.log   # macOS

Open this file in any text editor or tail it in a terminal:

tail -f ~/Library/Logs/Claude/mcp-server-squirrel-notes.log

Windows

%APPDATA%\Claude\logs\mcp-server-squirrel-notes.log

Write logs to a custom file

Set SQUIRREL_LOG_FILE to an absolute path in your claude_desktop_config.json to capture all stderr output to a file of your choice:

{
  "mcpServers": {
    "squirrel-notes": {
      "command": "npx",
      "args": ["-y", "@squirrelnotes.app/mcp"],
      "env": {
        "SQUIRREL_API_BASE_URL": "https://api.squirrelnotes.app",
        "SQUIRREL_API_KEY": "sqn_<your_api_key>",
        "SQUIRREL_PASSPHRASE": "<your_passphrase>",
        "SQUIRREL_LOG_FILE": "/tmp/squirrel-mcp.log"
      }
    }
  }
}

Then in a terminal:

tail -f /tmp/squirrel-mcp.log

What's logged

Each line goes to stderr and, when SQUIRREL_LOG_FILE is set, is also appended to that file.

  • upload_attachment_from_path progress: the local file path it reads, which can contain a plaintext filename; the file's size and MIME type; the new attachment id; the S3 PUT status code; and the confirmed attachment id. Leave SQUIRREL_LOG_FILE unset if those paths should not be kept on disk.
  • Memory tools: the tool name, memory ids, counts (memories searched, results, unverifiable records, profile items and omitted) and, on failure, the HTTP status, or network with the cause's error code when Node gives one (for example network (ECONNREFUSED)). Never memory text, entities, tags, keywords, reasons, validUntil dates or zones, a decision's status, rationale, date or decidedBy, or an origin or sourceRef. A refusal logs a fixed code only (for example remember_memory refused (decision-without-status)).
  • One start-up line when SQUIRREL_TIMEZONE is set to a name this machine does not know. It names the variable only, never its value or the machine's zone.
  • With the embeddings companion only: one start-up line saying whether local embeddings are ready or were not loaded (with a reason and, at most, a file name), and, once, a runtime or vector-file failure. Never a vector or a query.
  • Fatal startup errors: missing SQUIRREL_API_KEY, SQUIRREL_PASSPHRASE or SQUIRREL_API_BASE_URL, or a failure to connect the stdio transport.

Not logged:

  • An S3 PUT failure. The tool throws it as S3 upload failed (<status>), adding S3's error code when the response names one (for example S3 upload failed (403 SignatureDoesNotMatch)), and the MCP client shows it as the tool's error. The rest of S3's response (its message, the object key, the signed request) is never in the error or the log. The log has only the status line above. A PUT that gets no answer at all is thrown as storage PUT (attachment upload) → no response (…) with the cause's error code (see Troubleshooting network errors); the upload URL is never in it.
  • A wrong passphrase. It is no longer caught at startup: the key is derived on the first tool call, and a wrong passphrase fails that call (and every later one) with an error returned to the client.
  • The passphrase, the API key, the data key and any decrypted content.
  • A share token. An error from update_share_settings, republish_share or revoke_share names the path as /public/shared-notes/{token}/…, and the token is taken out of the server's answer too.

Troubleshooting network errors

When a request gets no HTTP answer, the tool error names the error code Node reports and says what it means, for example:

Squirrel API GET /public/notes → no response (network error: UNABLE_TO_GET_ISSUER_CERT_LOCALLY — Node does not trust the server's TLS certificate; …)

The memory tools put the same (network error: …) part in their own sentence, which adds that nothing is known to have been written. Only the code is shown, never the underlying error's message, which can contain URLs, host names or certificate details. A code Node adds later is named without an explanation; network error, cause unknown means Node gave no code.

A certificate your browser trusts but Node does not. NODE_EXTRA_CA_CERTS is only for your own locally trusted certificate, such as an mkcert certificate for an API you run on this machine. Against the hosted service (https://api.squirrelnotes.app) an untrusted certificate means the connection may be intercepted: do not add a CA to make the error go away; find out what is between you and the server first.

For your own certificate: Node ignores the operating system's certificate store, so add the CA file to the server's env in your MCP client config:

"env": {
  "SQUIRREL_API_BASE_URL": "https://localhost:3001",
  "NODE_EXTRA_CA_CERTS": "/path/to/rootCA.pem"
}

For mkcert, the file is rootCA.pem in the directory mkcert -CAROOT prints. Node versions that support the --use-system-ca flag can read the system store instead, but NODE_EXTRA_CA_CERTS works on every supported version and is the one to use.

| Code | Meaning | |---|---| | UNABLE_TO_GET_ISSUER_CERT_LOCALLY, UNABLE_TO_GET_ISSUER_CERT, UNABLE_TO_VERIFY_LEAF_SIGNATURE, SELF_SIGNED_CERT_IN_CHAIN, DEPTH_ZERO_SELF_SIGNED_CERT, CERT_UNTRUSTED | Node does not trust the server's certificate. For your own local certificate, set NODE_EXTRA_CA_CERTS as above; for the hosted service, do not add a CA (the connection may be intercepted). | | CERT_HAS_EXPIRED | The server's certificate has expired. | | CERT_NOT_YET_VALID | The certificate is not valid yet; check this machine's clock. | | ERR_TLS_CERT_ALTNAME_INVALID | The certificate does not name the host in SQUIRREL_API_BASE_URL. | | ERR_SSL_WRONG_VERSION_NUMBER, ERR_SSL_PACKET_LENGTH_TOO_LONG | The server did not answer in TLS: SQUIRREL_API_BASE_URL probably says https:// for a port that serves plain HTTP. Check its scheme and port. | | ECONNREFUSED | Nothing is listening at that host and port; check the API is running and the URL is right. | | ENOTFOUND | The host does not resolve; check the URL and DNS. | | EAI_AGAIN | The DNS lookup failed for now; check the network, then retry. | | EHOSTUNREACH | There is no route to the host; check the URL and the network, VPN or firewall. | | ENETUNREACH | This machine has no route to the server's network; check its connection or VPN. | | ETIMEDOUT, UND_ERR_CONNECT_TIMEOUT | The connection timed out; check the network, proxy or firewall. | | UND_ERR_HEADERS_TIMEOUT | The server accepted the connection but sent no response in time; retry later. | | ECONNRESET, UND_ERR_SOCKET | The connection closed before the server answered; retry later. | | ERR_INVALID_URL | SQUIRREL_API_BASE_URL is not a valid URL. |

The attachment upload's storage PUT goes to the upload URL the API returns, not to SQUIRREL_API_BASE_URL, so every code whose sentence names that variable says something else for it: the host and connection codes say to check the network, the two ERR_SSL_ codes that the API may have the wrong scheme for its storage endpoint.


Monthly API calls

Squirrel Notes counts the calls each account makes to the public API with its API key (feature 0067, proposal 0089). Every request this server sends with SQUIRREL_API_KEY counts as one call, so each tool call that reaches the API counts at least one: the first tool call of a process also fetches the encryption settings (one call), a note edit reads and then writes (two), and a memory search inside the 60 s list cache sends nothing. A request the key guard refuses (401, or 403 for an account that is not Pro) is not counted.

  • The limit. Pro includes 5,000 calls a calendar month, counted per account, all keys together.
  • The reset. The month is the UTC calendar month: the count starts again at 00:00 UTC on the 1st. The app shows the month's usage above Storage in the library panel and in Settings → API & Data.
  • Not enforced yet. The first release counts and shows usage and refuses no call. The handling below ships now so this server already reads the refusal correctly when the limit is switched on.
  • Once it is enforced, a call over the limit gets HTTP 429 with a Retry-After header and the body { "statusCode": 429, "error": "Too Many Requests", "message": "Monthly API call limit reached", "limit": 5000, "resetsAt": "2026-11-01T00:00:00.000Z" }. This server matches the message exactly (any other 429 is the per-minute limit) and then:
    • memory tools (get_context and memory_history included) answer with a fixed text: "This account has used its 5,000 monthly API calls. Nothing more was written. Calls resume on 2026-11-01 (UTC); the user can