Lesson 2: The running project: notes, a store and a model
Module 1 · Lesson 2 · About 40 minutes · Needs: basic Python (functions, classes, dictionaries); Lesson 1 helps but is not required
The situation
It is Monday 7 September 2026, the first day of the project. Nare wants every AI application she uses to answer one question from her notes: "How many participants will the nap study have?" The answer is in her lab sync note from 2 September: 24, up from 16. Before any protocol, you need three plain things: the notes folder, code that reads and searches it, and one way to reach a language model. This lesson builds all three, then answers the question the old way, with native function calling and no MCP, so that Lesson 3 can show exactly what MCP changes.
A real case, first
LiveKit makes an open-source framework for voice agents. On 19 December 2025 a user opened issue #4334 on its GitHub repository, "Google Realtime API fails with MCP tools due to $schema field in parameters" (livekit/agents #4334). Their MCP server described its tools in JSON Schema, and that schema included a $schema field. The framework cleaned schemas up for Gemini only for one kind of tool, not the kind MCP tools used. Gemini rejected the tools, and the agent failed to speak.
A contributor posted a patch that stripped the field and quoted the error, "Extra inputs are not permitted". On 23 December a maintainer replied that pull request #4344 switched to always using parameters_json_schema. The issue was closed on 24 June 2026. The lesson: a tool description one provider accepts, another can refuse, and the code that talks to providers is where that shows up.
What you will be able to do
- Set up the
notes-assistantpackage in a notebook and write Nare's eight notes. - Build
NoteStore, the notes logic, with no MCP in it, and explain why it is kept apart. - Reach Groq, Gemini or Ollama through one function,
chat(), that never crashes on a refused reply. - Run one round of native function calling, and say what is tied to your application.
- Name two search methods stronger than keyword counting, and when they are worth it.
1. Setting up notes-assistant in a notebook (Essential)
Every later module imports the files you write today, so where they live matters. The project is one repository, notes-assistant/, holding one Python package, notes_assistant/.
This lesson writes the package files into the notebook's folder (/content in Colab); the Module 1 lab saves the same files into your Google Drive copy, so they survive a runtime reset. First, the standard install and setup cells.
%pip install -q -U langchain langgraph langchain-groq langchain-google-genai langchain-ollamaimport os
from langchain.chat_models import init_chat_model
PROVIDER = "groq" # "groq" or "gemini" in Colab; "ollama" on your own computer
MODELS = {"groq": "groq:openai/gpt-oss-20b", "gemini": "google_genai:gemini-3.5-flash-lite", "ollama": "ollama:llama3.2:3b"}
KEY = {"groq": "GROQ_API_KEY", "gemini": "GOOGLE_API_KEY", "ollama": None}[PROVIDER]
if KEY and KEY not in os.environ:
from google.colab import userdata # keys live in Colab's Secrets panel
os.environ[KEY] = userdata.get(KEY)
llm = init_chat_model(MODELS[PROVIDER], temperature=0, max_retries=6)Code walkthrough
PROVIDERis the one word you change;MODELSholds the model names (checked October 2026).- The
ifblock copies your key from Colab's Secrets panel into an environment variable.init_chat_modelbuildsllm; this lesson'schat()helper builds its own the same way.
from pathlib import Path
os.environ["LLM_PROVIDER"] = PROVIDER # the package reads the provider from here
Path("notes_assistant").mkdir(exist_ok=True)
Path("notes_assistant/__init__.py").write_text('"""Nare\'s notes assistant."""\n')
print(sorted(p.name for p in Path("notes_assistant").iterdir()))Code walkthrough
LLM_PROVIDERpasses your choice tonotes_assistant/llm.py, so the package never needs editing.- A folder with an
__init__.pyfile is a package, something Python canimport. The output is['__init__.py'].
2. Nare's notes folder (Essential)
The notes are the data for the whole course, and they are small on purpose: you can check every answer the assistant gives by reading them yourself.
Each note is one Markdown file named after its note id, such as lab-sync-2026-09-02. It starts with frontmatter, a header between two --- lines holding the title, tags and date; the body follows. In the anchor note the participant count is on the second body line. It matters in section 5.
The next cells are the shared data from course-guide/06, copied exactly, split so no cell is too long.
from pathlib import Path
NOTES = {
"coffee-and-focus": """---
title: Caffeine and focus
tags: [caffeine, focus, sleep]
created: 2026-04-01
---
Caffeine blocks adenosine receptors, so sleep pressure is masked rather than removed.
Half-life is about 5 hours, so a 4 pm coffee still leaves half the dose at 9 pm.
Moderate doses (about 100 mg) improve reaction time; above 400 mg per day anxiety rises.
Personal rule: no coffee after 1 pm on days before an experiment.
""",
"deep-work": """---
title: Deep work blocks
tags: [focus, productivity]
created: 2026-07-08
---
Newport defines deep work as distraction-free concentration that pushes your abilities.
My best writing happens between 8 and 11 am before email.
Tracking: 14 deep hours last week, target is 15.
Phone in another room helps more than any app blocker.
""",
"lab-sync-2026-09-02": """---
title: Lab sync, 2 September 2026
tags: [meeting, sleep]
created: 2026-09-02
---
Attendees: Priya, Tomas, Nare.
Decision: run the nap study with 24 participants instead of 16, budget approved by Priya.
Action: Tomas books the sleep lab for the weeks of 5 and 12 October.
Action: Nare drafts the consent form by 15 September.
Risk: EEG caps are shared with another group, so booking conflicts are likely.
""",
}Code walkthrough
NOTESmaps each note id to the exact text of its file. This cell holds three notes, including the anchor note.
NOTES.update({
"pomodoro-technique": """---
title: Pomodoro technique
tags: [focus, productivity]
created: 2026-04-15
---
Work for 25 minutes, then take a 5 minute break; after four rounds take a longer 20 minute break.
Works well for writing and reading papers, badly for debugging where context switching is costly.
For deep tasks I now use 50 minute blocks with 10 minute breaks instead.
""",
"reading-list": """---
title: Reading list
tags: [reading, sleep, memory]
created: 2026-06-20
---
Why We Sleep, Matthew Walker (2017): readable, but some claims are disputed.
Make It Stick, Brown, Roediger and McDaniel (2014): retrieval practice and spacing.
Deep Work, Cal Newport (2016): scheduling focus time.
Pending: a review paper on targeted memory reactivation during sleep.
""",
"sleep-and-memory": """---
title: Sleep and memory consolidation
tags: [sleep, memory, neuroscience]
created: 2026-03-02
---
Slow-wave sleep replays the day's hippocampal activity and moves it into long-term cortical storage.
Walker (2017) reports that a night without sleep cuts new memory formation by about 40 percent.
A 20 minute nap after learning helps recall of word pairs, but only if the nap reaches stage 2 sleep.
Open question: does REM sleep matter more for procedural skills than for facts?
""",
})Code walkthrough
NOTES.update(...)adds three more notes to the same dictionary.
NOTES.update({
"spaced-repetition": """---
title: Spaced repetition
tags: [memory, learning]
created: 2026-03-10
---
Reviewing material at growing intervals (1 day, 3 days, 7 days, 21 days) beats massed study.
The forgetting curve from Ebbinghaus drops fastest in the first 24 hours after learning.
Anki uses the SM-2 algorithm; FSRS is a newer scheduler that predicts recall probability.
Combine with sleep: a review before bed seems to consolidate better than a morning review.
""",
"zettelkasten": """---
title: Zettelkasten note-taking
tags: [notes, productivity, learning]
created: 2026-05-05
---
One idea per note, written in your own words, linked to related notes by id.
Links matter more than folders: a note should be findable from several directions.
Luhmann kept about 90,000 index cards and cross-referenced them by hand.
This notes folder follows the same rule: one idea per Markdown file.
""",
})Code walkthrough
- The last two notes. The dictionary now holds all eight.
def write_notes(folder="notes", notes=NOTES):
"""Write every note to <folder>/<note_id>.md and return the folder."""
root = Path(folder)
root.mkdir(parents=True, exist_ok=True)
for note_id, text in notes.items():
(root / f"{note_id}.md").write_text(text, encoding="utf-8")
return root
root = write_notes()
print(len(list(root.glob("*.md"))), "notes in", root)
body = NOTES["lab-sync-2026-09-02"].split("---\n")[2].splitlines()
print("body line 2:", body[1])
print(sum(len(t.split("---\n")[2].split()) for t in NOTES.values()), "words of body text")Code walkthrough
write_noteswrites one<note_id>.mdfile per note; running it again restores them.split("---\n")[2]keeps the body;[1]is its second line.- The output is
8 notes in notes, thenbody line 2: Decision: run the nap study with 24 participants instead of 16, budget approved by Priya., then416 words of body text.
3. NoteStore: business logic with no MCP in it (Essential)
This is the project's most important design decision. The code that knows about notes knows nothing about MCP, so it can be tested without a protocol and stays still while the protocol changes.
The business logic, the rules about notes (what an id may be, how search scores, that nothing is overwritten), lives in store.py. The server and host are thin layers that call it, and store.py never imports mcp.
NoteStore has four methods: list_notes and get read, search ranks, create writes. The file is written in five cells; %%writefile -a appends instead of replacing.
%%writefile notes_assistant/store.py
"""Nare's notes as a folder of Markdown files. This file knows nothing about MCP."""
from __future__ import annotations
import re
from dataclasses import dataclass
from datetime import date
from pathlib import Path
NOTE_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
WORD_PATTERN = re.compile(r"[a-z0-9]+")
MAX_TITLE_LENGTH = 120
MAX_BODY_LENGTH = 20_000
class NoteError(Exception):
"""Base class for every error the store raises on purpose."""
class NoteNotFound(NoteError):
"""No note has this id."""
class NoteExists(NoteError):
"""A note with this id already exists. The store never overwrites."""
class InvalidNote(NoteError):
"""An id, title, body or query the store refuses."""Code walkthrough
%%writefilewrites the cell to the file instead of running it.NOTE_ID_PATTERNallows 1 to 64 lowercase letters, digits and hyphens.WORD_PATTERNdefines a search word. The limits cap titles at 120 characters and bodies at 20,000.- Four error classes, all children of
NoteError.
Every refusal is a NoteError. Later layers catch exactly these and turn them into messages a model can act on, while a real bug still surfaces as a bug.
%%writefile -a notes_assistant/store.py
@dataclass(frozen=True)
class Note:
note_id: str
title: str
tags: tuple[str, ...]
created: str
body: str
def snippet(self, length: int = 120) -> str:
"""The first line of the body, cut to `length` characters."""
first = self.body.splitlines()[0] if self.body else ""
return first if len(first) <= length else first[: length - 3] + "..."
@dataclass(frozen=True)
class SearchHit:
note_id: str
title: str
score: int
snippet: str
tags: tuple[str, ...] = ()
def slugify(title: str) -> str:
"""'Nap study: consent form!' -> 'nap-study-consent-form'."""
return "-".join(WORD_PATTERN.findall(title.lower()))[:64].strip("-")
def validate_note_id(note_id: str) -> str:
"""Refuse any id that is not a simple slug, before a file is touched."""
if not NOTE_ID_PATTERN.fullmatch(note_id):
raise InvalidNote(f"Invalid note id {note_id!r}. Use lowercase letters, digits "
"and hyphens, for example 'sleep-and-memory'.")
return note_idCode walkthrough
@dataclass(frozen=True)makes a record that cannot change once built.Note.snippetreturns the body's first line only, andSearchHitcarries that, which keeps results small.slugifylowercases a title and joins its words with hyphens.validate_note_idraisesInvalidNotewith a message saying what a valid id looks like.
That check guards against path traversal: an id such as ../secrets that would climb out of the folder if it became a path. It fails the pattern, so no file is opened.
%%writefile -a notes_assistant/store.py
def _parse(note_id: str, text: str) -> Note:
"""Split a file into its frontmatter fields and its body."""
meta, body = {}, text
if text.startswith("---\n"):
header, _, body = text[4:].partition("\n---\n")
for line in header.splitlines():
key, _, value = line.partition(":")
meta[key.strip()] = value.strip()
tags = tuple(t.strip() for t in meta.get("tags", "").strip("[]").split(",") if t.strip())
return Note(note_id, meta.get("title", note_id), tags, meta.get("created", ""), body.strip())
class NoteStore:
"""A folder of Markdown notes, one file per note, named <note_id>.md."""
def __init__(self, root: Path | str) -> None:
self.root = Path(root)
if not self.root.is_dir():
raise NoteError(f"Notes folder not found: {self.root}")
def _path(self, note_id: str) -> Path:
return self.root / f"{validate_note_id(note_id)}.md" # the only place an id becomes a path
def list_notes(self) -> list[Note]:
return [self.get(p.stem) for p in sorted(self.root.glob("*.md"))]
def get(self, note_id: str) -> Note:
path = self._path(note_id)
if not path.is_file():
raise NoteNotFound(f"No note with id {note_id!r}.")
return _parse(note_id, path.read_text(encoding="utf-8"))Code walkthrough
_parsesplits the file at the second---line, readskey: valuepairs and turns[meeting, sleep]into a tuple.__init__fails at once if the folder is missing._pathis the only place an id becomes a path, and it validates first.list_notessorts by id, so every machine sees the same order;getraisesNoteNotFoundfor a missing note.
Search is keyword scoring, fixed for the course: each query word scores 1 per appearance in the body, 3 in the title and 2 in the tags. For "nap study participants" the anchor note scores 3.
%%writefile -a notes_assistant/store.py
def search(self, query: str, limit: int = 5, tag: str | None = None) -> list[SearchHit]:
"""Rank notes by query words: 1 per body match, 3 per title match, 2 per tag match."""
terms = WORD_PATTERN.findall(query.lower())
if not terms:
raise InvalidNote("The query must contain at least one letter or digit.")
hits = []
for note in self.list_notes():
if tag and tag not in note.tags:
continue
body = WORD_PATTERN.findall(note.body.lower())
title = WORD_PATTERN.findall(note.title.lower())
tags = [t.lower() for t in note.tags]
score = sum(body.count(t) + 3 * title.count(t) + 2 * tags.count(t) for t in terms)
if score > 0:
hits.append(SearchHit(note.note_id, note.title, score, note.snippet(), note.tags))
hits.sort(key=lambda h: (-h.score, h.note_id)) # ties broken by note id
return hits[:limit]Code walkthrough
- The method is indented: it continues the
NoteStoreclass. An empty query raisesInvalidNote. tagfilters notes first; the score line applies the three weights. Sorting by minus the score, then the id, breaks ties the same way every time.
create writes a new note and never overwrites one. The date is an argument, not the computer's clock, so everyone gets the same file.
%%writefile -a notes_assistant/store.py
def create(self, title: str, body: str, tags=(), *, created: date) -> Note:
"""Write a new note stamped with `created`. Never overwrites an existing note."""
title = title.strip()
if not 1 <= len(title) <= MAX_TITLE_LENGTH:
raise InvalidNote(f"The title must be 1 to {MAX_TITLE_LENGTH} characters.")
if not body.strip() or len(body) > MAX_BODY_LENGTH:
raise InvalidNote(f"The body must be 1 to {MAX_BODY_LENGTH:,} characters.")
note_id = slugify(title)
if not note_id:
raise InvalidNote("The title must contain at least one letter or digit.")
path = self._path(note_id)
if path.exists():
raise NoteExists(f"A note with id {note_id!r} already exists.")
clean_tags = [slugify(t) for t in tags if slugify(t)]
header = f"---\ntitle: {title}\ntags: [{', '.join(clean_tags)}]\ncreated: {created.isoformat()}\n---\n"
path.write_text(header + body.strip() + "\n", encoding="utf-8")
return self.get(note_id)Code walkthrough
- The
*makescreatedkeyword-only, so the date is never passed by accident. The id comes fromslugify(title). path.exists()is the no-overwrite rule: an existing id raisesNoteExists. The new note is read back withget.
from datetime import date
from notes_assistant.store import NoteStore, NoteError
store = NoteStore("notes")
for hit in store.search("nap study participants"):
print(f"{hit.score} {hit.note_id:<20} {hit.snippet}")
for bad in ["../secrets", "no-such-note"]:
try:
store.get(bad)
except NoteError as err:
print(type(err).__name__, "|", err)
import shutil
shutil.rmtree("scratch-notes", ignore_errors=True) # start the copy afresh on every run
scratch = NoteStore(write_notes("scratch-notes")) # writes go to a copy, never notes/
note = scratch.create("Nap study consent form", "Draft due 15 September.", ["Meeting"], created=date(2026, 9, 15))
print("created", note.note_id, note.tags, note.created)
try:
scratch.create("Nap study: consent form!", "Second try.", created=date(2026, 9, 15))
except NoteError as err:
print(type(err).__name__, "|", err)Code walkthrough
- The search prints
3 lab-sync-2026-09-02,2 sleep-and-memoryand1 spaced-repetition.../secretsraisesInvalidNote;no-such-noteraisesNoteNotFound. shutil.rmtreeandwrite_notes("scratch-notes")make a fresh copy, so writes never touchnotes/. The note is stamped 15 September, the day Nare saves her consent form draft.- The output is
created nap-study-consent-form ('meeting',) 2026-09-15, thenNoteExists: the second title slugifies to the same id.
Break it: ask the store to climb out
Change "../secrets" to "lab-sync-2026-09-02/../../secrets" and run the cell again. It still raises InvalidNote: / and . are not in the pattern. Without validate_note_id in _path, Python would open whatever file the id pointed at.
4. The chat() helper: one function, three providers (Essential)
The host you build in Module 6 needs a language model, and every learner may have a different one. chat() hides the provider behind one function, so nothing else in the package ever names Groq, Gemini or Ollama.
chat() uses LangChain's init_chat_model, which reads a string such as groq:openai/gpt-oss-20b and builds the matching connector, the code that speaks that provider's format. The provider comes from one environment variable, LLM_PROVIDER.
Whatever the provider, the helper returns one record, a ChatReply: text, a list of ToolCalls, or an error reason.
%%writefile notes_assistant/llm.py
"""chat(): one function for Groq, Gemini and Ollama, built on LangChain."""
from __future__ import annotations
import json
import os
from dataclasses import dataclass, field
from typing import Any
from langchain.chat_models import init_chat_model
MODELS = {"groq": "groq:openai/gpt-oss-20b", "gemini": "google_genai:gemini-3.5-flash-lite", "ollama": "ollama:llama3.2:3b"}
KEYS = {"groq": "GROQ_API_KEY", "gemini": "GOOGLE_API_KEY", "ollama": None}
LLM_PROVIDER = os.environ.get("LLM_PROVIDER", "groq").lower()
LLM_MODEL = os.environ.get("LLM_MODEL") or MODELS.get(LLM_PROVIDER, "")
@dataclass
class ToolCall:
id: str
name: str
arguments: dict[str, Any]
@dataclass
class ChatReply:
content: str | None
tool_calls: list[ToolCall] = field(default_factory=list)
error: str | None = None # "provider_error: ..." after two refusals
def as_message(self) -> dict[str, Any]:
"""This reply as an assistant message, to add to the conversation."""
message = {"role": "assistant", "content": self.content or ""}
if self.tool_calls:
message["tool_calls"] = [{"id": c.id, "type": "function", "function": {
"name": c.name, "arguments": json.dumps(c.arguments)}} for c in self.tool_calls]
return messageCode walkthrough
LLM_PROVIDERdefaults togroq;LLM_MODELoverrides the model name.ToolCallis one request to run a tool: an id to match the result back, a name and arguments.ChatReply.erroris this course's field for a refused reply.as_messageturns the reply back into a history message, because the next request must include what the model asked for.
Providers sometimes refuse the model's own reply, for example when it names a tool it was never given. Course-guide/02 sets the rule: try once more, then return a clear "no answer" with the reason provider_error.
%%writefile -a notes_assistant/llm.py
_CACHE: dict[str, Any] = {}
def _model(provider: str):
"""Build the LangChain model for a provider once, then reuse it."""
if provider not in MODELS:
raise ValueError(f"Unknown LLM_PROVIDER {provider!r}. Choose one of {sorted(MODELS)}.")
key = KEYS[provider]
if key and not os.environ.get(key):
raise RuntimeError(f"Set {key} to use {provider}.")
if provider not in _CACHE:
name = LLM_MODEL if provider == LLM_PROVIDER else MODELS[provider]
_CACHE[provider] = init_chat_model(name, temperature=0, max_retries=6)
return _CACHE[provider]
def chat(messages: list[dict[str, Any]], tools: list[dict] | None = None,
provider: str | None = None) -> ChatReply:
"""Send messages and optional tools; return text or tool calls. Retries a refusal once."""
model = _model((provider or LLM_PROVIDER).lower())
if tools:
model = model.bind_tools(tools)
for attempt in range(2):
try:
message = model.invoke(messages)
break
except Exception as err:
last = err
else:
return ChatReply(content=None, error=f"provider_error: {type(last).__name__}")
calls = [ToolCall(c["id"] or f"call_{i}", c["name"], c["args"]) for i, c in enumerate(message.tool_calls)]
return ChatReply(content=message.text or None, tool_calls=calls)Code walkthrough
_modelraises for an unknown provider or a missing key, naming what to set: those are setup mistakes._CACHEkeeps one model per provider.bind_tools(tools)offers the tools; each connector translates them for its provider.- The
for ... elserunsinvokeat most twice; theelsebranch runs only if neither attempt reachedbreak. message.tool_callsholds each requested call'sname,argsandid; they becomeToolCalls.
5. Native function calling, no MCP (Essential)
Before MCP, this is how a model used a tool, and many applications still work this way. Building it by hand shows which parts MCP later moves out of your application.
In native function calling you describe the tool inside the model request, its arguments written in JSON Schema. The model reads the description and may ask for the tool. It never runs anything itself.
import json
from notes_assistant.llm import ToolCall, chat
SEARCH_TOOL = {
"type": "function",
"function": {
"name": "search_notes",
"description": "Search the user's research notes by keyword and return the best matching notes.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Words to look for, for example 'sleep memory'."},
"limit": {"type": "integer", "description": "Maximum number of notes to return.", "default": 5},
},
"required": ["query"],
},
},
}Code walkthrough
type: "function"with afunctioninside is the OpenAI-style format all three connectors accept.parameterssaysqueryis a required string,limitan optional integer.- Nothing checks this dictionary against
store.search. Add a store parameter and forget it here, and the model never learns of it.
When the model asks for a tool, your code runs it. The function that maps a tool name to real code is the dispatcher.
def run_tool(call: ToolCall) -> str:
"""Map the tool the model chose to Python code; return text for the model."""
if call.name != "search_notes":
return f"Unknown tool {call.name!r}"
try:
hits = store.search(call.arguments["query"], limit=call.arguments.get("limit", 5))
except NoteError as err:
return f"Error: {err}"
return json.dumps([{"note_id": h.note_id, "title": h.title, "snippet": h.snippet} for h in hits])
result = run_tool(ToolCall("demo", "search_notes", {"query": "nap study participants"}))
print(len(result), "characters")
print(json.loads(result)[0])
print("contains 24:", "24" in result)Code walkthrough
- An unknown tool name or a
NoteErrorbecomes text the model can read, not an exception. The hits become JSON text, because a tool result is text. - The output is
482 characters, then the first hit (lab-sync-2026-09-02with the snippetAttendees: Priya, Tomas, Nare.), thencontains 24: False.
Break it: the answer is on line 2
The anchor note ranks first, but its snippet is the first body line, the attendees. The 24 is on line 2, so no model can read it from this result: a careful one says so, a careless one guesses. Module 4 lets the model open a whole note.
Now one round with a real model. If it asks for a search, your code runs it, adds the request and result to the conversation, and asks again.
messages = [{"role": "user", "content": "How many participants will the nap study have?"}]
reply = chat(messages, tools=[SEARCH_TOOL])
if reply.tool_calls:
messages.append(reply.as_message())
for call in reply.tool_calls:
print("model asked for:", call.name, call.arguments)
messages.append({"role": "tool", "tool_call_id": call.id, "content": run_tool(call)})
reply = chat(messages, tools=[SEARCH_TOOL])
if reply.error:
print("no answer:", reply.error)
else:
print("answer:", reply.content or "(empty reply)")Code walkthrough
- If the reply holds tool calls, the assistant message goes into the history once, then one
toolmessage per call, matched bytool_call_id. The second call lets the model answer. - A refused reply prints
no answer: provider_error: ...and the cell ends cleanly.
Read the answer against the tool result. If it says 24, ask where that came from, because the search result did not contain it.
Count what is tied to this notebook: the schema and the dispatcher. A second AI application wanting the same search must copy both. That is Lesson 1's N times M problem in miniature; Lesson 3 moves both into an MCP server.
In production
LiveKit's bug sat in the layer chat() hands to LangChain: turning one tool definition into each provider's format. The fix changed how the framework passed schemas to Gemini, not the tools. Keep one definition and test it on every provider you support.
Model names change too. Groq's deprecations page says it emailed users on 17 June 2026 that llama-3.3-70b-versatile would be shut down; it was, on 16 August 2026 (checked October 2026). One MODELS table makes that a one-line edit.
6. Search backends beyond keyword scoring (Optional)
Keyword scoring is transparent, so you can check every result by hand. Its limits are worth seeing before you rely on it.
Ask the store Nare's whole question, "how many participants will the nap study have", and words such as "the" add points: spaced-repetition scores 4, three of them from "the". BM25 fixes this by weighting rare words above common ones (the rank_bm25 library implements it). Embeddings compare meaning, so "people in the nap experiment" can match "participants". Module 10 builds a retrieval server with both.
for hit in store.search("how many participants will the nap study have"):
print(hit.score, hit.note_id)Code walkthrough
- The output is
7 lab-sync-2026-09-02,4 sleep-and-memory,4 spaced-repetition,1 coffee-and-focus,1 zettelkasten. The last two score only from "the".
What you added to the notes assistant
notes/: Nare's eight notes, written bywrite_notes.notes_assistant/store.py:NoteStore,Note,SearchHit,slugify,validate_note_idand four errors, with no MCP in it.notes_assistant/llm.py:chat(),ToolCall,ChatReply,LLM_PROVIDERandLLM_MODEL, on top ofinit_chat_model.- A native function-calling baseline:
SEARCH_TOOLandrun_tool, kept in the notebook, not the package.
Check yourself
- Why must
store.pynever importmcp?
So the notes logic can be tested without a protocol and stays unchanged when the protocol or SDK changes.
- What stops
store.get("../secrets")from reading a file outside the folder?
_path calls validate_note_id first; ../secrets fails the pattern, so InvalidNote is raised before any path is built.
- Why can no model answer "how many participants?" correctly from
run_tool's result?
Each hit carries only the body's first line; the count is on line 2.
- What does
chat()return when the provider refuses twice, and why not raise?
A ChatReply with error set to provider_error: <ErrorName>, so one bad reply ends a step cleanly instead of stopping a notebook or a host's loop.
Exercise
Nare wants to search only meeting notes. Write SEARCH_TOOL_V2, a copy of SEARCH_TOOL with an optional string parameter tag, and run_tool_v2(call), which passes tag through to store.search.
import copy
SEARCH_TOOL_V2 = copy.deepcopy(SEARCH_TOOL)
SEARCH_TOOL_V2["function"]["parameters"]["properties"]["tag"] = {
"type": "string", "description": "Only search notes with this tag, for example 'meeting'."}
def run_tool_v2(call: ToolCall) -> str:
"""Like run_tool, with an optional tag filter."""
if call.name != "search_notes":
return f"Unknown tool {call.name!r}"
args = call.arguments
try:
hits = store.search(args["query"], limit=args.get("limit", 5), tag=args.get("tag"))
except NoteError as err:
return f"Error: {err}"
return json.dumps([{"note_id": h.note_id, "title": h.title, "snippet": h.snippet} for h in hits])Code walkthrough: deepcopy copies the nested dictionaries, so SEARCH_TOOL is unchanged; tag is not added to required, so it stays optional.
found = json.loads(run_tool_v2(ToolCall("ex", "search_notes", {"query": "sleep", "tag": "meeting"})))
assert [h["note_id"] for h in found] == ["lab-sync-2026-09-02"], found
assert "tag" not in SEARCH_TOOL["function"]["parameters"]["properties"]
assert "tag" not in SEARCH_TOOL_V2["function"]["parameters"]["required"]
print("exercise passed:", found[0]["note_id"])Code walkthrough: "sleep" with the tag meeting must return only the anchor note; the original tool must be unchanged and tag must be optional.
Key terms
| Term | Meaning |
|---|---|
| Note id | A note's name and file name: 1 to 64 lowercase letters, digits and hyphens |
| Frontmatter | The header holding a note's title, tags and date |
| Path traversal | Using ../ in a name to reach files outside the intended folder |
| Connector | Provider-specific code that turns one request into that provider's format |
ChatReply | What chat() returns: text, tool calls, or a provider_error reason |
| Native function calling | A tool described in the model request and run by your own code |
| Dispatcher | Your code that maps a tool name to the function that runs it |
| BM25 | A keyword ranking that weights rare words more than common ones |
Sources
- muhammadkhalid-03 and others, "Google Realtime API fails with MCP tools due to $schema field in parameters", livekit/agents issue #4334, opened 19 December 2025, closed 24 June 2026. https://github.com/livekit/agents/issues/4334
- Groq, "Deprecations" (the
llama-3.3-70b-versatilerow), fetched 13 October 2026. https://console.groq.com/docs/deprecations - LangChain, "Models" (
init_chat_model,bind_tools,tool_calls), fetched 13 October 2026. https://docs.langchain.com/oss/python/langchain/models