A minimal, framework-free agent that searches and answers questions over your Joplin notes archive. It talks to the local Joplin app, uses an LLM to decide what to search for and read, and runs the retrieval entirely on your own machine.
Licensed under the MIT License (see LICENSE).
- Python 3.10+ (needs the
str | Nonetype syntax used in the code). - The Joplin desktop app, installed and running, with some notes in it.
- An Anthropic API key — create one at https://platform.claude.com/dashboard
- Windows instructions below use PowerShell. On macOS/Linux the only
differences are venv activation (
source .venv/bin/activate) and using shell exports or the same.envfile — the Python is identical.
Every machine needs its own virtual environment and its own .env; neither
is committed to git, so you recreate them per computer. Full run, top to
bottom:
# 1. Clone and enter
git clone https://github.com/smyrnakis/joplin-notes-agent.git
cd joplin-notes-agent
# 2. Create an isolated Python environment and install dependencies
python -m venv .venv
.venv\Scripts\Activate.ps1
pip install -r requirements.txt
# 3. Add your keys
Copy-Item .env.example .env
notepad .env # fill in ANTHROPIC_API_KEY and JOPLIN_TOKEN, then save
# 4. (Optional) build the semantic search index for this machine
python build_index.py
# 5. Run it (Joplin desktop must be open)
python agent.pyThat's the whole thing. The sections below explain each step in more detail,
plus how to make the agent runnable as a plain joplin command from any
directory.
joplin_tools.py <- talks to Joplin's local Data API (the only Joplin-aware file)
agent.py <- the ReAct loop: reason -> call a tool -> observe -> repeat
semantic_search.py <- local embedding-based search (meaning, not keywords)
build_index.py <- one-time/occasional script that builds the semantic index
joplin.ps1 <- run-anywhere launcher (activates the venv, runs the agent)
.env.example <- template for your keys; copy to .env (which is git-ignored)
requirements.txt
- Open the Joplin desktop app and make sure it stays running while you use the agent — the API only exists while the app is open.
- Go to Tools/Options > Web Clipper.
- Click Enable Web Clipper Service.
- Copy the Authorization token shown there — you'll need it below.
cd C:\Git\personal\joplin-notes-agent
python -m venv .venv
.venv\Scripts\Activate.ps1
pip install -r requirements.txtHeads-up: sentence-transformers (for semantic search) pulls in PyTorch, so
this install can take a few minutes and a few hundred MB the first time.
That's a one-time cost per machine.
Copy the template and open it:
Copy-Item .env.example .env
notepad .envFill in the two required keys. After editing, .env should look like this
(with your real values):
ANTHROPIC_API_KEY=sk-ant-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
JOPLIN_TOKEN=abcdef0123456789abcdef0123456789
Where each value comes from:
ANTHROPIC_API_KEY— create/copy at https://platform.claude.com/dashboardJOPLIN_TOKEN— the Web Clipper Authorization token from step 1 (Joplin > Tools/Options > Web Clipper).
The app loads .env automatically on every run (via python-dotenv), so
you set this once per machine and never re-export variables each session.
.env is listed in .gitignore, so your real keys are never committed —
only .env.example (placeholder values) goes to GitHub.
The optional lines in .env.example (model name, Joplin URL, etc.) can stay
commented out — the code has sensible defaults. Uncomment one only to
override it.
python joplin_tools.py "CERN"You should get back a JSON list of matching note ids and titles. If you get a connection error here, Joplin isn't running or Web Clipper isn't enabled — fix that before moving on, since the agent will fail the same way.
python build_index.pyThis embeds every note locally using a small model (all-MiniLM-L6-v2,
~80MB, downloaded once and cached) and saves the vectors to
note_embeddings.npz. It can take a few minutes for a large archive, and
needs internet access for that one-time model download — after that, it
runs fully offline. You can skip this step entirely; the agent works fine
without it, it just won't have semantic_search available. Re-run it
whenever you want the index to catch up with notes you've added or edited
since the last build — this is a manual step, not automatic.
Everything about this step stays on your machine. The embedding model
runs locally and the index file (note_embeddings.npz) is git-ignored, so
it's never pushed to GitHub. Each computer builds and keeps its own index;
your note contents are only ever sent to Anthropic's API as part of a
normal question you ask the agent — the indexing itself uploads nothing.
python agent.pyTry something like:
you> What did I write about the bracket character class issue in PowerShell?
you> Find my notes about the Croatia trip itinerary
you> When did I first start documenting the evalink talos JSONPath conventions?
Watch the [step N] tool_name(...) lines print as it works — that's the
ReAct loop happening in real time: it searches first, reads the most
promising note, and only then answers.
joplin.ps1 activates the venv and runs the agent regardless of which
directory you call it from. To make a plain joplin command work from
anywhere, add a small function to your PowerShell profile:
# Open your profile (creates it if it doesn't exist)
if (-not (Test-Path $PROFILE)) { New-Item -ItemType File -Path $PROFILE -Force }
notepad $PROFILEAdd this line (adjust the path if your clone lives elsewhere):
function joplin { & "C:\Git\personal\joplin-notes-agent\joplin.ps1" @args }Reload the profile (or open a new terminal):
. $PROFILENow just type joplin from any directory — Joplin desktop needs to be
running, but you no longer need to cd anywhere or activate the venv by
hand.
Note on
$PATH: adding the repo folder directly toPATHdoesn't work cleanly here, because the agent must run under the venv's Python, not the system Python. The profile-function approach routes throughjoplin.ps1, which points at.venv\Scripts\python.exeexplicitly — that's why it's more reliable than a rawPATHentry.
Output is now colorized for Windows Terminal: step logs print dim/grey so
they read as background reasoning, and the final answer renders
**bold** and `code` as actual styling instead of literal asterisks
and backticks (the system prompt asks the model to stick to that handful
of Markdown constructs, since this is a terminal, not a browser).
| In code | What it controls |
|---|---|
MAX_ITERATIONS in agent.py |
Hard cap on loop turns — raise it for multi-note synthesis questions, lower it to fail fast |
MODEL env var (AGENT_MODEL) |
Swap models without touching code |
SYSTEM_PROMPT |
The agent's rules — tighten this first if it over- or under-searches |
TOOLS[*]["description"] |
The model only knows what these say — vague descriptions cause wrong tool choices |
EMBEDDING_MODEL env var |
Swap the local embedding model (default all-MiniLM-L6-v2) — re-run build_index.py after changing it |
top_k in the semantic_search tool call |
How many matches the model gets back per call (default 5) |
SEMANTIC_INDEX_PATH env var |
Where the index file lives, if you want more than one (e.g. per-notebook indexes) |
Four upgrades over the original version, all aimed at the same problem — don't blindly read every match, narrow down first:
-
list_notebooks— a new tool the model can call when your question clearly points at one area (e.g. a work topic vs. a personal one). It then scopessearch_noteswith Joplin'snotebook:"Name"filter instead of searching the entire 9-year archive. -
Preview before full read —
search_notesnow returns a short excerpt of each match's body (not the full text) in the same call, at no extra API round trip. The agent reads these previews to judge which 1-2 notes are actually worth a fullget_notecall, instead of fetching everything that matched. -
list_recent_notes(days)— for "what have I written lately" style questions, where there's no keyword to search for at all. Joplin's search syntax doesn't have a reliable date-range filter, so instead this calls the plain/noteslisting endpoint sorted byupdated_time DESC, pages through it, and stops as soon as it hits a note older than the cutoff — no need to pull the full archive to answer "what's new this week." -
semantic_search(query)— for when you don't remember the exact words you used.search_notesis keyword matching: it finds "onboarding" only if that word is literally in the note. Semantic search instead compares meaning: it embeds your query and every note into vectors using a small local model, then finds whichever notes are closest in that vector space — so "that thing where I was annoyed about onboarding friction" can match a note titled "CS process notes" that never uses either word, if the content is conceptually about the same thing.How it actually works, end to end:
build_index.py(step 5 above) fetches every note, turns each one into a 384-number vector withsentence-transformers, and saves all of those vectors plus the note metadata into one file,note_embeddings.npz. That's the slow, one-time (or occasional) part. At question time,semantic_search()only has to embed your short query — one vector, instant — and compare it against the saved ones with a dot product (cosine similarity, since the vectors are normalized). No API call, no internet needed once the model is cached; it all happens on your machine. The agent decides which tool to reach for:search_notesfirst (cheap, exact), andsemantic_searchwhen that comes up empty or the question is conceptual rather than keyword-shaped.
This is the "skim, then commit" pattern: return cheap previews first, and only fetch full content for the few notes that look right — cheaper in tokens, and more accurate, because the model chooses based on real content instead of title-guessing. See Anthropic's Building effective agents for the broader design philosophy this follows.
MAX_ITERATIONS was bumped from 6 to 7 to leave room for the extra
list_notebooks step on questions that need it.
Your keys live in .env and load automatically, so you never re-enter them.
Pick the row that matches your situation:
After a laptop reboot (or a fresh terminal). Open Joplin desktop first (the API only exists while it's running), then:
cd C:\Git\personal\joplin-notes-agent
.venv\Scripts\Activate.ps1
python agent.pyOr, if you set up the launcher in "Run from anywhere" above, skip all three
lines and just type joplin from any directory.
You edited the code and want to reload it. Python reads the file fresh on every run, so nothing to rebuild — in the same terminal:
quit
python agent.py(or Ctrl+C if it's stuck mid-response, then python agent.py again)
You added a package to requirements.txt. Reinstall before rerunning:
pip install -r requirements.txt
python agent.pyNothing works after reboot. Almost always one of: Joplin desktop isn't
open, or you didn't activate the venv (your prompt should show (.venv) at
the start of the line). Check both first.
Originally, any failure inside a tool (Joplin returning a 404 for a stale or wrong note id, the app not running, a dropped connection) raised an exception that propagated all the way up and killed the whole process — you'd lose the conversation and have to restart. Two changes fix that:
run_toolinagent.pynow wraps every dispatch in atry/except. Any failure becomes{"error": "..."}, fed back to the model as a normal tool result, instead of an uncaught exception. The model is told in the system prompt not to retry the identical call when it sees an error — the id or query was wrong, not transient — and to either try something different or say plainly it couldn't find the answer.- Errors print in red in the step log (
[step N] -> error: ...) so a failure is visible immediately while you're watching the terminal, not just silently absorbed by the model.
One likely cause of the exact crash you hit: list_notebooks returns
folder ids, and get_note needs a note id — if the model passes one to
the other, Joplin correctly 404s. The get_note tool description now says
explicitly which tools' output is valid input here.
There's also a small security cleanup bundled in: joplin_tools.py now
routes every request through one shared _get() helper, which strips your
Joplin token out of any error message before it's raised — so a 404 like
the one in your traceback can no longer leak the token into a log file or
into the LLM's context via a tool error result.
- Add basic observability: write each
[step N] tool(...)line to a log file with a timestamp instead of just printing it, so you can trace what the agent did after the fact. - Swap to a fully local model via Ollama: change only
agent.py's client setup —joplin_tools.pydoesn't change at all, which is a good demonstration of why separating "tools" from "model" matters. - Add a similarity floor to
semantic_search: right now it always returnstop_kresults even if none are actually close — returning a "nothing relevant found" signal below some threshold (e.g. 0.3) would let the model say so plainly instead of presenting a weak match as an answer.