[0.8.0]
The headline of 0.8.0 is integrations: NotebookLM is now reachable from AI
agents and HTTP clients through two new adapters built over the shared _app/
core (ADR-0021) — an MCP server and an experimental single-tenant REST API
server — plus a remote MCP connector you can self-host for claude.ai
(and Claude Desktop / Code / Cursor) behind a Cloudflare Tunnel or Tailscale
Funnel, gated by a single password.
0.8.0 also lands the breaking half of the ADR-0019 error contract (the
#1346 umbrella):
"absence and refusal raise; only success and async-lifecycle state are
returned." Every flip previewed under NOTEBOOKLM_FUTURE_ERRORS in v0.7.0 is
now the default, and the preview flag — together with the dict-subscript /
get-returns-None / kwarg-alias deprecation machinery — has been removed
(#1365). See the Upgrading to v0.8.0 guide and the
Breaking section below.
⚠
NOTEBOOKLM_FUTURE_ERRORSis gone. It was the v0.7.0 forward-compat
preview gate; its target behavior is now unconditional, so the flag is a no-op
(setting it changes nothing). Remove it from your environment / CI config.
Added
-
Experimental: MCP server (#1484, opt-in via the
mcpextra). A
Model Context Protocol server exposing
NotebookLM to MCP clients (Claude Desktop / Code, Cursor, Windsurf) as 28 tools
across notebooks, sources, chat, notes, studio artifacts, and research — built
as a transport-neutral sibling adapter over the_app/layer (ADR-0021), so it
behaves identically to the equivalentnotebooklmCLI command. Run it with the
notebooklm-mcpconsole script (stdio by default, or loopback HTTP via
--transport http); wire it into a client withnotebooklm mcp install <client>or the one-click.mcpbdesktop bundle. Notebook- and source-scoped
tool arguments accept a name or an id; destructive tools require
confirm=true(returning aneeds_confirmationpreview otherwise); long-running
generation is non-blocking (*_generatereturns atask_idto poll via
*_status, then download). The MCP tool surface is experimental and not
covered by the library's semver guarantees — names, parameters, and output
shapes may change between releases.pip install notebooklm-pyis unaffected:
the server and its dependencies (fastmcp) arrive only with themcpextra.
See docs/mcp-guide.md. -
Experimental: remote MCP connector — self-host NotebookLM for claude.ai,
Claude Desktop, and Cursor (#1645, #1647). The MCP server now also runs as a
remote HTTP connector, not just a local stdio process:notebooklm-mcp --transport http
serves the same tools over HTTP behind a bearer token, anddeploy/ships a
one-command Docker stack (make dev) that exposes it through a Cloudflare
Tunnel or a Tailscale Funnel — no public IP, no open port, no TLS cert to
manage. For claude.ai (whose connector UI is OAuth-only and has no bearer
field), the server can run its own tiny OAuth authorization server gated by a
single password (NOTEBOOKLM_MCP_OAUTH_PASSWORD) — no external IdP, no JWT
template; registered clients and tokens persist across restarts. Opt-in and
additive: leave the OAuth vars unset to stay bearer-only (Claude Code / Desktop
unaffected). The server fail-closed refuses to start on a non-loopback bind
with no auth, or on partial / weak / non-HTTPS OAuth config. Single-tenant,
self-hosted, one container per Google account. The remote-deployment surface
is experimental and may change between releases. See
deploy/README.md and
docs/mcp-guide.md. -
Experimental: single-tenant REST API server (#1538, opt-in via the
server
extra; console scriptnotebooklm-server). A FastAPI app exposing guarded
/v1routes — notebooks, sources, chat, notes, studio artifacts, sharing, and
research — over the same transport-neutral_app/core the CLI and MCP server
use (ADR-0021), so behavior matches the equivalent CLI command. Loopback-bound
by default, requiresNOTEBOOKLM_SERVER_TOKEN, and refuses an unauthenticated
non-loopback bind. Native multipart source upload andFileResponseartifact
download (no signed-URL broker needed). Follow-up work closed the remaining
REST↔MCP capability and hardening gaps (#1620 notes CRUD, #1767). The REST
surface is experimental and not covered by the library's semver guarantees.
pip install notebooklm-pyis unaffected — FastAPI / uvicorn arrive only with
theserverextra. See
docs/installation.md#rest-api-server. -
Master-token headless auth (#1638, #1640; ADR-0023; opt-in via the new
headlessextra). NotebookLM's browser cookies are short-lived, and until now
the only way to re-acquire them was a human re-runningnotebooklm loginin a
browser — fatal for long-lived headless / CI / server use. A durable Google
master token (minted once vianotebooklm login --master-token, then stored
0600beside the profile) can now re-mint fresh web cookies on demand with no
per-session browser. Recovery is automatic: when amaster_token.jsonis
present, an expired session re-mints in-process as the final layer of the
refresh ladder (single-flight, so concurrent RPCs coalesce one re-mint) instead
of raising "run 'notebooklm login'".notebooklm auth checksurfaces the
master-token identity and--master-token-refreshre-mints on demand. This is
the foundation that makes the remote MCP connector and unattended deployments
viable. Security: the master token is a full-account, long-lived
credential — store it like a password and prefer a dedicated / throwaway account
for servers. It and its dependency (gpsoauth) arrive only with theheadless
extra. See docs/installation.md and
docs/auth-cookie-lifecycle.md. -
Source labels (#1474). A new
client.labelsnamespace and alabelCLI
command group bring NotebookLM's source-label (tag) feature to the client:
list / create / rename / delete labels and assign or clear them on sources, then
narrow a listing withsource list --label <name>(the MCPsource_listtool
also gained a label filter). Additive new public surface across the Python API
and CLI. -
Suggested prompts (#1612, #1616). New
client.chat.suggest_prompts(...)
returns model-generated starter prompts for a notebook (backed by the
GeneratePromptSuggestionsRPC), surfaced as anotebooklm suggest-promptsCLI
command and, later, an MCPsuggest_promptstool (#1726). Amodeselector
targets the surface the suggestions are for (ask / audio / video / quiz /
flashcards / …). Additive new public surface. -
Opt-in
curl_cffibrowser-impersonation transport (#1632). An alternative
HTTP backend that mimics a real browser's TLS fingerprint, selected via
NOTEBOOKLM_TRANSPORT=curl_cffi(requires thecurl_cffipackage), for
environments where the defaulthttpxtransport is fingerprint-blocked. It sits
behind the existingasync_client_factoryseam; the default transport is
unchanged. -
--jsonon every local CLI command (#1643). The remaining local commands
gained a--jsonflag — now enforced by a coverage guardrail — so any
notebooklmcommand can emit machine-readable output for scripting and
automation. -
MCP soft-404 body-pattern detection. The
source_waitcontent-sanity check
(#1698) now also flags a READY web page that sails past the thin-text threshold
but whose short body matches a dead-link / error-page boilerplate phrase (e.g.
"Whoops! broken link") — a soft-404 that ingests as a full-bodied 200. Body-only
(titles are never scanned), gated to sub-2000-char bodies, advisory-only (never
blocks), zero extra RPC (the body is already fetched). The batch
source_add(urls=[...])now surfaces the same warning per synchronously-ready
web-page item. -
MCP artifact rename & delete tools. Two new MCP tools close the artifact
CRUD gap (the server previously exposed create + read only):artifact_rename
(title-only update) andartifact_delete(destructive, two-stepconfirm).
Both accept a notebook/artifact name or ID, cover every studio artifact
type including both mind-map kinds — note-backed maps route through the note
system, interactive maps and regular artifacts through the artifact RPC — over
the shared kind-aware_app.artifactscores the CLI already uses. The MCP tool
surface is now 28 tools. -
Live-API e2e coverage for the MCP server and the CLI binary. The nightly
E2E job now installs--extra mcp, so the MCP/CLI layers run against the real
NotebookLM API once per release instead of being silentlyimportorskip-ped.
New suites: per-domain MCP tool round-trips plus a 28-tool→test matrix
(tests/e2e/test_mcp.py); the HTTP transport, bearer gate,.well-known
discovery, and signed-URL upload/download routes driven in-process over
httpx.ASGITransport(tests/e2e/test_mcp_http.py); live-only contract
checks — thesource_idsomitted-==-[]-==-all collapse (#1652),
not-found resolution, destructive confirm-gating, and the MCP error shape
(tests/e2e/test_mcp_contracts.py); and a CLI-binary--jsonsmoke including
stdout-purity on a live failure (tests/e2e/test_cli_live.py). A standalone
scripts/mcp_live_smoke.pyruns the upload+download round-trip against a
deployed server (PASS/FAIL) to bootstrap the new per-release manual
"MCP connector smoke" checklist indocs/releasing.md. -
Remote MCP file upload & download (ADR-0024). Over the remote (HTTP)
connector — where the claude.ai browser can't carry the MCP credential and the
JSON-RPC channel can't carry bytes —source_add type=fileand
artifact_downloadnow broker a short-lived HMAC-signed URL served by the
same container, and the browser does the byte transfer out-of-band (the
established remote-MCP pattern; MCP has no native upload primitive and its
native binary-Resource download is capped far below a podcast/video). Upload
accepts a raw body overPOST/PUT(a browser file-picker page or a
code-execution-sandboxcurl), bounded by a 200 MiB per-request cap and an
in-flight-upload limit; download returns a clickableresource_link. Enabled
byNOTEBOOKLM_MCP_PUBLIC_URL(falls back toNOTEBOOKLM_MCP_OAUTH_BASE_URL);
unset → the two tools return a clear "not configured" error and the server
still starts. stdio (local) installs are unchanged — they keep reading and
writing real local paths. The RESTserverextra already supports native
multipart upload +FileResponsedownload and is unaffected. See
docs/mcp-guide.md. -
Retrieve the generation prompt behind an artifact (#1571). New
client.artifacts.get_prompt(notebook_id, artifact_id)returns the free-text
prompt an artifact was generated from, and a matchingartifact get-prompt
CLI command prints it (with--json). Works for every studio artifact type —
audio, report, video, quiz, flashcards, interactive mind map, infographic,
slide deck, and data table — by reading the prompt already present in the
LIST_ARTIFACTSresponse through the newArtifactRow.generation_prompt
accessor (no new RPC). ReturnsNonefor an artifact with no stored prompt
(e.g. a note-backed mind map) and raisesArtifactNotFoundErrorfor an
unknown id. The transport-neutral_app.artifacts.get_artifact_promptexposes
the same behaviour to the MCP/HTTP adapters. -
Custom prompt for interactive mind maps.
instructionsis now sent for
interactive (studio-artifact) mind maps, not just note-backed ones. The
interactiveCREATE_ARTIFACTpayload carries the free-text prompt at the
[9][1][2]slot of its options block — the same slot quizzes and flashcards
use — and the NotebookLM server honors it for variant 4 (verified live: the
prompt steers the generated node tree, and reads back via
artifacts.get_prompt). Previouslyclient.mind_maps.generate(..., kind=INTERACTIVE, instructions=...)andnotebooklm generate mind-map --kind interactive --instructions ...silently dropped the prompt with a warning;
both now apply it. The no-prompt request shape is unchanged. (Note-backed maps
still passinstructionsthroughGENERATE_MIND_MAP, but the server does not
reliably act on them.) -
Passive auth validation for unattended monitors (#1569). New
notebooklm.auth.fetch_tokens_passive(...)validates the cookies on disk with
a strictly read-only token fetch — it never runsNOTEBOOKLM_REFRESH_CMD,
never fires the layer-1 keepalive rotation poke, and never writes
storage_state.json(additive public symbol; the active
fetch_tokens_with_domainsis unchanged).notebooklm auth check --test --passiveroutes the token probe through it, so a systemd/cron health check
can answer "do the cookies currently authenticate?" without mutating state,
spawning a subprocess, or racing real work. The transport-neutral
run_auth_check(AuthCheckPlan(..., passive=True))exposes the same probe to
the MCP/HTTP adapters. -
notebooklm auth refresh --verify(#1569). After a refresh completes,
runs the passive token probe to confirm the resulting cookies actually
authenticate, exiting non-zero if they still redirect to sign-in. A successful
refresh command alone does not prove the post-refresh cookies work — this is
especially valuable with--browser-cookies, which rewrites the cookie jar
but does not otherwise verify it. -
macOS login recovery hint. When the bundled-Chromium interactive login
times out (Login not detected within 5 minutes), the message now suggests
retrying withnotebooklm login --browser chrometo reuse an already
signed-in system Chrome session — which often detects immediately and sidesteps
bundled-Chromium issues on macOS. -
Layer-3 headless re-auth:
client.refresh_auth(allow_headless=True)(#1525,
P2; P1 was #1512). When NotebookLM's first-party cookies are fully dead — the
homepage GET 302s to the Google login page and neither token refresh (L1) nor
RotateCookiesrotation (L2) can help — a persisted browser profile may still
hold a live Google SSO session that outlivesstorage_state.json. The new
opt-in re-auth layer drives an unattended headless browser against that
profile to silently re-mint cookies, then retries. It is explicit by
default:refresh_auth(allow_headless=True)(additive keyword-only,
defaults toFalse) triggers it on demand, and a mid-RPC auto-fire happens
only whenNOTEBOOKLM_HEADLESS_REAUTH=1is set — L3 never fires by
default, so behavior with no opt-in and no profile is unchanged. Outcomes are
typed and honest (re-minted / profile-session-also-dead / unavailable) and it
never reports success on dead tokens. SECURITY: the persistent profile is
an account-equivalent credential; L3 is local-unattended-only and must not
be the auth path for a remote / hosted MCP server. It reuses the existing
cookie-domain allowlist and never logs a captured cookie value. -
client.mind_maps.list_note_backed(notebook_id)— typed list of only
the note-backed mind maps (everykindisNOTE_BACKED,tree
populated, deleted rows excluded) via a singleGET_NOTES_AND_MIND_MAPS
RPC — noLIST_ARTIFACTS. Factored out ofmind_maps.list()(which now
builds on it) and used by the CLIartifact deletecarve-out probe so the
note-backed membership check is fully typed while keeping the historical
single-RPC call set (recorded cassettes replay unchanged). -
Schema-drift observability:
rpc_decode_errorscounter + chat drift canary
(#1492). Wire-schema drift is the stated #1 breakage class, but
decode/drift failures (DecodingError/UnknownRPCMethodError) were
invisible to metrics — they did not even reach the transport-leg
rpc_calls_failedcounter (the middleware chain wraps only the transport
leg; decode happens after).ClientMetricsSnapshotnow exposes a dedicated
rpc_decode_errorscounter (additive, defaults to0, appended at the end
of the dataclass so existing positional construction is unaffected),
incremented at the executor's response-decode boundary whenever a decoded
response envelope is rejected as drift — both the wrapped shape-drift case
(bad JSON / missing key-or-index) and a surfacedDecodingError/
UnknownRPCMethodErrorfrom the envelope decoder. A decoded semantic error
(rate-limit, not-found, auth) is not drift and does not bump the counter; a
drift error recovered by refresh-and-retry is not counted. (Positional drift
raised later by feature-layersafe_indexnavigation, afterrpc_call
returns, is not yet routed through this counter — a tracked follow-up.)
Operators can now alert on "Google reshaped a response" distinctly from
ordinary 5xx / network failures. Separately,
scripts/check_rpc_health.pynow probes the streamed-chat orchestration RPC
GenerateFreeFormStreamed— aPATH_NOT_METHOD(v1URL) endpoint with no
obfuscated method ID — by asserting a 200 plus a recognizable stream frame,
closing the gap where the chat surface escaped the daily drift canary.
Changed
-
MCP
source_waitnow returns one unified per-source aggregate (#1669).
Both modes — waiting for a singlesourceor for every source in the notebook
— return the same shape:{notebook_id, ok, ready, timed_out, failed, not_found}.readycarries the sources that reached READY (with
kind/status_labellabels); the three error buckets carry
{source_id, error}entries;okistrueiff all error buckets are empty.
Previously the single-source mode returned{status: ready|not_found|failed| timeout}while the all-sources mode threw on the first failure and discarded
every source that had already become ready — the all-sources mode now reports
partial progress instead. (Asourcereference that does not resolve still
raises NOT_FOUND before the wait — an input error, distinct from a resolved
source the backend reports missing/failed/slow.) -
Regenerable test baselines (Phase 1; contributor-facing, no public API
change). Frozen public-surface snapshots that were hand-typed copies of
values the code already derives —_FROZEN_TYPES_ALLand
_UNGATED_PUBLIC_ALL_SNAPSHOTin
tests/_guardrails/test_public_surface_manifest.py— are now derived
baselines committed undertests/fixtures/baselines/(types_all.json,
ungated_surface.json) and registered intests/_baselines/registry.py
alongside the existing CLI contract. A single freeze test diffs each committed
file againstderive(); a dev-only--update-baselinespytest flag (wrapped by
python scripts/regen_baselines.py) regenerates them. Adding a public symbol is
now one regen command plus a reviewed diff instead of hand-editing several
literals. CI never regenerates — it only diffs (the regen flag is refused under
CI). The authored_DOCUMENTED_PUBLIC_IMPORTS(promised-import intent) and
_TOP_LEVEL_TYPE_EXPORTS(fuzzy derivation) stay hand-curated. See
ADR-0022. -
notebooklm.rpcpublic surface narrowed to the two documented power-user
imports (#1589).notebooklm.rpc.__all__now lists onlyRPCMethodand
resolve_rpc_id; the ~47 other names it used to advertise (the batchexecute
wire helpersencode_rpc_request/decode_response/extract_rpc_result/
…, the endpoint URL constants and helpers,safe_index, and the enum /
exception re-exports that remain public under their canonical names —
most enums asnotebooklm.<X>/notebooklm.types.<X>, the exceptions as
notebooklm.<X>/notebooklm.exceptions.<X>, withArtifactStatus/
artifact_status_to_strnotebooklm.types-only andArtifactTypeCodehaving
no public alias) are no longer part of the
blessed, compat-gated public surface. This aligns the audited surface with
docs/stability.md, which has always markednotebooklm.rpc.*internal. Not
a removal: every name stays importable asnotebooklm.rpc.<name>for
back-compat — only the public-API advertisement shrank. New code should
import the canonical public name (orRPCMethod/resolve_rpc_idfor
raw-RPC power use). See docs/deprecations.md. -
notebooklm.authpublic surface narrowed (PR-1) (#1592).auth.__all__
drops 23 internal re-exports that only first-partysrc/tests imported (the
cookie-snapshot/storage helperssave_cookies_to_storage/snapshot_cookie_jar
/CookieSnapshot*/CookieSaveResult/advance_cookie_snapshot_after_save,
the WIZ-extraction helpersextract_csrf_from_html/extract_session_id_from_html
/extract_wiz_field,authuser_query/format_authuser_value,
load_httpx_cookies/normalize_cookie_map,ALLOWED_COOKIE_DOMAINS/
MINIMUM_REQUIRED_COOKIES, the env/URL constantsKEEPALIVE_ROTATE_URL/
NOTEBOOKLM_REFRESH_CMD_ENV/NOTEBOOKLM_REFRESH_CMD_USE_SHELL_ENV/
NOTEBOOKLM_DISABLE_KEEPALIVE_POKE_ENV,load_auth_from_storage,fetch_tokens,
andrecover_psidts_in_memory). These were migration leftovers from the
_auth/*extraction (ADR-0003 → ADR-0014);docs/stability.mdhas always marked
notebooklm.auth.*internal. Not a removal: every name stays importable as
notebooklm.auth.<name>for back-compat — first-party code now imports them from
theirnotebooklm._auth.<sub>home. The documented imports (AuthTokens,
convert_rookiepy_cookies_to_storage_state, the cookie-domain constants) and the
cohesive operations (enumerate_accounts,fetch_tokens_with_domains,
fetch_tokens_passive, …) are unchanged. See
docs/deprecations.md.
Removed
SettingsAPI.get_account_tier()and theAccountTiertype (BREAKING).
client.settings.get_account_tier(),notebooklm.AccountTier/
notebooklm.types.AccountTier, and the underlyingGET_USER_TIERRPC are
removed. The tier came fromFetchRecommendations, a promotions endpoint,
and was a promotion-eligibility signal that could not distinguish free from
paid — both a free and a Pro account reported
NOTEBOOKLM_TIER_PRO_CONSUMER_USER. Useclient.settings.get_account_limits()
(AccountLimits.notebook_limit/source_limit) for quota decisions instead.
The MCPserver_info(include_account=True)account block drops itstierand
plan_namekeys (now{email, authuser, available, notebook_limit, source_limit, output_language}). See
docs/deprecations.md.
Fixed
-
notebooklm doctorno longer greenlights a session that is missing
__Secure-1PSIDTS(#1753). The auth check previously passed onSID
presence alone, so a login that captured only half the Tier-1 cookie set — a
common outcome on Windows, where Chrome 127+ App-Bound Encryption blocks
--browser-cookiesdecryption and an automation-detected Playwright login can
be served a session without the rotating token-binding cookie — reported "All
checks passed" even though every real RPC then failed withMissing required cookies: __Secure-1PSIDTS. Doctor now emits a warn row (not a hard fail —
the cookie legitimately rotates and can be re-minted at runtime, so a static
offline probe must not false-negative a recoverable session) pointing at the
Firefox--browser-cookiesand--master-tokenworkarounds.auth check --testalready reported the real error and is unchanged. New
troubleshooting.md section documents the Windows
App-Bound Encryption limitation and its workarounds. -
notebooklm auth checktext mode now exits non-zero when an executed check
fails, matching--jsonmode (#1569). Previously the Rich-table renderer
printed the failed checks but always exited0, so an unattended health check
usingauth check --test(without--json) silently treated expired auth as
healthy — text and JSON modes had different process contracts. Both modes now
exit0only when every executed check passes (skipped checks — e.g. the
token fetch without--test— do not count as failures) and non-zero (1)
otherwise. Behavioral fix to the CLI exit-code contract; no API change. -
Notebook.created_atnow reflects the true creation time instead of the
last-modified time;Notebook.modified_atis newly exposed. The notebook
metadata block carries two timestamps — the creation instant at
data[5][8][0](pinned across edits) and the last-modified instant at
data[5][5][0](advances on every modification).Notebook.from_api_response
read the modified slot (data[5][5]) and labeled itcreated_at, so the
field — and everything built on it (--jsonoutput,metadataexport, the
Createdtable column) — silently reported the last edit time.created_at
now reads the creation slot (data[5][8]), and the last-modified time is
surfaced additively asNotebook.modified_at(new field, defaults toNone,
appended at the end so positional construction is unaffected; also added to
NotebookMetadata.to_dict()and the notebook--jsonenvelopes). This
applies to bothnotebooks.get()andnotebooks.list()(the homepage/recent
feed), which share the decode path. No signature change —created_atkeeps
its type, only its source value is corrected — so this is a behavioral fix,
not an API-compat break, andmodified_atis purely additive. -
Decoded
created_attimestamps are now tz-aware UTC instead of naive
host-local time (#1519). The shared decoder_datetime_from_timestamp
(backingNotebook/Source/Artifact/MindMap.created_at) called
datetime.fromtimestamp(value)with notz, producing a naive datetime in
the host's local zone — so the same epoch surfaced as a different wall-time
string per machine, andcreated_at.isoformat()/--jsonoutput, the
strftimetable cells, etc. mis-stated the absolute instant (a notebook
created13:40:05UTC rendered as the offset-less08:40:05under
America/New_York). It now returnsdatetime.fromtimestamp(value, tz=utc):
the value is tz-aware UTC and host-independent..timestamp()round-trips
unchanged, so internal sort/dedup/download ordering is provably unaffected —
only the rendered string changes (now offset-aware, identical everywhere).
This is the production sibling of the timezone slip pinned out of the #1511
golden VCR test. -
Artifact downloads re-validate every redirect hop against the trusted-host
allowlist (SSRF-adjacent) (#1521). Both download clients
(download_urlsingle +download_urls_batch) use
follow_redirects=True, but the host-allowlist + HTTPS gate validated only
the initial URL. A trusted Google URL whoseLocationpointed
off-allowlist — a non-HTTPS hop, or a private/link-local host such as
169.254.169.254/localhost— was followed and its body written to the
caller'soutput_path, defeating the explicit allowlist. (Google session
cookies were already not leaked to a non-Google redirect host — the stdlib
cookie policy is domain-scoped — so this never exposed credentials; the
residual harm was attacker-influenced bytes landing on the filesystem.) Both
clients now attach an httpxrequestevent hook that re-checks every hop's
host + scheme before the request is sent, raisingArtifactDownloadError
on the first off-allowlist or non-HTTPS hop so the untrusted host never
receives a connection. Legitimate trusted→trusted redirects (Google
signed-URL CDNs already on the allowlist) are unaffected. The host-allowlist
check (_is_trusted_download_host) also no longer percent-decodes the host
before matching: decoding created a parser differential where
evil%2egoogleapis.com(%2e→.) was judged trusted while httpx connected
to the raw, non-Google host — the guard now matches the exact host httpx
connects to and rejects any host containing%. -
source deletenow honors exact-id-wins over prefix matching, in lockstep
withsource get/rename/refresh(#1522). The delete-path resolver
(_app/source_mutations.resolve_source_for_delete) built its prefix-match
list and branched solely onlen(matches)with no exact-id short-circuit, so
source delete abcraisedAMBIGUOUS_IDwhen a notebook held bothabcand
abcdef— even though the shared resolvers (cli.resolve.resolve_partial_id_in_items
and its_apptwin_app.resolve.resolve_ref) both return on an exact
(case-insensitive) id match before evaluating prefixes, so the other verbs
resolved the same input. The delete resolver now mirrors that Rule 3: a
source whose id equals the input (case-insensitively) wins over any
longer-id prefix match (and is not treated as a partial expansion, so no
"Matched:" prose is emitted). Genuine prefix ambiguity (two strict prefixes,
no exact match) and the not-found / title-instead-of-id paths are unchanged. -
Note.created_atand note-backedMindMap.created_atare now populated
(#1529). Both fields were declareddatetime | Nonebut never filled in,
even though the rawGET_NOTES_AND_MIND_MAPS/CREATE_NOTEresponses carry
the creation timestamp in the note metadata envelope atrow[1][2][2][0]—
the same slot the artifact path already decodes.NoteRowgains a
created_at_raw/created_atproperty pair (mirroringArtifactRow) that
centralizes the descent behind named position constants, and every
note-creation surface now reads it:notes.list/notes.get,
notes.create(both the wrapped[[id, …]]and the flat
[id, …]CREATE_NOTE response shapes),chat.save_answer_as_note, and the
note-backedmind_maps.list_note_backed/mind_maps.generate.
Artifact.from_mind_mapwas lifted to reuse the sharedNoteRowextraction
so the position knowledge lives in one place. Additive: absent / legacy
rows still yieldNone; no signature change. -
profilefilesystem commands now surface a friendly error + exit 1
instead of a raw traceback (#1520). Theprofile delete(shutil.rmtree),
profile create(directory materialization),profile rename(os.rename),
and text-modeprofile listpaths performed their pure-filesystem operations
unguarded, so anOSError— a half-deleted or locked profile directory, a
read-only mount, or a browser-profile file held by AV/the browser on Windows
— escapedSectionedGroup.main(which only catchesClickException/Abort)
and printed a Python traceback. Each now mirrorsprofile switch's existing
except OSError -> click.ClickExceptionidiom, yielding the documented
friendly-message + exit-1 CLI contract. The--json profile listpath keeps
its existinghandle_errorsenvelope (an unexpectedOSErrorthere stays the
UNEXPECTED_ERROR/ exit-2 contract automation relies on). -
Runtime secret redaction now derives from one canonical registry, closing
several credential-disclosure gaps (#1517, #1518). The logging redaction
cookie-name alternation in_logging.pywas hand-enumerated and had drifted
from the project's own cassette sanitizer: the session cookiesNID,
LSOLH, and__Host-GAPS(classified must-scrub by
tests/cassette_patterns.py) were absent, so a bareNID=g.a000-…token —
exactly what_auth/refresh.pylogs at DEBUG from refresh-command
stdout/stderr through the redacting logger — passed throughscrub_secrets
verbatim (#1517). Google API keys (AIza…) and any future__Secure-*/
__Host-*cookie carrying an opaque (non-token-shaped) value were also not
redacted at runtime. Separately,UnknownRPCMethodError.data_at_failurewas
spliced unscrubbed with!rinto__str__/__repr__/ tracebacks (a
string splice that bypasses the loggingRedactingFilter), unlike the
siblingraw_responsewhich was already scrubbed, so a credential-shaped
indexed value leaked through every rendering regardless ofNOTEBOOKLM_DEBUG
(#1518). All are fixed by a single canonical runtime registry
(notebooklm._secrets):RUNTIME_SESSION_COOKIES(the bare must-scrub cookie
names the redaction alternation now derives from),SECURE_HOST_UMBRELLA_PATTERNS
(__Secure-*/__Host-*prefix umbrellas whose name class spans the full
RFC 6265tokencharset — any future secure/host cookie name fails closed by
construction), andAUTH_TOKEN_SHAPE_PATTERNS(carrier-agnostic
g.a000-/sidts-/ya29.token catch-alls plus theAIza…Google
API-key shape, ported from the cassette registry as defense in depth so a
secret under an unknown carrier name still fails closed).data_at_failure
(and the already-scrubbedAuthExtractionError.payload_preview) are routed
throughscrub_secrets, so all three additions cover the exception surfaces
too. Every name-anchored value pattern (cookies, the__Secure-*/__Host-*
umbrellas, and theat=/csrf=/f.sid=/upload_id=/OAuth/Bearerquery +
header forms) now also redacts an RFC 6265 / JSON double-quoted value
(SID="opaque",f.sid="opaque"): the value class excluded", so a quoted
value made the whole pattern miss and leaked verbatim — the optional
surrounding quotes redact the inner value while preserving the quotes. A
parity guardrail
(tests/_guardrails/test_runtime_secret_registry_parity.py) asserts the
runtime registry stays in lockstep with the cassette sanitizer on every axis:
bare-cookie superset, secure/host umbrella coverage by construction, and
regex-string equality of the credential-shape set — so a new must-scrub shape
added to the cassette registry forces the runtime registry to keep up. -
Playwright login: closing the browser during the final storage-state
capture now shows the browser-closed help instead of a bug-report prompt
(#1514, deferred from the #1512 review). Every in-flow Playwright call in
the login flow (page recovery, the navigation retry loop, the login wait,
cookie-forcing) already mappedTargetClosedErrorto the friendly
BROWSER_CLOSED_HELPtext + exit 1, but a closure in the narrow window
during the finalcontext.storage_state()capture fell through the outer
handler's bareraiseand exited 2 ("Unexpected error … please report a
bug"). The outer handler inrun_browser_capturenow recognizes
TargetClosed and surfaces the same help + exit 1; every other unexpected
failure keeps the exit-2 bug-report contract. -
Playwright storage-state filter hardened against malformed cookie rows
and exact-duplicate identities (#1513, deferred from the #1512 review).
filter_storage_state_cookies_by_domain_policyno longer crashes the whole
persist when rookiepy / Playwright emits a malformed row: non-dict entries,
cookies whosedomainis not a str, and cookies whosenameis not a
non-empty str are skipped with one boundedlogger.warningper row
(reprlibpreview) instead of raising in.get/.lstrip. It also
dedups rows sharing an exact RFC 6265 identity(name, domain, path)
(path normalized viaor "/", matching every loader): the last occurrence
in capture order wins whole (fields are never merged), mirroring the
persistence-merge rule insave_cookies_to_storagewhere the newer
observation overwrites the stored row for the same key. Same-name rows on
different domains or paths are all kept — cross-domain same-name
resolution remains a load-time concern (the flat loaders rank by
_auth_domain_priority); deduping by bare name at write time would starve
the(name, domain, path)-keyed runtime loader
(build_httpx_cookies_from_storage), which legitimately holds e.g. the
per-productOSIDcookie onnotebooklm.google.comand
myaccount.google.comas distinct jar entries. -
Split-state PSIDTS recovery no longer writes a duplicate
__Secure-3PSIDTSrow tostorage_state.json(#1523). On the
--browser-cookiespath, when__Secure-1PSIDTSis missing/expired (so
recovery fires) but a fresh__Secure-3PSIDTSis already in the source jar,
Google'sRotateCookiesPOST returns both rotated SIDTS cookies and the
in-memory recovery append loop emitted a second__Secure-3PSIDTS(and a
stale__Secure-1PSIDTStwin) entry with no analog in any real browser jar.
Auth still worked (the row is deduped on load), but the on-disk artifact
diverged from the true cookie set.recover_psidts_in_memorynow keys the
source jar by RFC 6265 identity(name, domain, path)(path normalized via
or "/", matching every loader) and overwrites the existing row in place
with the rotated occurrence instead of appending — exactly one row per key,
carrying the fresh value, mirroring the last-occurrence-wins dedup added to
filter_storage_state_cookies_by_domain_policyin #1513. -
sources.add_textno longer swallows typed transport errors into
SourceAddError. Its bareexcept RPCErrorwrapped everything —
including theRPCErrorsubclassesRateLimitError,AuthError, and
ServerError— so callers could not catch a rate-limitedadd_textto
back off viaretry_after(or re-login onAuthError). It now re-raises
the narrow transport types unwrapped before wrapping only the residual
broadRPCError, matching the ADR-0019 catch ordering its siblings
add_url/add_drivealready follow. The rule is now enforced, not just
documented: a new AST guardrail
(tests/_guardrails/test_error_contract_catch_ordering.py) fails any
except RPCErrorclause that wrap-and-raises a different exception class
without a preceding narrow-transport re-raise clause in the sametry
(scope:src/notebooklm/**minus therpc/protocol layer, where the
transport subtree originates). -
notebooklm note create --jsonno longer reports failure on every
successful create. It previously emitted{"id": null, "created": false, "error": "Creation may have failed"}for every note it successfully
created: a leftover raw-shape decoder in the_applayer went dead when
notes.createwas typed to return aNote(it expected the retired
raw-list RPC shape and yieldedNonefor a typedNote). The bug was
masked in the unit suite by stale raw-list mocks ofnotes.create. The CLI
now emits the real note id with"created": true; facade failures
propagate as exceptions through the standard CLI error handler instead of
a soft-failure envelope. -
14 positional-decode sites no longer fabricate wrong-but-valid values
silently on wire drift. Guarded single-level reads of decoded
batchexecutepayloads could swallow a Google reshape into a plausible
default — an empty notebook / mind-map id, an empty share email, a deleted
mind map leaking as live, a silently-empty chat history, aLIST_NOTEBOOKS
wrapper mis-dispatch feeding garbage rows, an unvalidated source type code,
and a note lookup flipping found → not-found. Per the #1485
absence-vs-malformed policy, genuine absence (short rows,Noneslots,
legitimately-empty containers) keeps its soft degrade, while
present-but-malformed data is now loud: the chat conversation-history walk
moved behind a newConversationTurnRowadapter and raises
UnknownRPCMethodErroron a truthy non-list payload or turns container
(malformed individual turn rows and unrecognized role codes are skipped
with a DEBUG diagnostic);notebooks.list()raisesDecodingErroron an
unrecognized payload shape; mind-map rows are decoded throughNoteRow
and WARN when a null content slot lacks the soft-delete sentinel;
notebook-id, share-email, and source-type-code slots WARN with a bounded
payload preview when present-but-wrong-type (keeping list parsing alive);
andnotes.get_or_noneid matching reads throughNoteRow.id. One
behavior nuance:SharedUser.emailis now always astr— aNoneemail
slot normalizes to""instead of leakingNonethrough thestr-typed
field. -
Chat citation-structure drift is no longer swallowed at DEBUG (#1505
continuity — the last named survivor of that drift-swallow class). A Google
reshape of the streamed-chat citation structure previously degraded to
"answers with no citations" via a blanketexcept → logger.debug → []in
parse_citations— invisible, and it also discarded already-parsed
citations. Per the absence-vs-malformed policy: genuine absence (no type
block, short type block,None/empty citation slot — the routine "answer
without citations" shapes on real traffic) stays completely silent; a
truthy non-list where the citation container belongs (first[4][3]) is
structural wire drift and raisesUnknownRPCMethodError(matching the
parser's existinginner_data[0]raise andunwrap_conversation_turns);
a present-but-unusable individual citation row now logs at least one
bounded WARNING and is skipped, so a good answer keeps its surviving
citations. Surviving citations keep their raw wire ordinal as
citation_number(a skipped row leaves a hole; with nothing skipped this
equals the dense numbering always produced), so the answer's literal[N]
markers never shift onto a different citation. Correspondingly,
save-as-note's positional marker fallback (references[N-1]) now applies
only when that positional reference carries nocitation_number: a holed
marker drops its anchor with a warning instead of anchoring the wrong
chunk.
Breaking
sources/artifacts/notes/mind_maps.get()raise on a miss
(#1247). A genuine miss now raises the matching*NotFoundError
(SourceNotFoundError/ArtifactNotFoundError/NoteNotFoundError/
MindMapNotFoundError) instead of returningNone(and the v0.7.0
DeprecationWarningis gone), matchingnotebooks.get. Return annotations
narrow fromX | NonetoX. Use the unchanged, warning-freeget_or_none()
for the sanctionedNone-on-miss lookup, or wrap intry/except *NotFoundError.- Typed research / mind-map / guide returns are attribute-only (#1251). The
MappingCompatMixindict-subscript bridge is removed fromResearchTask/
ResearchStart/MindMapResult/SourceGuide/ResearchSource:
result["key"]raisesTypeError;result.get(...)/.keys()/.items()/
.values()raiseAttributeError;"k" in result/iter(result)/
len(result)raiseTypeError. Only attribute access (result.status,
guide.keywords, …) andto_public_dict()survive.ResearchStatusstays a
str-enum, sostatus == "completed"keeps working. research.wait_for_completion(interval=...)removed (#1254). The deprecated
interval=keyword alias is gone (its v0.7.0DeprecationWarningcycle is
complete); passing it now raises the standardTypeErrorfor an unexpected
keyword. Useinitial_interval=(same poll cadence).generate mind-mapdefaults to interactive (#1272). The CLI
notebooklm generate mind-map <nb>(andartifact/downloadmind-map paths)
now default--kindtointeractiveinstead of the note-backed JSON map. Pass
--kind note-backedto keep the note-backed behavior.sources.refresh()/chat.delete_conversation()returnNone(#1290).
Both previously returnedTrueon success (uninformative — any failure raised
first); they now returnNoneand their annotations change from-> boolto
-> None.chat.clear_cache(...)is deliberately unchanged and stays-> bool
(its bool is meaningful).- Synchronous generation-kickoff refusals raise (#1342).
artifacts.generate_*
andrevise_slideno longer swallow aUSER_DISPLAYABLE_ERRORrefusal into a
GenerationStatus(status="failed")— they re-raise the underlying
RateLimitError/RPCError._parse_generation_resultraises
ArtifactFeatureUnavailableError/DecodingErroron a missing artifact id.
research.startraisesDecodingErroron an empty / non-list payload or a
falseytask_id(return type narrows fromResearchStart | Noneto
ResearchStart). The publicartifacts.with_rate_limit_retryhelper retries
only on a raisedRateLimitErrorand re-raises on budget exhaustion (a
returned rate-limited status is no longer a retry signal). - Derived-read / lister drift raises
DecodingError(#1344). A
structurally-unrecognized RPC payload that previously collapsed to an empty
value now raisesDecodingError, so callers can distinguish a genuine miss from
server-side shape drift:sources.check_freshness(), the note lister, and the
artifact raw lister reject malformed-but-truthy payloads. Legitimate
empty / stale shapes are unchanged. - Mutate-existing ops fail loud on a missing target (#1362).
notes.update
preflights existence and raisesNoteNotFoundErrorbefore firing the update
RPC;sources.rename(..., return_object=False)and
artifacts.rename(..., return_object=False)run the existence preflight on the
Falsepath and raiseSourceNotFoundError/ArtifactNotFoundErroron a miss.
return_object=Falsestill returnsNoneon success. NotebooksAPI.share()removed + research poll/wait raise on ambiguity
(#1363). The deprecatedclient.notebooks.share()is gone — use
client.sharing.set_public(...)+client.notebooks.get_share_url(...).
research.poll(task_id=None)/wait_for_completion(task_id=None)now raise the
newAmbiguousResearchTaskErrorwhen two or more tasks are in flight (instead of
warning and guessing); with a single in-flight task they resolve it silently.- Removed
NOTEBOOKLM_FUTURE_ERRORSand the deprecation machinery (#1365).
The forward-compat preview gate and thewarn_get_returns_none/
deprecated_kwarg/MappingCompatMixindeprecation helpers are deleted now
that every break they previewed is the default.warn_deprecatedand
NOTEBOOKLM_QUIET_DEPRECATIONSremain for future one-off deprecations.