A production-quality, conversational note-taking assistant. Manage notes — create, search, edit, delete — entirely through natural language, with conversational memory, human-in-the-loop confirmation before destructive actions, and intent disambiguation when a request is ambiguous.
The agent understands free-text requests like:
- "Create a note about Docker deployment"
- "Actually, add Kubernetes too" (resolves "too" to the note just created)
- "What did I write about APIs last week?"
- "Delete my standup note" (asks for confirmation before deleting)
It never guesses when multiple notes match, never invents information that isn't in the database, and always asks for explicit confirmation before a destructive or content-replacing action.
Natural language is parsed into structured intent (add_note,
search_notes, update_note, delete_note, etc.) in one of three ways,
resolved automatically at runtime by agent/intent.py::_resolve_provider:
- Anthropic (Claude) — used if
ANTHROPIC_API_KEYis set (andLLM_PROVIDERisn't forcing something else). The message plus recent conversation history is sent to the Claude API via theanthropicSDK. - Groq — used if
GROQ_API_KEYis set and Anthropic isn't. Uses thegroqSDK's OpenAI-compatible chat completions endpoint, with the same system prompt. - Rule-based fallback (no API key required) — a set of conservative
regex/keyword heuristics (
agent/intent.py::parse_intent_rules) handles the same intents, so the whole app is fully runnable and testable with zero external API calls or cost.
Both LLM paths use the exact same JSON-only system prompt
(agent/prompts.py) and schema, so switching providers doesn't change any
downstream logic in agent/chatbot.py — only how the intent JSON gets
produced. Confirmation detection (yes/no) always uses simple rule matching
regardless of mode, since it must be fast and unambiguous.
Provider selection is controlled via environment variables:
| Variable | Purpose |
|---|---|
ANTHROPIC_API_KEY |
Enables the Anthropic path |
GROQ_API_KEY |
Enables the Groq path |
LLM_PROVIDER |
Force "anthropic" or "groq" explicitly. If unset, auto-detects: Anthropic preferred, then Groq, then rule-based. |
NOTE_AGENT_MODEL |
Override the model name for whichever provider is active (defaults: claude-sonnet-4-6 / llama-3.3-70b-versatile) |
If a call to either provider fails for any reason (bad key, network error, malformed response), the app transparently falls back to the rule-based parser for that message rather than crashing. The sidebar always shows which mode is currently active.
User (Streamlit chat)
│
▼
NoteChatbot (agent/chatbot.py) ── orchestrates the conversation
│
├── agent/intent.py ── NL → structured intent (LLM or rules)
├── agent/memory.py ── in-memory dialogue/session state
├── agent/confirmation.py ── yes/no confirmation detection
│
▼
services/notes.py ── business logic (create/update/delete, formatting)
services/search.py ── keyword / tag / category / date / NL search
services/parser.py ── date & title parsing helpers
│
▼
database/crud.py ── CRUD functions over SQLAlchemy sessions
database/models.py ── Note ORM model
database/database.py── SQLite engine/session setup
│
▼
data/notes.db (persisted SQLite file)
Key design decision: conversational/session state (what note "that" or
"the second one" refers to, pending confirmations, chat transcript) is kept
entirely in memory (agent/memory.ConversationMemory), separate from the
persistent notes database. This keeps the SQLite schema clean and means
restarting the app resets dialogue context without touching your notes.
note_agent/
│
├── app.py # Application entry point (run with `streamlit run app.py`)
├── requirements.txt
├── README.md
│
├── database/
│ ├── database.py # SQLAlchemy engine / session / init_db()
│ ├── models.py # Note ORM model
│ └── crud.py # create/read/update/delete helpers
│
├── agent/
│ ├── chatbot.py # Conversation orchestrator (the "brain")
│ ├── intent.py # NL → structured intent (Claude API + fallback)
│ ├── memory.py # ConversationMemory / pending confirmation & disambiguation
│ ├── confirmation.py # Yes/No confirmation detection
│ └── prompts.py # System prompt for the LLM intent parser
│
├── services/
│ ├── search.py # Keyword / tag / category / date / NL search
│ ├── notes.py # Note business logic + display formatting
│ └── parser.py # Relative-date parsing, hashtag extraction, auto-titles
│
├── ui/
│ └── app.py # Streamlit chat UI
│
└── data/
└── notes.db # SQLite database file (created automatically)
- Python 3.12+
streamlit— chat UIsqlalchemy— ORM over SQLiteanthropic— Claude API client (optional; only used if the Anthropic provider is active)groq— Groq API client (optional; only used if the Groq provider is active)python-dateutil— flexible date parsing for searchpython-dotenv— loads variables from a.envfile automatically at startup
# 1. Clone the project
git clone <your-repo-url>
cd note_agent
# 2. Create and activate a virtual environment (on Windows)
python -m venv .venv
.venv\Scripts\activate
# 3. Install dependencies
pip install -r requirements.txt
# 4. (Optional) Enable LLM-powered natural language understanding
cp .env.example .env
# Edit .env and set EITHER:
# ANTHROPIC_API_KEY=sk-ant-... (uses Claude)
# OR
# GROQ_API_KEY=gsk_... (uses Groq, e.g. Llama 3.3 70B)
#
# If you set both, ANTHROPIC_API_KEY wins unless you also set
# LLM_PROVIDER=groq to force Groq. If neither key is set, the app
# automatically uses the built-in rule-based parser, so you can run and
# test everything with zero API keys and zero cost.app.py loads .env automatically at startup via python-dotenv, so you
don't need to export anything manually in your shell — just edit .env
and restart the app. (You can still use export ANTHROPIC_API_KEY=... /
export GROQ_API_KEY=... directly in your shell if you prefer; both work.)
streamlit run app.pyThis opens the chat UI in your browser (typically http://localhost:8501).
The SQLite database is created automatically on first run at
data/notes.db — no manual setup needed.
- On startup,
database/database.init_db()creates thenotestable if it doesn't already exist, using SQLAlchemy's declarative models (database/models.py). - Every note operation runs inside a transactional session
(
database.database.get_session()), which commits on success and rolls back automatically on any error. - The database file lives at
data/notes.dbby default; override the location with theNOTE_AGENT_DB_PATHenvironment variable (useful for tests — see below). - Tags are stored as a comma-separated string column for simplicity (SQLite
has no native array type) and exposed to the rest of the app as a Python
list[str]via a model property.
A few things worth knowing about how notes are actually stored and searched:
- Timestamps:
created_atis set once, at creation.updated_atstaysnulluntil the note is genuinely edited for the first time -- the UI only shows "Updated: ..." once that's happened; otherwise it shows "Created: ... (never edited)". - Content formatting: the assistant is instructed to store clean,
well-formed note content -- not the user's raw command sentence -- and to
format comma/
and-separated lists as Markdown bullet points rather than one run-on sentence. A regex-based safety net applies the same cleanup when running in rule-based (no-API-key) mode. - Auto-tagging: if you don't give explicit tags/category, the LLM (when configured) infers 1-3 relevant tags and a category from the note's content itself, rather than leaving them empty. This only happens in LLM-powered mode -- the rule-based fallback doesn't attempt to invent tags.
- Date-based search ("what did I create today", "notes from last
week") filters by the note's most recent activity (
updated_atif edited, elsecreated_at) and is no longer confused by generic query words like "create" or "today" being treated as literal content keywords. - Semantic search: when an LLM provider is configured, a query that
doesn't literally share words with a note's content (e.g. "login security
notes" vs. a note titled "OAuth Authentication") is re-ranked by the LLM
for topical relevance, not just keyword overlap. See
services/search.py::_llm_semantic_search. Falls back to keyword-only search with no provider configured. - Targeted edits: "replace X with Y" performs a precise, in-place text substitution on the note body, preserving everything else -- it does not overwrite the whole note (that's what full-content confirmation prompts are for). If X isn't found in the note, nothing is changed and you're shown the note's current content instead.
- Undo: every create/update/delete pushes an entry onto an in-memory
undo stack (
ConversationMemory.action_history). Saying "undo" (or "revert") pops the most recent entry and reverses it: a create is undone by deleting the note, an update is undone by restoring the prior content, and a delete is undone by recreating the note (as a new row, since SQLite auto-increment ids aren't reused). Saying "undo" while a confirmation prompt is pending cancels that pending action instead.
You: Create a note about Docker deployment
Bot: Done. I created **Docker deployment**.
You: Add a note titled "Grocery List" with milk, eggs, and bread, tag it #errands
Bot: Done. I created **Grocery List**.
You: Show my meeting notes
Bot: I found 2 matching note(s):
1. **Team Standup** (meetings) [weekly]
2. **Client Kickoff** (meetings)
You: What did I write about APIs last week?
Bot: I found 1 matching note(s):
1. **API Rate Limiting Notes**
You: Rename "Docker deployment" to "Docker Production Deployment"
Bot: Updated **Docker Production Deployment**.
You: Actually, add Kubernetes too
Bot: Updated **Docker Production Deployment**.
(The assistant resolves "too" and "it" to the note just discussed via conversational memory — no need to repeat the note's name.)
You: Delete my standup note
Bot: You're about to permanently delete **Team Standup**.
Type **Yes** to confirm or **No** to cancel.
You: Yes
Bot: Deleted **Team Standup**.
You: Delete my API note
Bot: I found 2 matching notes. Which one would you like to delete?
1. **API Design Notes**
2. **API Testing Checklist**
You: 2
Bot: You're about to permanently delete **API Testing Checklist**.
Type **Yes** to confirm or **No** to cancel.
You: Yes
Bot: Deleted **API Testing Checklist**.
You: Create a note about Docker deployment
Bot: Done. I created **Docker deployment**.
You: Actually, add Kubernetes too
Bot: Updated **Docker deployment**.
You: Summarize it
Bot: **Docker deployment**
Create a note about Docker deployment
Kubernetes too
_Category: none | Tags: none | Updated: 2026-08-01 10:15_
You: Delete it
Bot: You're about to permanently delete **Docker deployment**.
Type **Yes** to confirm or **No** to cancel.
You: No
Bot: Okay, I cancelled that action. Nothing was changed.
- No hallucination: if a search or reference resolves to zero notes, the assistant says so explicitly instead of inventing a note or result.
- Human-in-the-loop confirmation: deletions and any update that would replace the bulk of a note's content require an explicit "Yes"/"No" before anything happens. Appending content ("add X too") or simple renames/tag edits are treated as low-risk and applied directly.
- Disambiguation over guessing: whenever a request could refer to more than one note, the assistant lists the candidates and asks the user to pick, instead of assuming.
You can exercise the chatbot logic directly in Python without the UI:
from database.database import init_db
from agent.chatbot import NoteChatbot
from agent.memory import ConversationMemory
init_db()
bot = NoteChatbot(ConversationMemory())
print(bot.handle_message("Create a note about Docker deployment"))
print(bot.handle_message("Actually, add Kubernetes too"))
print(bot.handle_message("Show my notes about docker"))