Skip to content

Latest commit

 

History

History
1044 lines (757 loc) · 58.1 KB

troubleshooting.md

File metadata and controls

1044 lines (757 loc) · 58.1 KB

Troubleshooting

Status: Active Last Updated: 2026-09-02

Common issues, known limitations, and workarounds for notebooklm-py.

Common Errors

Android backend

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.

Authentication Errors

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/CD

This 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

Automatic Token Refresh

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:
    1. Fetches fresh CSRF token and session ID from the NotebookLM homepage
    2. Waits briefly to avoid rate limiting
    3. 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.

Cookie freshness for long-running / unattended use

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:

  1. Per-call rotation poke (default ON) — every fetch_tokens makes a best-effort POST to accounts.google.com/RotateCookies. Disable with NOTEBOOKLM_DISABLE_KEEPALIVE_POKE=1.
  2. Periodic background poke — pass keepalive=<seconds> to NotebookLMClient for clients held open for hours.
  3. Layer-3 headless re-auth — explicit Python opt-in via NotebookLMClient.from_storage(allow_headless=True) or await client.refresh_auth(allow_headless=True), one-command opt-in via notebooklm auth refresh --allow-headless, or automatic opt-in with NOTEBOOKLM_HEADLESS_REAUTH=1. This drives the persisted browser profile, or attaches to a loopback Chrome DevTools endpoint from NOTEBOOKLM_HEADLESS_REAUTH_CDP_URL. Treat CDP as account-equivalent: only use 127.0.0.1 / localhost, never a remote browser.
  4. Layer-4 master-token re-mint — when a master_token.json sits beside the profile's file-backed storage_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 refresh mints and passively validates it from the sibling token. See installation.md#d-headless-server-or-ci.
  5. External recovery script — NOTEBOOKLM_REFRESH_CMD runs when auth has fully expired, then retries once.
  6. Manual re-login — notebooklm login; the legacy notebooklm login --master-token-refresh remains for an unconditional master-token re-mint.
  7. External scheduler — notebooklm auth refresh driven 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-bootstrap means Google rejected the stored token or it was explicitly revoked; re-run notebooklm 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 gpsoauth means 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-token sign-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, then notebooklm 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 the oauth_token.
  • Capture the token manually: in a normal browser sign in at accounts.google.com/EmbeddedSetup, copy the oauth_token cookie (DevTools → Application → Cookies → accounts.google.com), then notebooklm login --master-token --account you@gmail.com --oauth-token <value>. The oauth_token is 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.

macOS: --browser-cookies prompts for your password

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:

  1. 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 venv again) usually changes the interpreter path and you'll be re-prompted once for the new path.

  2. Use Touch ID instead of typing the password. macOS Sonoma+ accepts Touch ID for Keychain dialogs — see System Settings → Touch ID & Password.

  3. Pre-unlock the login keychain in your shell (best for cron jobs after one initial interactive run):

    security unlock-keychain ~/Library/Keychains/login.keychain-db

    Prompts 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.

  4. Use Firefox as the cookie source. Firefox stores cookies in a plain SQLite DB (no Keychain), so notebooklm login --browser-cookies firefox runs 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 firefox will 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.

  5. Truly headless servers. --browser-cookies is not the right tool — there's no live browser to extract from. Either re-extract on a workstation and ship storage_state.json to 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 cookies OSID / APISID + SAPISID the automatic RotateCookies recovery needs to re-mint it). When that happens the saved storage_state.json is genuinely incomplete and re-running the same flow reproduces it.
  • notebooklm login --browser-cookies chrome (or edge) → 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 just notebooklm-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:

  1. 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::none syntax — see the macOS section above for the container notes.) This is the simplest fix for the ABE case.

  2. Set up a master token (best for unattended / long-lived use). notebooklm login --master-token --account you@example.com (needs gpsoauth, supplied by the [headless] or [android] extra) stores a durable master_token.json beside 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 manual oauth_token variants 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. Plain notebooklm login --master-token --account you@example.com launches a headed Playwright browser to capture the single-use oauth_token, so on a machine where the browser cannot start at all (see spawn UNKNOWN below) 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 stored master_token.json.

  3. 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.

