Status: Active Last Updated: 2026-09-02
Common issues, known limitations, and workarounds for notebooklm-py.
The Android transport is opt-in. Install its runtime before selecting it:
pip install "notebooklm-py[android,browser]"
notebooklm login --master-token --account you@example.com
notebooklm --backend android list --json--backend android must appear before the command. If the command reports a
missing grpcio, protobuf, or gpsoauth module, install the [android]
extra in the same environment as the notebooklm executable. Android uses the
active profile's master_token.json to mint mobile bearer tokens; an ordinary
browser-cookie login alone is not sufficient. Typed namespace operations stay
on the Android transport; use them by default, or client.raw.unary(...) /
raw.unary_stream(...) for advanced Android calls.
The retained default v0.x compatibility policy still makes
NotebookLMClient.from_storage(backend="android") send a homepage GET while
the storage wrapper builds the client, before Android open. A stale/missing Web
cookie, network failure, or homepage-token failure therefore still surfaces at
that build step. The bootstrap does not run PSIDTS recovery or persist the
homepage cookie observation. The deprecated root client.rpc_call(...) then
crosses into a lazy Web sidecar and uses those Web cookies, not the Android
master token. A master-token-only profile can run typed Android and raw unary
operations but cannot use rpc_call; migrate that call instead of adding Web
cookies solely for the compatibility path. See the
Android backend guide for credential and protocol
diagnostics.
First step: Run notebooklm auth check to diagnose auth issues:
notebooklm auth check # Quick local validation
notebooklm auth check --test # Full validation with network test
notebooklm auth check --json # Machine-readable output for CI/CDThis shows:
- Storage file location and validity
- Which cookies are present and their domains
- Whether NOTEBOOKLM_AUTH_JSON or NOTEBOOKLM_HOME is being used
- (With
--test) Whether token fetch succeeds
The client automatically refreshes CSRF tokens when authentication errors are detected. This happens transparently:
- When an RPC call fails with an auth error, the client:
- Fetches fresh CSRF token and session ID from the NotebookLM homepage
- Waits briefly to avoid rate limiting
- Retries the failed request once
- Concurrent requests share a single refresh task to prevent token thrashing
- If refresh fails, the original error is raised with the refresh failure as cause
This means most "CSRF token expired" errors resolve automatically.
Google rotates __Secure-1PSIDTS (the freshness partner of __Secure-1PSID) on its own cadence; the on-disk Expires field is not a reliable predictor of server-side validity. The library handles freshness in layered fallbacks, ordered cheapest to heaviest:
- Per-call rotation poke (default ON) — every
fetch_tokensmakes a best-effort POST toaccounts.google.com/RotateCookies. Disable withNOTEBOOKLM_DISABLE_KEEPALIVE_POKE=1. - Periodic background poke — pass
keepalive=<seconds>toNotebookLMClientfor clients held open for hours. - Layer-3 headless re-auth — explicit Python opt-in via
NotebookLMClient.from_storage(allow_headless=True)orawait client.refresh_auth(allow_headless=True), one-command opt-in vianotebooklm auth refresh --allow-headless, or automatic opt-in withNOTEBOOKLM_HEADLESS_REAUTH=1. This drives the persisted browser profile, or attaches to a loopback Chrome DevTools endpoint fromNOTEBOOKLM_HEADLESS_REAUTH_CDP_URL. Treat CDP as account-equivalent: only use127.0.0.1/localhost, never a remote browser. - Layer-4 master-token re-mint — when a
master_token.jsonsits beside the profile's file-backedstorage_state.json(the[headless]extra;notebooklm login --master-token), a fully-expired session re-mints fresh cookies from the durable master token during cold start or mid-session, after layers 1–3 are exhausted. If storage is absent,notebooklm auth refreshmints and passively validates it from the sibling token. See installation.md#d-headless-server-or-ci. - External recovery script —
NOTEBOOKLM_REFRESH_CMDruns when auth has fully expired, then retries once. - Manual re-login —
notebooklm login; the legacynotebooklm login --master-token-refreshremains for an unconditional master-token re-mint. - External scheduler —
notebooklm auth refreshdriven by cron / launchd / systemd / Task Scheduler / k8s CronJob, for idle profiles with no Python process running. Recommended cadence: 15–20 minutes.
Master-token troubleshooting:
MasterTokenError: ... re-bootstrapmeans Google rejected the stored token or it was explicitly revoked; re-runnotebooklm login --master-token --account you@example.com. A password change alone is not reliable revocation or containment for a leaked master token—remove its associated device/session in Google Account security.MissingDependencyError: ... needs gpsoauthmeans neither the[headless]nor[android]extra is installed; add the extra for your backend. Recovery stops with that configuration error instead of misreporting rejected credentials. A minted jar "missing required cookies" indicates a MergeSession change — file an issue."This browser or app may not be secure" during
--master-tokensign-in: Google blocks sign-in inside the automated browser the auto-capture launches. The client drops the obvious automation flags, but Google may still block — use one of the reliable paths instead:
- Attach to your own Chrome (recommended): quit Chrome, relaunch it with
--remote-debugging-port=9222, thennotebooklm login --master-token --account you@gmail.com --cdp-url http://127.0.0.1:9222. It opens an EmbeddedSetup tab in your real (non-automated) browser, so Google allows sign-in, and scrapes theoauth_token.- Capture the token manually: in a normal browser sign in at
accounts.google.com/EmbeddedSetup, copy theoauth_tokencookie (DevTools → Application → Cookies → accounts.google.com), thennotebooklm login --master-token --account you@gmail.com --oauth-token <value>. Theoauth_tokenis single-use and short-lived — use it immediately.
Most users only need layer 1 — it is on by default and requires no configuration. For supported authentication methods and session-refresh commands, use the installation guide and the examples in this section. Do not copy credentials into scheduler arguments or logs.
On macOS, Chrome (and Edge / Brave / Opera) encrypts its cookies file with a key stored in the macOS Keychain under the entry Chrome Safe Storage. By default that entry's ACL only allows Google Chrome.app itself to read the key without prompting; any other process — Python, Terminal, cron, an editor — gets a "wants to use the Chrome Safe Storage key" dialog. This is how macOS Keychain protects local data and applies to every cookie-extraction tool (rookie-cookies, browser-cookie3, pycookiecheat), not just notebooklm-py.
Workarounds, ordered by hassle:
-
Click "Always Allow" in the prompt. Adds the calling Python interpreter to the Keychain entry's ACL so subsequent runs of that exact binary should stop prompting. Caveat: rebuilding your venv (e.g.
uv venvagain) usually changes the interpreter path and you'll be re-prompted once for the new path. -
Use Touch ID instead of typing the password. macOS Sonoma+ accepts Touch ID for Keychain dialogs — see System Settings → Touch ID & Password.
-
Pre-unlock the login keychain in your shell (best for cron jobs after one initial interactive run):
security unlock-keychain ~/Library/Keychains/login.keychain-dbPrompts once for your login password, then any process in the same login session can read entries you've already approved without re-prompting until the keychain auto-locks.
-
Use Firefox as the cookie source. Firefox stores cookies in a plain SQLite DB (no Keychain), so
notebooklm login --browser-cookies firefoxruns with no prompt at all — provided you're logged into Google in Firefox.notebooklm login --browser-cookies firefox
This is the simplest answer for unattended macOS use.
Firefox Multi-Account Containers note. If your Google session lives inside a container, unscoped
--browser-cookies firefoxwill merge cookies from every container into one jar (see issues #366 / #367) and produce an inconsistent session. Use the explicit container syntax:notebooklm login --browser-cookies 'firefox::Work' # named container notebooklm login --browser-cookies 'firefox::none' # no-container default
When a container is in use, the unscoped form also emits a yellow warning pointing at this syntax.
-
Truly headless servers.
--browser-cookiesis not the right tool — there's no live browser to extract from. Either re-extract on a workstation and shipstorage_state.jsonto the server, or accept that human interaction is needed when cookies finally expire.
Quick diagnostic:
security find-generic-password -s 'Chrome Safe Storage' -a 'Chrome' -w >/dev/null && echo OK || echo "ACL or lock issue"Prints OK without prompting → keychain is unlocked and your user has access; the prompt you saw is the per-binary ACL re-asking for a new caller (your Python). Click Always Allow once and that binary is permanently approved. If it prompts → run security unlock-keychain first.
Windows: Missing required cookies: __Secure-1PSIDTS after login, and --browser-cookies "Could not decrypt"
On Windows, both credential paths can leave you without __Secure-1PSIDTS (the rotating freshness partner of __Secure-1PSID that every real RPC needs — see Automatic Token Refresh above), so notebooklm login reports success but notebooklm list then fails with Missing required cookies: __Secure-1PSIDTS (issue #1753). Two distinct causes are in play:
notebooklm login --browser chrome(Playwright flow). The interactive browser completes Google sign-in, but Google may serve an automation-detected session without the token-binding cookie (and sometimes without the secondary-binding cookiesOSID/APISID+SAPISIDthe automaticRotateCookiesrecovery needs to re-mint it). When that happens the savedstorage_state.jsonis genuinely incomplete and re-running the same flow reproduces it.notebooklm login --browser-cookies chrome(oredge) →Could not decrypt chrome cookies. Chrome 127+ (and current Edge) protect the cookie database with App-Bound Encryption (ABE): the decryption key is bound to the browser process via a Windows service, so no external process can read it. This blocks every cookie-extraction library (rookie-cookies,browser-cookie3,pycookiecheat), not justnotebooklm-py. There is no flag that bypasses ABE.
notebooklm doctor checks local auth material, not whether Google accepts the session.
It reports a warn row when __Secure-1PSIDTS is missing, expired, empty, or
cannot be sent to the configured NotebookLM URL. A usable local SID does not
guarantee successful recovery or an accepted server session. Use
notebooklm auth check --test --passive to test token fetching without refreshing
or writing credentials when the required cookie names remain structurally usable.
Absent, empty, malformed, or disallowed-domain PSIDTS stops the passive check
locally before contacting Google. For incomplete file-backed auth, use
notebooklm auth check --test for best-effort recovery; it may refresh, rotate,
or update stored cookies. Incomplete inline auth needs a complete export, or
unsetting NOTEBOOKLM_AUTH_JSON to use stored profile recovery.
Keep the same --profile / --storage selector and inline auth environment as
the failing command; doctor prints guidance for that source. On Windows, paste
doctor's labeled commands into PowerShell so quoted selectors stay literal.
Workarounds, most reliable first:
-
Use Firefox as the cookie source. Firefox stores cookies in a plain SQLite DB — no App-Bound Encryption — so extraction just works. Sign in to Google in Firefox, then:
notebooklm login --browser-cookies firefox
(If your Google session lives in a Multi-Account Containers tab, use the explicit
firefox::Container/firefox::nonesyntax — see the macOS section above for the container notes.) This is the simplest fix for the ABE case. -
Set up a master token (best for unattended / long-lived use).
notebooklm login --master-token --account you@example.com(needsgpsoauth, supplied by the[headless]or[android]extra) stores a durablemaster_token.jsonbeside your profile. When cookies are missing or fully expired, the Web client re-mints a complete, fresh cookie jar — including__Secure-1PSIDTS— from the master token in-process, so it does not depend on what the browser login happened to hand back. If Google blocks sign-in inside the automated capture window ("This browser or app may not be secure"), use the CDP-attach or manualoauth_tokenvariants described in the master-token troubleshooting note above. See also installation.md#d-headless-server-or-ci.⚠️ The one-time bootstrap is not browser-free. Plainnotebooklm login --master-token --account you@example.comlaunches a headed Playwright browser to capture the single-useoauth_token, so on a machine where the browser cannot start at all (seespawn UNKNOWNbelow) it fails the same way. Only the two manual variants avoid spawning a browser:--master-token --account you@example.com --oauth-token <value>(paste the cookie yourself) and--master-token --account you@example.com --cdp-url <url>(attach to a Chrome you started). What is browser-free is everything after bootstrap — the layer-4 re-mint from the storedmaster_token.json. -
Retry the Playwright login on a fresh profile. Sometimes a stale persistent profile is the culprit rather than automation detection:
notebooklm login --fresh
If three attempts (normal,
--fresh+ password,--fresh+ passkey) all reproduce the missing cookie, treat it as the automation-detection case and switch to Firefox or a master token above.
BrowserType.launch_persistent_context: spawn UNKNOWN
Failed to launch: Error: spawn UNKNOWN
Cause: something on the machine vetoed executing the browser — this is not a missing install and not a notebooklm-py bug (issue #2004). UNKNOWN is libuv's UV_UNKNOWN: CreateProcessW returned a Win32 error that has no entry in libuv's translation table. A missing binary would surface as ENOENT and an ordinary ACL denial as EACCES, so what actually reaches UNKNOWN is policy- or antivirus-shaped:
ERROR_ACCESS_DISABLED_BY_POLICY— AppLocker, Software Restriction Policies, or WDAC blocking execution from%LOCALAPPDATA%\ms-playwright.ERROR_VIRUS_INFECTED/ERROR_VIRUS_DELETED— Microsoft Defender or another endpoint-security agent quarantining the Chromium build.
Upstream Playwright reports (microsoft/playwright#35363, #28307, #28858) all resolve to group policy or endpoint security, most often on managed corporate machines.
--headless does not help. It launches the identical binary through the identical spawn; the veto happens at process creation, before any window would exist.
Workarounds, most reliable first:
- Use a system browser. Chrome and Edge install under
Program Files, a different path — so a rule scoped to Playwright's directory does not cover them:notebooklm login --browser chrome notebooklm login --browser msedge
- Sign in elsewhere and ship the credentials in. This is the right answer for a non-interactive or locked-down Windows host, and needs no browser on that host at all: run
notebooklm loginon a machine with a display, then copystorage_state.jsonover (or setNOTEBOOKLM_AUTH_JSON) — see installation.md#d-headless-server-or-ci. - Have IT allow-list the directory. Permit execution from
%LOCALAPPDATA%\ms-playwright(or whereverPLAYWRIGHT_BROWSERS_PATHpoints), and exclude it from real-time antivirus scanning.
Note that --browser-cookies chrome is not a workaround on Windows: it runs into App-Bound Encryption instead (see the section above). Use --browser-cookies firefox if you want the cookie-extraction route.
Cause: Session cookies expired (happens every few weeks).
Note: Automatic token refresh handles CSRF/session ID expiration. This error only occurs when the underlying cookies (set during notebooklm login) have fully expired.
Solution:
notebooklm loginCause: The CSRF token (SNlM0e) couldn't be extracted from the NotebookLM page response. The exact wording depends on which code path raised it:
Failed to extract CSRF token (SNlM0e). Page structure may have changed or authentication expired. Preview: '...'— raised byrefresh_auth()when the WIZ_global_data extraction fails (client.py).CSRF token not found in HTML. Final URL: <url> This may indicate the page structure has changed.— raised by the lower-level extractor when the response did come from a NotebookLM app host but carried no token (auth.py). This is the genuine "file a bug" case.CSRF token not found in HTML. Final URL: <url> The response did not come from a NotebookLM app host, so the request never reached the app …— same extractor, but the chain landed somewhere else entirely. The final URL is the diagnosis: follow it to see where the request actually went. Nothing is wrong with your login.Failed to extract 'SNlM0e' from NotebookLM HTML response. This usually means Google changed the page structure. Preview: '...'— raised asAuthExtractionErrordirectly (rare; usually wrapped by one of the messages above) (exceptions.py).
A related auth-redirect message — Authentication expired. Run 'notebooklm login' to re-authenticate. (or Authentication expired or invalid. Final URL: … / … Redirected to: …) — surfaces the same root cause when the page redirected to Google's login flow. Every one of these messages carries the final URL; if it does not name accounts.google.com, the session did not expire.
Note: the "did not come from a NotebookLM app host" wording exists because these two conditions used to be indistinguishable. A page-body scan for
accounts.google.comlinks reported any Google-served page (help articles, the region gate, the app itself) as an expired login — see #2038 and the six-day false alarm in #2019. If you are reading an older error that says "Authentication expired" with no URL at all, it came from that removed code path and should not be trusted.
Cause: the request's redirect chain passed through accounts.google.com/CookieMismatch (which then forwards to a support.google.com help article). Google rejected the cookies as not matching the host they were sent to. This is a cookie scoping problem, not necessarily an expired session — the credentials themselves may be perfectly valid.
Common causes:
- a
storage_state.jsonwhose per-cookiedomainvalues were flattened to a single host, so cookies scoped to.google.comget sent to hosts they were never issued for, - cookies belonging to a different Google account or session than the one being addressed,
- a stale
__Secure-1PSIDTSthat Google could not reconcile.
Solution:
notebooklm login # re-extract cookies with their original domainsIf it recurs, inspect storage_state.json and confirm each cookie kept its own domain field rather than being rewritten to one host. The installation guide covers the supported credential files.
Note: These errors should rarely surface, since the client automatically retries with a fresh CSRF token on auth failures (see Automatic Token Refresh above). When one does reach you, the automatic refresh also failed.
Solution (if auto-refresh fails):
# In Python — manual refresh
await client.refresh_auth()Or re-run notebooklm login if session cookies are also expired. If the failure persists across re-login, the page structure has likely changed — file an issue and include the Preview: snippet from the error.
Cause: The request to the personal app host — notebook.google.com by default since #2067, or whichever host NOTEBOOKLM_BASE_URL selects — was redirected to notebooklm.google/?location=unsupported — Google's region / anti-abuse risk-control gate (the marketing/landing page, which has no CSRF token). The response is a landing page rather than the authenticated app, so it cannot supply app tokens. The redirect alone does not establish cookie validity or the underlying cause. The gate can depend on the access environment, not just the account's country, and can affect accounts in supported regions when Google sees:
- a VPN / proxy / datacenter / shared IP (especially previously-abused ones),
- an IP ↔ timezone ↔ browser-language mismatch, or
- a non-browser / automated access pattern (a raw HTTP client without a real browser fingerprint).
The rebranded landing page is https://notebook.google/ (without .com); notebooklm.google now redirects there. It is a marketing site, not a new authenticated app endpoint. A redirect there alone does not establish that cookies expired or identify why Google declined app access. Do not set NOTEBOOKLM_BASE_URL to that landing host or copy cookies to it. Issue #2441 exposed a missing landing-host alias in the redirect diagnostic; recognizing it improves the error but does not bypass Google's access gate or establish the cause of an RPC PERMISSION_DENIED response.
Confirm: open the same host the client is using (https://notebook.google.com unless you set NOTEBOOKLM_BASE_URL) in a normal browser, signed in to the same account, on the same network. If it also redirects to notebooklm.google/?location=unsupported or notebook.google/, compare the result on another network to investigate the access environment. Test the host that actually failed — the two personal hosts are gated by the same risk-control system, but diagnosing against the host you are not using is how a working setup gets misread as broken.
Solution: access from a residential connection in a supported region, keep your system timezone/language consistent with the IP's country, and avoid shared/datacenter VPN exit IPs. When the trigger is the non-browser fingerprint (a raw HTTP client) rather than the IP, the opt-in browser-TLS-impersonation transport can help: set NOTEBOOKLM_TRANSPORT=curl_cffi (requires the curl_cffi package) so requests carry a real browser's TLS fingerprint. (See issue #1630.)
Cause: Google detecting automation and blocking login.
Solution:
- Delete the browser profile:
rm -rf ~/.notebooklm/profiles/<profile>/browser_profile/(or~/.notebooklm/profiles/default/browser_profile/for the default profile) - Run
notebooklm loginagain - Complete any CAPTCHA or security challenges Google presents
- Ensure you're using a real mouse/keyboard (not pasting credentials via script)
Cause: The bundled Chromium that notebooklm login launches by default opened a fresh, signed-out browser, and its login-detection wait timed out — common on macOS where bundled Chromium can also be flaky (macOS 15+).
Solution: If you are already signed in to Google in system Chrome, retry with that browser so the existing session is reused instead of starting a fresh sign-in:
notebooklm login --browser chrome --storage <path>--browser chrome drives your installed Google Chrome (with its signed-in profile), which usually detects the account immediately and sidesteps bundled-Chromium issues. --browser msedge is the equivalent for organizations that require Microsoft Edge for SSO.
Cause: NOTEBOOKLM_BASE_URL does not match an accepted HTTPS app origin. The error lists the supported hosts documented in configuration.md: notebook.google.com (default), notebooklm.google.com (legacy personal), notebook.cloud.google.com (enterprise), and notebooklm.cloud.google.com (legacy enterprise). Non-root paths, query strings, credentials, and explicit ports are rejected. The marketing hosts notebook.google and notebooklm.google are not authenticated app endpoints.
Status: notebook.google.com is the default since #2067. A live probe on 2026-08-04 reached batchexecute on both personal hosts, so the endpoint is dual-served, not rebrand-host-only or legacy-only. The cassettes in tests/cassettes/ now record requests against the rebrand host. The pre-rebrand host notebooklm.google.com remains a valid, still-served value and is the documented rollback lever; switching back is normally just the variable, with the caveats described below. See ADR-0028.
Solution: Leave NOTEBOOKLM_BASE_URL unset, or set it to a documented value:
# Personal (default — no need to set it)
export NOTEBOOKLM_BASE_URL=https://notebook.google.com
# Personal, pre-rebrand host (still served; rollback lever)
export NOTEBOOKLM_BASE_URL=https://notebooklm.google.com
# Enterprise (origin only; project/region routing is not configured here)
export NOTEBOOKLM_BASE_URL=https://notebook.cloud.google.com
# Legacy enterprise
export NOTEBOOKLM_BASE_URL=https://notebooklm.cloud.google.comBoth personal hosts are documented values since #2067. https://notebook.google.com is the default and is what this project's cassettes now exercise; https://notebooklm.google.com remains served and is the supported rollback lever.
Switching between them is normally just the variable. The host-scoped OSID does not survive the switch — it was minted on the host you left and is never sent to the other one — but it is not the only binding path: APISID + SAPISID together with bare LSID also satisfies the check, and a profile captured by notebooklm login normally has all three. Note the LSID conjunct is required; APISID + SAPISID on their own fail (see the ablation table on _has_valid_secondary_binding, issue #1977). If auth does fail after switching, the profile was leaning on OSID and is missing one of those three; recover with notebooklm login --fresh. Use --fresh rather than a plain notebooklm login, which can report "Already logged in" and re-mint nothing, since the login accept-set matches either personal host.
Cause: The RPC method ID may have changed (Google updates these periodically), or:
- Rate limiting from Google
- Account quota exceeded
- API restrictions
Diagnosis:
# Enable debug mode to see what RPC IDs the server returns
NOTEBOOKLM_DEBUG_RPC=1 notebooklm <your-command>This will show output like:
DEBUG: Looking for RPC ID: Ljjv0c
DEBUG: Found RPC IDs in response: ['NewId123']
If the IDs don't match, the method ID has changed. Report the new ID in a GitHub issue.
Workaround:
- Wait 5-10 minutes and retry
- Try with fewer sources selected
- Reduce generation frequency
Google rotates undocumented batchexecute method IDs without warning. When
this happens, notebooklm-py raises UnknownRPCMethodError with the new ID
the server now uses (see the previous section's diagnosis recipe). Rather
than waiting for a release, you can patch the mapping for your process with
the NOTEBOOKLM_RPC_OVERRIDES environment variable.
Format: JSON object mapping RPCMethod member names (the Python enum
member name, not the obfuscated value) to the override RPC ID:
export NOTEBOOKLM_RPC_OVERRIDES='{"LIST_NOTEBOOKS": "newId123", "CREATE_NOTEBOOK": "newId456"}'
notebooklm listOr in Python:
import os
os.environ["NOTEBOOKLM_RPC_OVERRIDES"] = '{"LIST_NOTEBOOKS": "newId123"}'
from notebooklm import NotebookLMClient
# Subsequent client calls send the override IDs on the wire.Behavior:
- The override is applied at BOTH the URL
rpcids=query parameter AND the request bodyf.reqpayload, so the wire format stays consistent. - The override is gated on the configured base host being a known Google
NotebookLM endpoint (
notebook.google.com,notebooklm.google.com,notebook.cloud.google.com, ornotebooklm.cloud.google.com). Overrides do NOT apply to non-Google hosts, so this env var cannot be weaponised to leak custom RPC IDs to a hostile endpoint. - Method names not listed in the override map continue to use the canonical
IDs from
notebooklm.rpc.types.RPCMethod. - Malformed input (invalid JSON, top-level array, etc.) is logged at
WARNINGand treated as no overrides. - The first time a distinct override set is applied in a process, the
mapping is logged at
INFOso you can confirm the config you intended is live.
Discovering the new ID: see the NOTEBOOKLM_DEBUG_RPC=1 recipe above —
the Found RPC IDs in response: [...] line tells you what the server is
now returning. Cross-reference against the call site that failed.
Please also report the rotated IDs in a GitHub issue so the canonical
mapping in src/notebooklm/rpc/_identifiers.py can be updated for everyone.
RPCError.raw_response is truncated to 80 chars + "..." by default so
error messages stay readable in logs and CLI output. When you need the
full body to diagnose schema drift or a malformed response, opt in:
NOTEBOOKLM_DEBUG=1 notebooklm <your-command>Or in Python, set the env var before instantiating the client:
import os
os.environ["NOTEBOOKLM_DEBUG"] = "1"
from notebooklm import NotebookLMClient
# Subsequent RPCError instances will carry the full untruncated body.The value must be exactly "1" — "0", "true", etc. are treated as
unset (still truncated).
Cause: Google's API rejected the request. Common cases:
- Invalid parameters or a not-found resource ID
- Account quota exceeded (for
create, status[3]is also the notebook-limit signal) - Rate limiting
Solution:
- Check that notebook/source IDs are valid
- Add delays between operations (see Rate Limiting section)
If it only affects write operations (create, source add, generate) while reads (list, ask) keep working — and the web UI still works — the likely cause is that Google changed the request payload (wire format) for those RPCs and notebooklm-py is still sending the old shape. Google rolls these out gradually, so it can hit some accounts before others.
First, rule out a mis-decoded success. Re-run the failing action, then check
notebooklm list: if the resource was actually created despite the error, it's a response-decoding issue (share the Response below). If it was not created, Google rejected our request (share the Payload below).
Help us fix it — share the web UI's payload (no cookies needed). Either option below leaks nothing: cookies, the at= CSRF token, and Set-Cookie live in request/response headers, never inside the f.req payload.
⚠️ Never paste the raw.haritself. A HAR contains your cookies, theat=CSRF token, and the full NotebookLM page HTML — which embeds API keys, the CSRF token, and your account email. Only ever share the scrubber's output below (or the singlef.reqline from Option B). The scrubber processes only/batchexecutecalls and redacts every value, so the page HTML never reaches its output.
Option A — thorough, auto-scrubbed (recommended; captures every RPC + its response).
This walkthrough takes ~2 minutes. You never copy a cookie, a token, or the raw HAR — a small bundled script does the redaction for you.
-
Open DevTools on the Network tab. In Chrome/Edge press F12 (or Cmd+Opt+I on macOS); in Firefox press F12. Click the Network tab at the top of the panel.
-
Arm the capture. Tick Preserve log (Chrome) / Persist Logs (Firefox) so a page reload doesn't wipe the capture, and confirm the round ● Record button is red (it usually is by default).
-
Reproduce the failure. In the NotebookLM tab, perform the exact action that fails — e.g. create a notebook, or add a source. You'll see
batchexecute?rpcids=…rows appear in the Network list. You can stop as soon as the action errors. -
Export the session to a file:
- Chrome/Edge: click the ⤓ Export HAR… download icon in the Network toolbar (or right-click any row → Save all as HAR with content).
- Firefox: click the ⚙️ gear / … menu in the Network toolbar → Save All As HAR.
Save it as
capture.har. (The "with content" variant matters — it's what includes the response bodies the scrubber reports.) -
Scrub it. From your
notebooklm-pycheckout, run the bundled script (stdlib-only — no install needed):$ python scripts/scrub_rpc_har.py capture.har NotebookLM RPC capture — string values → <str:N>; cookies / headers / at= / Set-Cookie never read: CCqFvf (CREATE_NOTEBOOK) request : ["<str:7>",null,null,[2],[1]] response: HTTP 200 | status_code=[3] | result=null 1 call(s). Safe to share — no cookies / CSRF / session tokens are present (they live in headers, which this tool never reads).
Narrow to a single RPC with
--rpcidif the capture is noisy:$ python scripts/scrub_rpc_har.py capture.har --rpcid CCqFvfThe script reads only each request's
f.reqfield and the response body — never the headers/cookies arrays, never the non-batchexecutepage HTML — and replaces every text value with its length (<str:7>). It refuses to print if any raw string ever slips through, so the output is safe by construction. -
Paste that output into the issue. Read it back first as a sanity check: every value should be
<str:N>, never readable text.status_code=[3]withresult=null→ Google rejected our request (a payload/wire-format change). This is what we need to fix it.- A non-null
result→ the call actually worked and this is a response-decode issue; share it just the same.
Option B — quick, one RPC by hand (no script). Use this if you can't run the script.
- In DevTools → Network, click the
batchexecute?rpcids=…POST for the failing call (e.g.rpcids=CCqFvffor create,izAoDdfor add-source). - Open the Payload tab (Chrome) / Request tab (Firefox), copy only the
f.reqvalue — not theat=field beside it, and don't open the Cookies/Headers tabs. - Replace any free text (title, URL) with
REDACTEDand paste it. We diff it against what the library sends —["<title>", null, null, [2], [1]]for create — and update the payload.
Cause: NotebookLM refused the generation kickoff synchronously (often quota,
feature availability, rate limiting, or an RPC shape drift). In v0.8.0 the
Python API raises instead of returning None.
What to do:
# Let the CLI surface the typed error envelope / message
notebooklm generate audio --wait --json
# If generation was accepted and you have a task id, poll manually
notebooklm artifact poll <task_id> --jsonIn Python, catch RateLimitError, ArtifactFeatureUnavailableError, or
RPCError depending on the failure. If kickoff succeeds and later polling
times out, use the timeout guidance below.
Cause: NotebookLM accepted the generation task, but the upstream media queue did not reach a terminal state before your wait budget. For media artifacts, the SDK also keeps polling if NotebookLM marks the row completed before the media URL is populated.
Solution:
- Increase the wait budget with
--timeoutor the Pythonwait_for_completion(..., timeout=...)argument. - For
generate <kind> --wait, the built-in media defaults are 1200s for audio, 1800s for standard video, and 3600s for cinematic video; pass a larger--timeoutif your account's media queue is slower. artifact waitis intentionally generic and still defaults to 300s; when waiting manually on a media task ID, pass the matching media timeout.- Catch
ArtifactPendingTimeoutErrorto retry queued tasks separately fromArtifactInProgressTimeoutError, which means the task started but did not finish before the timeout. - Log
exc.status_historyandexc.status_transitionsfor upstream queueing diagnostics instead of parsing the exception message.
Cause: Generation may silently fail without error.
Solution:
- Wait 60 seconds and check
artifact list - Try regenerating with different/fewer sources
Cause: NotebookLM's file-upload endpoint rejects HTML-family uploads, even though the web UI may accept pasted rich text.
Solution: Convert saved web pages to text, Markdown, or PDF before adding them with your preferred extractor:
notebooklm source add ./article.txtYou can also pipe extracted text through stdin:
python extract_article_text.py ./article.html | notebooklm source add - --type text --title "Article"Cause: The upload was accepted, but NotebookLM processed unexpected content
or reported a source-processing error. Current add_file() returns a Source;
missing or untrusted source IDs raise SourceAddError instead of returning
None.
Workaround: When you control the text, bypass file-type inference and use
add_text:
# Instead of: notebooklm source add ./notes.txt
# Do:
notebooklm source add - --type text --title "My Notes" < ./notes.txtOr in Python:
content = Path("notes.txt").read_text()
await client.sources.add_text(nb_id, "My Notes", content)Cause: Files over ~20MB may exceed upload timeout.
Solution: Split large documents or use text extraction locally.
Symptoms:
- Source title shows "Fixing X.com Privacy Errors" or similar error message
- Generated content discusses browser extensions instead of the actual article
- Source appears to process successfully but contains wrong content
Cause: X.com (Twitter) has aggressive anti-scraping protections. When NotebookLM attempts to fetch the URL, it receives an error page or compatibility warning instead of the actual content.
Solution - Use bird CLI to pre-fetch content:
The bird CLI can fetch X.com content and output clean markdown:
# Step 1: Install bird (macOS/Linux)
brew install steipete/tap/bird
# Step 2: Fetch X.com content as markdown
bird read "https://x.com/username/status/1234567890" > article.md
# Step 3: Add the local markdown file to NotebookLM
notebooklm source add ./article.mdAlternative methods:
Using browser automation:
# If you have playwright/browser-use available
# Fetch content via browser and save as markdownManual extraction:
- Open the X.com post in a browser
- Copy the text content
- Save to a
.mdfile - Add the file to NotebookLM
Verification:
Always verify the source was correctly parsed:
notebooklm source list
# Check that the title matches the actual article, not an error messageIf the title contains error-related text, remove the source and use the pre-fetch method:
# Remove incorrectly parsed source
notebooklm source delete <source_id>
# Or, if you only have the exact title:
notebooklm source delete-by-title "Exact Source Title"
# Then re-add using the bird CLI method aboveOther affected sites:
- Some paywalled news sites
- Sites requiring JavaScript execution for content
- Sites with aggressive bot detection
Google enforces strict rate limits on the batchexecute endpoint.
Symptoms:
RateLimitErrorin Python, or CLI JSON withcode: "RATE_LIMITED"RPCErrorwith IDR7cb6cUserDisplayableErrorwith code[3]
Best Practices:
CLI: Use --retry for automatic exponential backoff:
notebooklm generate audio --retry 3 # Retry up to 3 times on rate limit
notebooklm generate video --retry 5 # Works with most generate commandsNote: generate mind-map is synchronous and does not accept the --retry option. All other generate subcommands support --retry.
Python:
import asyncio
from notebooklm import RPCError
from notebooklm.artifacts import with_rate_limit_retry
# Add delays between intensive operations
for url in urls:
await client.sources.add_url(nb_id, url)
await asyncio.sleep(2) # 2 second delay
# Use the shared generation retry policy when starting artifacts
status = await with_rate_limit_retry(
lambda: client.artifacts.generate_audio(nb_id),
max_retries=3,
)
# For non-artifact RPC calls, retry by passing a fresh callable each attempt
async def retry_rpc_call(make_call, max_retries=3):
for attempt in range(max_retries + 1):
try:
return await make_call()
except RPCError:
if attempt >= max_retries:
raise
await asyncio.sleep(2**attempt)
notebook = await retry_rpc_call(lambda: client.notebooks.create("Research Notes"))client.chat.ask(notebook_id, question) with conversation_id=None
attaches the question to the user's current conversation on the
notebook — by design. The SDK still fetches the server-recorded
conversation_id via hPTbtc after the ask and returns it on
AskResult.conversation_id, so follow-ups using that id work
correctly.
To force a brand-new server-side conversation, delete the existing one first — this mirrors the web UI's "Delete history" button:
last_conv_id = await client.chat.get_conversation_id(nb_id)
if last_conv_id:
await client.chat.delete_conversation(nb_id, last_conv_id)
result = await client.chat.ask(nb_id, "Start fresh")Or via the CLI (prompts for confirmation; -y skips):
notebooklm ask --new -y "Start fresh"This is destructive: deleted turns are not recoverable. The CLI
shows the conversation's short id in the prompt and defaults to "No".
--json implies --yes so scripted callers don't hang on stdin.
History: Before the SDK gained delete_conversation it had no way
to honor the "fresh conversation" intent — both the SDK and the CLI's
--new flag would silently extend the most-recent conversation, so
users worked around it by creating a new notebook for each thread.
The J7Gthc RPC was reverse-engineered from the web UI's "Delete
history" button and removes the need for that workaround.
Some features have daily/hourly quotas:
- Audio Overviews: Limited generations per day per account
- Video Overviews: More restricted than audio
- Deep Research: Consumes significant backend resources
Artifact downloads (audio, video, images) use httpx with cookies from your
storage state. Playwright is not required for downloads themselves—it is
used only by interactive notebooklm login or, when explicitly enabled, the
optional layer-3 browser recovery path.
If downloads fail with authentication errors:
Solution: Ensure your authentication is valid:
# Re-authenticate if cookies have expired
notebooklm login
# Or copy a fresh storage_state.json from another machineCustom auth paths: When using from_storage(path=...) or from_storage(profile="work"),
artifact downloads automatically use the same storage path for cookie authentication.
If you are on an older version where downloads fail with "Storage file not found" pointing
to the default location, upgrade or set NOTEBOOKLM_HOME as a workaround.
Download URLs for audio/video are temporary:
- Expire within hours
- Always fetch fresh URLs before downloading:
# Get fresh artifact list before download
artifacts = await client.artifacts.list(nb_id)
audio = next(a for a in artifacts if a.kind == "audio")
# Use audio.url immediatelyPlaywright missing dependencies:
playwright install-deps chromiumplaywright install chromium fails with TypeError: onExit is not a function:
This is an environment-specific Playwright install failure that has been observed
with some newer Playwright builds on Linux. notebooklm-py needs a working
browser install for interactive notebooklm login and for explicitly enabled
layer-3 browser recovery; the workaround is to install a known-good Playwright
version in a clean virtual environment.
Workaround (intentionally uses pip rather than the canonical uv sync --frozen flow from installation.md#e-contributor — this workaround needs to override the playwright>=1.40.0 constraint to a specific older version, which uv sync --frozen would refuse):
python -m venv .venv
source .venv/bin/activate
pip install -U pip
pip install "playwright==1.57.0"
python -m playwright install chromium
pip install -e ".[all]"Why this order matters:
python -m playwright ...ensures you use the Playwright module from the active virtual environment- installing the browser before
pip install -e ".[all]"avoids picking up an older broken globalplaywrightexecutable - if you already have another
playwrighton your system, verify withwhich playwrightafter activation - using
piphere (notuv sync --frozen) is deliberate: this workaround needs to override the project's resolvedplaywrightversion with a specific older release, which the lockeduvflow would block
If you need a non-editable install from Git instead of a local checkout, replace the last step with:
pip install "git+https://github.com/<your-user>/notebooklm-py@<branch>"No display available (headless server):
- Browser login requires a display
- Authenticate on a machine with GUI, then copy
storage_state.json
Chromium not opening:
# Re-install Playwright browsers
playwright install chromiumSecurity warning about Chromium:
- Allow in System Preferences → Security & Privacy
Login problems? Two Windows-specific login failures are covered under Authentication Errors above:
spawn UNKNOWNwhen the browser will not start, andCould not decrypt/ missing__Secure-1PSIDTS.
CLI hangs indefinitely (issue #75):
The CLI runs on asyncio's default Windows event loop, ProactorEventLoop, like notebooklm-mcp, notebooklm-server and the Python API. One user running the CLI inside a Sandboxie-Plus box reported commands hanging at the IOCP (I/O Completion Ports) layer with that loop.
Symptoms:
notebooklm loginworks, butnotebooklm listand other network commands never return- Process appears frozen with no output
- Ctrl+C shows a traceback ending in
_overlapped.GetQueuedCompletionStatus
Solution: Switch the CLI to the selector event loop:
$env:NOTEBOOKLM_WINDOWS_EVENT_LOOP = "selector" # PowerShellset NOTEBOOKLM_WINDOWS_EVENT_LOOP=selectorOnly do this if the default loop hangs. On Python 3.12–3.14 asyncio's selector loop has a bug that drops data from large uploads (#2485). When this variable selects it, the CLI installs the fix from Python 3.15 for the run, so source add uploads are safe. Python 3.10, 3.11 and 3.15 do not have the bug.
The variable accepts only selector or proactor (case-insensitive). Any other value makes every CLI command, even status, fail before it starts with Unknown NOTEBOOKLM_WINDOWS_EVENT_LOOP=... (CONFIG_ERROR with --json). If you set it persistently, for example in your user environment variables, fix or remove it there.
Earlier releases forced the selector loop on every Windows machine (#79). If you added a workaround for #2485, such as a .pth hook that aliases WindowsSelectorEventLoopPolicy to the Proactor policy, you can remove it.
For the Python API in such an environment, run your code on a selector loop. The library does not patch asyncio, so on Python 3.12–3.14 large uploads on that loop can fail with NETWORK_ERROR … (upload_finalize) after a flood of AssertionError: Data should not be empty; use Python 3.11 or 3.15 if you upload large files there:
import asyncio
asyncio.run(main(), loop_factory=asyncio.SelectorEventLoop) # Python 3.12+
with asyncio.Runner(loop_factory=asyncio.SelectorEventLoop) as runner: # Python 3.11
runner.run(main())On Python 3.10, set asyncio.WindowsSelectorEventLoopPolicy() with asyncio.set_event_loop_policy before calling asyncio.run.
Unicode encoding errors on non-English Windows (issue #75, #80):
Windows systems with non-English locales (Chinese cp950, Japanese cp932, etc.) may fail with UnicodeEncodeError when outputting Unicode characters like checkmarks (✓) or emojis.
Symptoms:
UnicodeEncodeError: 'cp950' codec can't encode character- Error occurs when printing status output with Rich tables
Solution: The library automatically sets PYTHONUTF8=1 at CLI startup. For Python API usage, either:
- Set
PYTHONUTF8=1environment variable before running - Run Python with
-X utf8flag:python -X utf8 your_script.py
Path issues:
- Use forward slashes or raw strings:
r"C:\path\to\file" - Ensure
~expansion works: usePath.home()in Python
Browser opens in Windows, not WSL:
- This is expected behavior
- Storage file is saved in WSL filesystem
notebooklm-py provides structured logging to help debug issues. The variables below are the logging-relevant subset; for the full environment-variable reference (storage, profile, network, decoder strictness, RPC overrides, etc.) and precedence rules, see docs/configuration.md#environment-variables.
Environment Variables (logging-specific):
| Variable | Default | Effect |
|---|---|---|
NOTEBOOKLM_LOG_LEVEL |
WARNING |
Set to DEBUG, INFO, WARNING, or ERROR |
NOTEBOOKLM_DEBUG_RPC |
(unset) | Legacy: Set to 1 to enable DEBUG level |
NOTEBOOKLM_DEBUG |
(unset) | Set to 1 to preserve the full raw RPC response body on RPCError.raw_response (default: truncated to 80 chars + "...") |
When to use each level:
# WARNING (default): Only show warnings and errors
notebooklm list
# INFO: Show major operations (good for scripts/automation)
NOTEBOOKLM_LOG_LEVEL=INFO notebooklm source add https://example.com
# Output:
# 14:23:45 INFO [notebooklm._sources] Adding URL source: https://example.com
# DEBUG: Show all RPC calls with timing (for troubleshooting API issues)
NOTEBOOKLM_LOG_LEVEL=DEBUG notebooklm list
# Output:
# 14:23:45 DEBUG [notebooklm._core] RPC LIST_NOTEBOOKS starting
# 14:23:46 DEBUG [notebooklm._core] RPC LIST_NOTEBOOKS completed in 0.842sProgrammatic use:
import logging
import os
# Set before importing notebooklm
os.environ["NOTEBOOKLM_LOG_LEVEL"] = "DEBUG"
from notebooklm import NotebookLMClient
# Now all notebooklm operations will log at DEBUG levelStart simple to isolate issues:
# 1. Can you list notebooks?
notebooklm list
# 2. Can you create a notebook?
notebooklm create "Test"
# 3. Can you add a source?
notebooklm source add "https://example.com"If you suspect network issues:
import httpx
# Test basic connectivity
async with httpx.AsyncClient() as client:
r = await client.get("https://notebook.google.com")
print(r.status_code) # Should be 200 or 302The MCP and REST servers have their own setup and failure modes documented with the features:
- MCP server / remote connector (stdio & HTTP transports, self-hosted OAuth, Cloudflare / Tailscale tunnels): see mcp-guide.md#troubleshooting and deploy/README.md.
- REST server (
notebooklm-server): a non-loopback bind refuses to start withoutNOTEBOOKLM_SERVER_TOKEN; every/v1request needs the bearer (401 otherwise). See installation.md#rest-api-server. curl_cffitransport:NOTEBOOKLM_TRANSPORT=curl_cffirequires thecurl_cffipackage; see the region / anti-abuse gate section above.
Cause: The MCP host fetches the server's tool list (tools/list) when it first
connects and caches it against the connector. After you upgrade a deployed
server to a version that renamed or folded a tool, the host keeps advertising
the old manifest, so a call to a since-removed tool reaches the server and fails
cleanly with Unknown tool: '<name>'. This is expected MCP-host caching, not a
server bug — the server's live manifest is correct (you can confirm with
await Client(create_server()).list_tools()). A server can't notify a host that
isn't connected, and notifications/tools/list_changed only reaches a live
session.
Solution: remove and re-add the connector in the host's connector settings.
A reconnect / re-auth / off-and-on toggle is often not enough — several
hosts (notably ChatGPT) re-authenticate but keep the cached tool list and do not
re-call tools/list, so the ghost tool persists across reconnects. Fully deleting
the connector and adding it again forces a fresh tools/list. (Newly-added
optional parameters on existing tools forward through the stale schema and keep
working without any refresh — only removed/renamed tools ghost.)
Only removed or renamed tools ghost this way — new optional parameters
forward through the stale schema. For example, when source_upload_bytes was folded
into source_add(bytes_base64=…) (and studio_get_prompt into
studio_list(item=…)), the removed tool names failed with Unknown tool, but
source_add with the new bytes_base64 argument reached the upgraded server and
worked without a reconnect.
Cause (fixed in the version that shipped #2330): the server used to open its
NotebookLMClient — cookie rotation plus the CSRF fetch, and the cold-recovery
ladder when those fail — before answering the MCP initialize handshake. That
auth work has a larger budget (a 15 s RotateCookies poke plus a 30 s CSRF
fetch, more on the recovery rungs) than the 30 s deadline hosts give the
handshake, so a slow or rate-limited Google looked to the host like a dead
server: CONNECT_TIMEOUT, with no way to tell "still working" from "stuck".
Each retry spawned a fresh process that redid the same work, which is why it
took several attempts (and, once cookies were warm, eventually succeeded).
The client is now opened lazily and in the background: the handshake answers
immediately, and the first tool call awaits the auth round-trip instead. An auth
problem now arrives as a normal tool error (AUTH: …) naming the real cause, and
the next call retries the open — so re-running notebooklm login recovers a
running server without restarting it.
If you still see CONNECT_TIMEOUT: you are on an older server. Upgrade, and
check the host's MCP logs (the server logs to stderr) for the real error. It is
not an auth problem in the client — the handshake never reaches auth.
- Check this troubleshooting guide
- Search existing issues
- Open a new issue with:
- Command/code that failed
- Full error message
- Python version (
python --version) - Library version (
notebooklm --version) - Operating system