Using NotebookLM from Python
The NotebookLM automation projects that work tend to be written in Python,
and ours is not — it is TypeScript. That turns out not to matter, because the
thing you integrate against is an HTTP endpoint on localhost, and Python
talks to those perfectly well.
There is no official SDK for the consumer product. What there is: a local
server you run, and ordinary requests calls into it.
A note on the name. Google renamed NotebookLM to Gemini Notebook in July 2026. It is the same product, old links redirect, and everything below applies to both — the two names are used interchangeably here because that is how people still search for it.
What does the minimal call look like?
Start the server once, then:
import requests
BASE = "http://localhost:3000"
HEAD = {"X-API-Key": "your-key"} # only needed if you set NOTEBOOKLM_API_KEY
r = requests.post(f"{BASE}/ask", headers=HEAD, json={
"question": "What does chapter 3 conclude about sampling bias?",
"notebook_id": "your-notebook-id",
"source_format": "json",
})
data = r.json()["data"]
print(data["answer"])
for c in data["sources"]["citations"]:
print(f'{c["marker"]} {c["sourceText"][:120]}')
Every route answers in the same envelope — {"success": ..., "data": ...} on
the way out, {"success": false, "error": "..."} when something went wrong —
so one helper around requests covers all 56 of them.
source_format: "json" is the part worth remembering. Without it you get the
answer as prose with citation markers in it; with it you get the source names
and the cited passages as data you can file, diff, or put in a table. The web
app exports none of that — how that extraction works.
How do I get evidence without spending an answer?
This is the call most people miss. Asking a question costs compute; retrieving passages does not generate anything.
r = requests.post(f"{BASE}/notebooks/{nb}/search", headers=HEAD, json={
"query": "sampling bias",
"limit": 5,
})
for p in r.json()["data"]["passages"]:
print(p["rank"], p["sourceId"], "—", p["text"][:160])
Two properties of that reply are easy to misread. Rank is global across the
whole notebook while the server groups the reply by source, so the order on
the wire is not the ranking — we sort before applying limit, but if you
decode the RPC yourself, do not take the first N off the wire. And a passage
that starts at offset zero arrives with the slot absent rather than set to
0, so start and end are null when the server did not give them;
guessing a zero is how a quote ends up attributed to the top of a long
document.
When what you actually want is the evidence, and the reasoning is going to happen in your own code or your own model, ask for the passages rather than for prose. On a free account metered by a rolling compute allowance, that is the difference between a pipeline that finishes and one that stalls halfway.
How do I run a few hundred questions overnight?
Not with a for loop around /ask, or at least not naively. Two things bite:
the allowance, and the fact that you will want the results on disk in a form
you can search later.
r = requests.post(f"{BASE}/batch-to-vault", headers=HEAD, json={
"notebook_id": nb,
"questions": ["...", "...", "..."],
"vault_dir": "/home/me/research/vault",
"slug_prefix": "thesis",
"sleep_between_ms": 2000,
})
Each answer lands as markdown with a JSON sidecar carrying the citations, in a documented schema. The pattern that pays is answer once, then store — the expensive step is NotebookLM, so re-asking the same question on every run spends your allowance for nothing. The overnight batch guide covers the rotation and auto-reauth side.
What about audio overviews and the rest of Studio?
Same shape, one call:
requests.post(f"{BASE}/content/generate", headers=HEAD, json={
"notebook_url": nb_url, # this route takes the URL, not the id
"content_type": "audio_overview", # or report, video, infographic,
"language": "en", # presentation, data_table
})
Generation is asynchronous on NotebookLM's side; poll GET /content for the
status and the download URL. A failed generation keeps its id, so
POST /content/{id}/retry re-queues it in place rather than minting a new one
your poll loop has never heard of.
Should this have been written in Python?
Honest answer: the browser fallback is why it is not. The RPC path handles
nearly everything now, but login, re-authentication and the fallback transport
drive a real Chromium, and the mature driver for that is Node's. Rewriting in
Python would mean reimplementing the half that is hardest to test, to gain an
import statement that requests already replaces.
So the split is deliberate: the engine is Node, the integration surface is HTTP, and your pipeline stays in whatever language it was already in.
When is this the wrong approach?
- When you need millisecond latency. An answer takes seconds. Do not put it in a request path a user is waiting on.
- When you would expose the server publicly to reach it. It binds loopback and refuses a wider address without a key, deliberately. Move the consumer, not the server. See securing the API.
- When a plain Gemini API call would do. If you do not need grounding in your own documents, none of this earns its complexity.
- When you need an official, supported contract. This drives the same undocumented interfaces the web app uses. They move; we fix. That is a different risk profile from a GA API — what Google actually publishes.
Trying this one
git clone https://github.com/roomi-fields/notebooklm-mcp.git
cd notebooklm-mcp && npm install && npm run build
npx -y -p @roomi-fields/notebooklm-mcp notebooklm-mcp-setup-auth
HTTP_PORT=3000 node dist/http-wrapper.js
Node 22 or newer for the server; any Python for the client. All 56 routes are in the API reference, and the OpenAPI 3.1 specification is published if you would rather generate a client than write one.
