@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
endLineis inclusive. A\r\nline break counts as one break and is never shown; a lone\ris an ordinary character. When every line break in a note is\r\n, the text a write adds uses\r\ntoo; 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 asC), and in a note with\r\nline endings a fenced code block is not recognised, so a#line inside one is listed. edit_notematches exactly: case, spaces, accents, Unicode form and line breaks all count, and overlapping matches count (aaoccurs twice inaaa). When the text repeats, widenoldTextor passheadingto 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_sectionandinsert_in_sectionkeep 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_sectionleaves the spacing the note would have had without the section.insert_at_lineadds 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 oninsert_at_line): pass theupdatedAtfrom 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 changesupdatedAt, 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'andflaggedAtinside 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 tonullwhen 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 (vstays 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, ornullwhen 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;nullwith no date),stale(true once today's date in that zone is aftervalidUntil) andoverdueDays(calendar days past it in that zone,nullwhen not stale). A memory dated 2027-04-20 inAustralia/Sydneyis 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.staleis 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 checkstalebefore relying on it. The date and zone are inside the encrypted payload (vstays 1), so the server never sees them, and they are never logged. A stored date that is not a realYYYY-MM-DDdate 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
decisionis a choice with a status (proposed,discussed,decided,reversedordisputed) and, optionally, a rationale, a day and who reached it. By defaultsearch_memoryreturns decided decisions only, alongside every other memory, and the profile includes a pinned decision only when it is decided. Areferenceis 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 forcategory: "reference", and out of the profile even when pinned. Every memory the tools return carriesdecision(nullunless the memory is a decision; otherwise{ status, rationale, date, by }, any fieldnullwhen 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
createdAtandcreatedBy({ 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;updatedAtis the last write's time),originandsourceRef. 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, readsnullrather than being passed on. Superseding or invalidating a memory, here or in the app, no longer rewrites itsauthorandprovenance. Memories written before this version carrynullfor the four new keys, and theirauthorandprovenancemay 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. AsourceRefis 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 (vstays 1) and none is logged. A value of the wrong shape reads asnull; the memory is still returned. Older MCP versions and app tabs keep the new keys on the memories they rewrite, but still overwriteauthorandprovenance, and a supersede or edit there losessourceRefon 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 (DNSandad blockingon a memory about Pi-hole).search_memorymatches 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,excludeTagsandentitynever read them, nor doesremember_memory's near-duplicate check orsupersede_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 carrieskeywords([]when none), exceptget_context's compact items. They are inside the encrypted payload (vstays 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) andconflicts(its open conflicts, oldest first:{ with, reason, recordedAt },withbeing 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 (asupersede_memorymoves 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'sdisputedstatus, which is a status on one decision; neither conflict tool changes it. The entries are inside the encrypted payload (vstays 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, orapi-keywith the key's 12-character prefix, which names a key, not who used it; the client and tool names stay in the encryptedcreatedBy/modifiedBy), when, which operation, and the memory's ciphertext before and after.memory_historydecrypts each snapshot on your machine under its own id and revision, so a snapshot moved to another memory or revision does not open: it isnulland counted inunverifiable, as is an entry the server sent in the wrong shape. A snapshot is the memory's stored fields (text,category,entity,tags, the readablekeywords,state,needsReview,pinned,author,reason,supersedes,supersededBy,sourceNoteId,provenance,createdBy,modifiedBy,origin,sourceRef,validUntiland its storedvalidUntilZone,decision, the storedconflictsandupdatedAt), with nothing computed from the clock or other memories (nostale, nodisputed).beforeandafterare alsonullwhen 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,relatedIdandatare 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.atis when the write's transaction started, so an entry can commit after one with a lateratand be missing from a page read just before: to read new entries later, startsincea little before the lastatyou 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_memoryand the profile never read them, and no tool writes, changes or deletes them. Withentity, a call makes at most five requests, so fewer thanlimitentries with anextCursormeans 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_contextputs 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 theirvalidUntil, and how many wait for your review), then what changed sincesince(pass the end of the last session; the default is the last 7 days). Each list section holds at most 10 memories (changes20), 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 andby, and each of its conflicts, reason, other id and time, and itsupdatedAt, 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 inomitted, 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_memoryreturns every field.entitynarrows every section but the profile; tags narrow every section and are a convenience, not access control; there is noscopefilter yet. References are never included, in any section.decisionStatusnarrows only the decisions section, so a proposed decision that is disputed or out of date is still flagged.changesreads the server's history (at most five pages of 50, oldest first):completeisfalsewhen there was more, which means the most recent changes are the ones missing, so call again with a latersince. If the history cannot be read (a network error, a server without the history route, a 5xx),availableisfalsewith 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/memoryhas 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:
cafefinds "Café",strassefinds "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 (piholeorPi-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-endfinds "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 "тёмную",livefinds "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_notesandsearch_note_contentare 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_contextreuse 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
needsReviewis true. Results carryauthor,provenance(via, the client's self-asserted name, capped at 100 characters, and the tool) andreviewedAt. - 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, assemanticScore. - Tools, inputs and output keys are identical (
rankingsayshybridwhen 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_ROOTrestricts which directoriesupload_attachment_from_pathmay read from. Defaults toos.tmpdir()—/tmpon 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."
- Claude generates the CSV content →
write_file("/tmp/expenses.csv", content)(Filesystem MCP) - Claude calls
upload_attachment_from_pathwithlocalPath="/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."
- Claude writes the script to
/tmp/generate_report.py→ executes it →report.xlsxis written to/tmp/ - Claude calls
upload_attachment_from_pathwithlocalPath="/tmp/report.xlsx"
Bulk-import Markdown files from disk:
"Import all the markdown files from /tmp/obsidian-export/ as notes in my Archive collection."
- Claude calls
list_directory("/tmp/obsidian-export/")(Filesystem MCP) to enumerate files - For each
.mdfile:read_file(path)→create_notewith the content (Squirrel MCP)
Export a note to disk:
"Export my project roadmap note as a Markdown file to /tmp/roadmap.md."
- Claude calls
get_note(id)(Squirrel MCP) to read the note - 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.jsThen 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 testPoint 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-inspectDebugging / 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 # macOSOpen this file in any text editor or tail it in a terminal:
tail -f ~/Library/Logs/Claude/mcp-server-squirrel-notes.logWindows
%APPDATA%\Claude\logs\mcp-server-squirrel-notes.logWrite 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.logWhat's logged
Each line goes to stderr and, when SQUIRREL_LOG_FILE is set, is also appended to that file.
upload_attachment_from_pathprogress: 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. LeaveSQUIRREL_LOG_FILEunset 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
networkwith the cause's error code when Node gives one (for examplenetwork (ECONNREFUSED)). Never memory text, entities, tags, keywords, reasons,validUntildates or zones, a decision's status, rationale, date or decidedBy, or an origin orsourceRef. A refusal logs a fixed code only (for exampleremember_memory refused (decision-without-status)). - One start-up line when
SQUIRREL_TIMEZONEis 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_PASSPHRASEorSQUIRREL_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 exampleS3 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 asstorage 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_shareorrevoke_sharenames 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-Afterheader 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_contextandmemory_historyincluded) 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
- memory tools (