Windows: browser fails to start with spawn UNKNOWN

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:

  1. 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
  2. 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 login on a machine with a display, then copy storage_state.json over (or set NOTEBOOKLM_AUTH_JSON) — see installation.md#d-headless-server-or-ci.
  3. Have IT allow-list the directory. Permit execution from %LOCALAPPDATA%\ms-playwright (or wherever PLAYWRIGHT_BROWSERS_PATH points), 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.

"Unauthorized" or redirect to login page

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 login

"Failed to extract CSRF token (SNlM0e)" / "CSRF token not found in HTML"

Cause: 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 by refresh_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 as AuthExtractionError directly (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.com links 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.

"Google's CookieMismatch page was reached during this request"

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.json whose per-cookie domain values were flattened to a single host, so cookies scoped to .google.com get 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-1PSIDTS that Google could not reconcile.

Solution:

notebooklm login   # re-extract cookies with their original domains

If 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.

"NotebookLM redirected this request to its region / anti-abuse access gate"

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.)

Browser opens but login fails

Cause: Google detecting automation and blocking login.

Solution:

  1. Delete the browser profile: rm -rf ~/.notebooklm/profiles/<profile>/browser_profile/ (or ~/.notebooklm/profiles/default/browser_profile/ for the default profile)
  2. Run notebooklm login again
  3. Complete any CAPTCHA or security challenges Google presents
  4. Ensure you're using a real mouse/keyboard (not pasting credentials via script)

"Login not detected within 5 minutes" (especially on macOS)

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.

Configuration Errors

ValueError: NOTEBOOKLM_BASE_URL must use https and one of: ...

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.com

Both 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.

RPC Errors

"RPCError: No result found for RPC ID: XyZ123"

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

RPC method ID rotated by Google — self-patch with NOTEBOOKLM_RPC_OVERRIDES

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 list

Or 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 body f.req payload, 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, or notebooklm.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 WARNING and treated as no overrides.
  • The first time a distinct override set is applied in a process, the mapping is logged at INFO so 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.

How to get the full response preview from an RPCError

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).

"RPCError: [3]" (Invalid argument) / "UserDisplayableError"

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 .har itself. A HAR contains your cookies, the at= 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 single f.req line from Option B). The scrubber processes only /batchexecute calls 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.

  1. 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.

  2. 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).

  3. 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.

  4. 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.)

  5. Scrub it. From your notebooklm-py checkout, 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 --rpcid if the capture is noisy:

    $ python scripts/scrub_rpc_har.py capture.har --rpcid CCqFvf

    The script reads only each request's f.req field and the response body — never the headers/cookies arrays, never the non-batchexecute page 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.

  6. 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] with result=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.

  1. In DevTools → Network, click the batchexecute?rpcids=… POST for the failing call (e.g. rpcids=CCqFvf for create, izAoDd for add-source).
  2. Open the Payload tab (Chrome) / Request tab (Firefox), copy only the f.req value — not the at= field beside it, and don't open the Cookies/Headers tabs.
  3. Replace any free text (title, URL) with REDACTED and paste it. We diff it against what the library sends — ["<title>", null, null, [2], [1]] for create — and update the payload.

Generation Failures

Audio/Video generation is refused immediately

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> --json

In Python, catch RateLimitError, ArtifactFeatureUnavailableError, or RPCError depending on the failure. If kickoff succeeds and later polling times out, use the timeout guidance below.

Audio/Video task times out as pending or in progress

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 --timeout or the Python wait_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 --timeout if your account's media queue is slower.
  • artifact wait is intentionally generic and still defaults to 300s; when waiting manually on a media task ID, pass the matching media timeout.
  • Catch ArtifactPendingTimeoutError to retry queued tasks separately from ArtifactInProgressTimeoutError, which means the task started but did not finish before the timeout.
  • Log exc.status_history and exc.status_transitions for upstream queueing diagnostics instead of parsing the exception message.

Mind map or data table "generates" but doesn't appear

Cause: Generation may silently fail without error.

Solution:

  • Wait 60 seconds and check artifact list
  • Try regenerating with different/fewer sources

File Upload Issues

HTML/XHTML files are rejected before upload

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.txt

You can also pipe extracted text through stdin:

python extract_article_text.py ./article.html | notebooklm source add - --type text --title "Article"

Text/Markdown upload succeeds but processing/content is wrong

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.txt

Or in Python:

content = Path("notes.txt").read_text()
await client.sources.add_text(nb_id, "My Notes", content)

Large files time out

Cause: Files over ~20MB may exceed upload timeout.

Solution: Split large documents or use text extraction locally.


Protected Website Content Issues

X.com / Twitter content incorrectly parsed as error page

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.md

Alternative methods:

Using browser automation:

# If you have playwright/browser-use available
# Fetch content via browser and save as markdown

Manual extraction:

  1. Open the X.com post in a browser
  2. Copy the text content
  3. Save to a .md file
  4. 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 message

If 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 above

Other affected sites:

  • Some paywalled news sites
  • Sites requiring JavaScript execution for content
  • Sites with aggressive bot detection

Known Limitations

Rate Limiting

Google enforces strict rate limits on the batchexecute endpoint.

Symptoms:

  • RateLimitError in Python, or CLI JSON with code: "RATE_LIMITED"
  • RPCError with ID R7cb6c
  • UserDisplayableError with 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 commands

Note: 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"))

Starting a brand-new conversation (resolves the older issue #659 workaround)

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.

Quota Restrictions

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

Download Requirements

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 machine

Custom 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.

URL Expiry

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 immediately

Platform-Specific Issues

Linux

Playwright missing dependencies:

playwright install-deps chromium

playwright 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 global playwright executable
  • if you already have another playwright on your system, verify with which playwright after activation
  • using pip here (not uv sync --frozen) is deliberate: this workaround needs to override the project's resolved playwright version with a specific older release, which the locked uv flow 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

macOS

Chromium not opening:

# Re-install Playwright browsers
playwright install chromium

Security warning about Chromium:

  • Allow in System Preferences → Security & Privacy

Windows

Login problems? Two Windows-specific login failures are covered under Authentication Errors above: spawn UNKNOWN when the browser will not start, and Could 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 login works, but notebooklm list and 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"   # PowerShell
set NOTEBOOKLM_WINDOWS_EVENT_LOOP=selector

Only 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:

  1. Set PYTHONUTF8=1 environment variable before running
  2. Run Python with -X utf8 flag: python -X utf8 your_script.py

Path issues:

  • Use forward slashes or raw strings: r"C:\path\to\file"
  • Ensure ~ expansion works: use Path.home() in Python

WSL

Browser opens in Windows, not WSL:

  • This is expected behavior
  • Storage file is saved in WSL filesystem

Debugging Tips

Logging Configuration

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.842s

Programmatic 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 level

Test Basic Operations

Start 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"

Network Debugging

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 302

Adapter-specific issues (MCP server, REST server)

The 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 without NOTEBOOKLM_SERVER_TOKEN; every /v1 request needs the bearer (401 otherwise). See installation.md#rest-api-server.
  • curl_cffi transport: NOTEBOOKLM_TRANSPORT=curl_cffi requires the curl_cffi package; see the region / anti-abuse gate section above.

"Unknown tool" from an MCP host (claude.ai, ChatGPT, …) after upgrading the server

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.

MCP server CONNECT_TIMEOUT on connect ("connection timed out after 30000ms")

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.

Getting Help

  1. Check this troubleshooting guide
  2. Search existing issues
  3. Open a new issue with:
    • Command/code that failed
    • Full error message
    • Python version (python --version)
    • Library version (notebooklm --version)
    • Operating system