Skip to content

smyrnakis/joplin-notes-agent

Repository files navigation

Your first local agent — Joplin notes assistant

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).

Prerequisites

  • Python 3.10+ (needs the str | None type 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 .env file — the Python is identical.

Quickstart — setting up on a new computer

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.py

That'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.

How it's wired

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

1. Enable Joplin's Data API (one-time per computer)

  1. 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.
  2. Go to Tools/Options > Web Clipper.
  3. Click Enable Web Clipper Service.
  4. Copy the Authorization token shown there — you'll need it below.

2. Set up the Python environment (Windows Terminal / PowerShell)

cd C:\Git\personal\joplin-notes-agent
python -m venv .venv
.venv\Scripts\Activate.ps1
pip install -r requirements.txt

Heads-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.

3. Set your credentials (once, via .env)

Copy the template and open it:

Copy-Item .env.example .env
notepad .env

Fill 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:

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.

4. Smoke-test the Joplin connection by itself, before touching the LLM

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.

5. (Optional) Build the semantic search index

python build_index.py

This 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.

6. Run the agent

python agent.py

Try 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.

Run from anywhere (type joplin in any terminal)

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 $PROFILE

Add 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):

. $PROFILE

Now 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 to PATH doesn't work cleanly here, because the agent must run under the venv's Python, not the system Python. The profile-function approach routes through joplin.ps1, which points at .venv\Scripts\python.exe explicitly — that's why it's more reliable than a raw PATH entry.

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).

Tuning knobs

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)

Smarter retrieval: notebook scoping, preview-before-fetch, recency, and meaning

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 scopes search_notes with Joplin's notebook:"Name" filter instead of searching the entire 9-year archive.

  • Preview before full readsearch_notes now 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 full get_note call, 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 /notes listing endpoint sorted by updated_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_notes is 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 with sentence-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_notes first (cheap, exact), and semantic_search when 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.

Running it again (after reboot, or day-to-day)

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.py

Or, 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.py

Nothing 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.

Resilience: a bad tool call no longer crashes the session

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_tool in agent.py now wraps every dispatch in a try/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.

Possible next steps

  1. 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.
  2. Swap to a fully local model via Ollama: change only agent.py's client setup — joplin_tools.py doesn't change at all, which is a good demonstration of why separating "tools" from "model" matters.
  3. Add a similarity floor to semantic_search: right now it always returns top_k results 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.

About

AI agent interacting with local Joplin Notes

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors