Files
wehub-resource-sync 09e9f3545f
CodeQL / Analyze (push) Waiting to run
dependency-audit / pip-audit (push) Waiting to run
Test / Code Quality (push) Has been cancelled
Test / Test (macos-latest, Python 3.10) (push) Has been cancelled
Test / Test (macos-latest, Python 3.11) (push) Has been cancelled
Test / Test (macos-latest, Python 3.12) (push) Has been cancelled
Test / Test (macos-latest, Python 3.13) (push) Has been cancelled
Test / Test (macos-latest, Python 3.14) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.10) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.11) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.12) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.13) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.14) (push) Has been cancelled
Test / Test (windows-latest, Python 3.10) (push) Has been cancelled
Test / Test (windows-latest, Python 3.11) (push) Has been cancelled
Test / Test (windows-latest, Python 3.12) (push) Has been cancelled
Test / Test (windows-latest, Python 3.13) (push) Has been cancelled
Test / Test (windows-latest, Python 3.14) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:30:13 +08:00

58 KiB
Raw Permalink Blame History

Contributing Guide

Status: Active Last Updated: 2026-07-04

This guide covers everything you need to contribute to notebooklm-py: architecture overview, testing, and releasing.

New contributor? Start with CONTRIBUTING.md at the repo root for the install/lint/test workflow and PR conventions, then come back here for architectural context once you're ready to write code.


Architecture

Canonical post-refactor map: see docs/architecture.md for the current adapter/app/client/runtime/RPC graph and capability-protocol model. This section remains as the contributor on-ramp (package layout + adding-features guidance) and links out to the architecture doc rather than duplicating it.

Package Structure

src/notebooklm/
├── __init__.py          # Public exports
├── client.py            # NotebookLMClient main class
├── auth.py              # Public auth facade
├── types.py             # Dataclasses and type definitions
├── _app/                # Transport-neutral business logic shared by adapters
├── _client_composed.py  # Client-owned composition holder
├── _runtime/            # Runtime contracts, config, lifecycle, auth, transport
├── _notebooks.py        # NotebooksAPI implementation
├── _notebook_metadata.py # Private notebook metadata composition service
├── _sources.py          # SourcesAPI implementation
├── _source/             # Private source services
├── _artifacts.py        # ArtifactsAPI implementation
├── _artifact/           # Private artifact services
├── _chat/               # ChatAPI implementation (facade + chat helpers)
├── _research.py         # ResearchAPI implementation
├── _notes.py            # NotesAPI implementation
├── _mind_map.py         # Private note-backed mind-map service
├── _mind_maps_api.py    # MindMapsAPI implementation
├── _labels.py           # LabelsAPI implementation
├── _settings.py         # SettingsAPI implementation
├── _sharing.py          # SharingAPI implementation
├── _sharing_manager.py  # Private legacy notebook share-link service
├── rpc/                 # RPC protocol layer
│   ├── __init__.py
│   ├── types.py         # RPCMethod enum and constants
│   ├── encoder.py       # Request encoding
│   └── decoder.py       # Response parsing
├── cli/                 # Click adapter (`*_cmd.py`) plus `cli/services/`
├── mcp/                 # FastMCP adapter (optional `mcp` extra)
└── server/              # FastAPI REST adapter (optional `server` extra)

Layered Architecture

┌─────────────────────────────────────────────────────────────┐
│                      Adapter Layer                          │
│        cli/ (Click), mcp/ (FastMCP), server/ (FastAPI)       │
└───────────────────────────┬─────────────────────────────────┘
                            │
┌───────────────────────────▼─────────────────────────────────┐
│                  App Core Layer (`_app/`)                    │
│        transport-neutral request/plan/result workflows       │
└───────────────────────────┬─────────────────────────────────┘
                            │
┌───────────────────────────▼─────────────────────────────────┐
│                      Client Layer                           │
│  NotebookLMClient → NotebooksAPI, SourcesAPI, ArtifactsAPI  │
│       private services compose cross-facade behavior         │
└───────────────────────────┬─────────────────────────────────┘
                            │
┌───────────────────────────▼─────────────────────────────────┐
│                      Runtime Layer                          │
│          RpcExecutor, RuntimeTransport, Kernel, lifecycle    │
└───────────────────────────┬─────────────────────────────────┘
                            │
┌───────────────────────────▼─────────────────────────────────┐
│                        RPC Layer                            │
│        encoder.py, decoder.py, types.py (RPCMethod)         │
└─────────────────────────────────────────────────────────────┘

Layer Responsibilities

Layer Files Responsibility
Adapters cli/, mcp/, server/ User commands/tools/routes, transport-specific input/output, auth envelopes
App core _app/*.py Transport-neutral workflows reused by adapters
Client client.py, _*.py High-level Python API, returns typed dataclasses
Runtime client.py, _client_composed.py, _runtime/init.py, _kernel.py, runtime collaborators NotebookLMClient composition root plus seam-module helpers (HTTP client lifecycle, RPC dispatch, metrics, drain bookkeeping, request-id counter, auth refresh, conversation cache, polling registry, cookie persistence)
RPC rpc/*.py Protocol encoding/decoding, method IDs

Runtime seam modules

The client runtime is split across NotebookLMClient (composition root), ClientComposed (holder), _runtime/init.py (construction helpers), _kernel.py (HTTP client owner), and single-responsibility collaborator modules. (The legacy _core.py compatibility shim was deleted in v0.5.0; callers import directly from the canonical modules.) Each helper exposes a narrow Protocol surface so it can be unit-tested against a stub:

Module Class Responsibility
_client_composed.py ClientComposed Client-owned holder for transport, executor, chain host, middleware metadata, and session collaborator bundle.
_runtime/init.py RuntimeCollaborators helpers Validates constructor args, builds collaborators, wires middleware, and binds ClientComposed.
_client_metrics.py ClientMetrics ClientMetricsSnapshot counters, queue-wait recorders, on_rpc_event async callback.
_transport_drain.py TransportDrainTracker In-flight transport counters, _TransportOperationToken, lazy asyncio.Condition powering client.drain(...).
_reqid_counter.py ReqidCounter Monotonic _reqid counter for chat backend (baseline 100000, step 100000).
_runtime/auth.py AuthRefreshCoordinator Refresh-task lifecycle, refresh lock, AuthSnapshot rotation.
_runtime/contracts.py Runtime Protocols Shared capability Protocols: Kernel, RpcCaller, and LoopGuard. Single-consumer capabilities stay local to their owner modules.
_runtime/lifecycle.py ClientLifecycle Loop-affinity guard, aclose plumbing, keepalive task wiring.
_runtime/transport.py RuntimeTransport Authenticated transport leg used by RpcExecutor and the middleware chain terminal.
_rpc_executor.py RpcExecutor RPC dispatch executor with direct collaborator dependencies.
_request_types.py AuthSnapshot, BuildRequest, request materialization Shared request construction Interface.
_transport_errors.py transport exceptions, parse_retry_after, raise_mapped_post_error Terminal Kernel.post error mapping for middleware retry/auth behavior.
_streaming_post.py stream_post_with_size_cap Low-level POST streaming and response-size guard.
_conversation_cache.py ConversationCache Per-instance true-LRU conversation cache for ChatAPI continuity. Caps the conversation count (MAX_CONVERSATION_CACHE_SIZE) and the turns retained per conversation (MAX_TURNS_PER_CONVERSATION).
_polling_registry.py PollRegistry Pending-poll registry shared by long-running artifact generations.
_cookie_persistence.py CookiePersistence Cookie-jar → storage-state serialization, __Secure-1PSIDTS rotation.

The feature-facing surface is the set of capability Protocols in notebooklm._runtime.contractsKernel, RpcCaller, and LoopGuard. Single-consumer capability shapes stay in the owning feature module (AuthMetadata in _source/upload.py, OperationScopeProvider in _artifact/polling.py), and the unused AsyncWorkRuntime composite was deleted. The broad Session Protocol that previously bundled these together was deleted in the final phase of the capability refactor (see docs/refactor-history.md and ADR-0013); each feature now depends on the narrowest slice it needs and takes those collaborators by keyword-only constructor argument. The feature-local composite-runtime Protocols (ChatRuntime, ArtifactsRuntime, UploadRuntime) and their adapter dataclasses that previously bundled three capability Protocols apiece were retired once it was clear they only hid stable collaborators with one production satisfier; see ADR-0013 for the promotion criterion (at least two production consumers) that still gates adding any new shared Protocol.

Private service modules sit inside the client layer but below the public facades. They own cross-facade composition without importing sibling facades: _notebook_metadata.py composes notebook metadata through a narrow source lister, _sharing_manager.py owns legacy SHARE_ARTIFACT link behavior, and _mind_map.py owns note-backed mind-map rows shared by notes and artifacts. Facade modules keep the public method surface stable and delegate to these services.

Boundary Guardrails

These are the same family as the Architecture & invariant gates (tests/_guardrails/) described below. The pure ones (e.g. test_cli_boundary.py) have been consolidated into tests/_guardrails/; the hybrids that pair a gate with behavioral tests (e.g. test_public_shims.py) keep their behavioral half in tests/unit/ and split the gate half into a dedicated tests/_guardrails/ file.

The architecture tests encode the current layer contract:

  • tests/_guardrails/test_public_surface_manifest.py has a documented public import manifest. When a docs change adds or removes a supported import path, update the manifest in the same PR so public API drift is intentional and reviewable. The behavioral half of the public-shim suite (the select_cited_sources / ResearchAPI back-compat delegations, the UnknownTypeWarning filter behaviour, and NotebookLMClient.rpc_call forwarding) stays in tests/unit/test_public_shims.py.
  • tests/_guardrails/test_cli_boundary.py parses src/notebooklm/cli/**/*.py and rejects CLI imports from notebooklm._*, notebooklm.rpc.*, or _private names exposed by public modules. Promote needed symbols through a public facade (notebooklm.types, notebooklm.auth, notebooklm.research, etc.) before using them from the CLI.
  • Auth internals may move under notebooklm._auth during architecture work, but first-party callers continue to import through notebooklm.auth. The compatibility manifest in tests/_guardrails/test_public_surface_manifest.py enforces the current first-party surface for that move; it is not a broader public API decision, and removing a listed name needs a separate deprecation plan.
  • tests/_guardrails/test_no_facade_reach_in.py holds the AST reach-in / runtime-import boundary gates: notebook metadata services must not import or construct SourcesAPI; artifact/source/notebook composition services must not runtime-import facade APIs. Add new private services to those guard lists when they take ownership of cross-facade behavior. The construction / init-order behaviour tests — NotebookLMClient constructs SourcesAPI before NotebooksAPI and passes it through the legacy sources_api= slot, plus the mind-map decoupling flows — stay in tests/unit/test_init_order.py.

Key Design Decisions

Why underscore prefixes? Files like _notebooks.py are internal implementation. Public API stays clean (from notebooklm import NotebookLMClient).

Why namespaced APIs? client.notebooks.list() instead of client.list_notebooks() - better organization, scales well, tab-completion friendly.

Why async? Google's API can be slow. Async enables concurrent operations and non-blocking downloads.

Naming conventions. See docs/conventions.md for the canonical tiebreakers on waiting/polling verbs (poll_X / wait_for_X / wait_until_X / await_X / _wait_for_X), RPC-callable Protocol names (NextCall / RpcCallback / RpcCaller), and metrics method verbs (record_X vs emit_X). New code should pick names from those catalogues rather than introducing parallel patterns.

Adding New Features

New RPC Method:

  1. Capture traffic (see RPC Development Guide)
  2. Add to rpc/types.py: NEW_METHOD = "AbCdEf"
  3. Implement in appropriate _*.py API class
  4. Add dataclass to types.py if needed
  5. Add CLI command if user-facing

New API Class:

  1. Create _newfeature.py with NewFeatureAPI class.
  2. Type each constructor parameter against the narrowest shared capability Protocol it actually uses (RpcCaller, LoopGuard, Kernel — see docs/architecture.md for the protocol catalog). If the capability has only one consumer, define the Protocol locally beside that consumer instead of promoting it to _runtime/contracts.py. Pass each collaborator by keyword-only argument; do not bundle them into a feature-local composite-runtime Protocol unless a second production consumer materialises. Do NOT depend on a broad runtime facade for type annotations — there is no concrete Session class (the broad Session Protocol was deleted; see ADR-0013).
  3. Add the wiring in _client_assembly.py::_assemble_client(...), not directly in client.py. The assembly seam is shared by NotebookLMClient.__init__ and the canonical test factory; set every constructor-time attribute there and thread concrete collaborators from compose_client_internals(...).
  4. Tests should inject the narrow collaborator the feature actually needs. tests/_fixtures/fake_core.py:FakeSession remains available for legacy broad-fixture tests, but new direct feature tests should prefer MagicMock(spec=RpcCaller, rpc_call=AsyncMock(...))-style fakes or local protocol fakes.
  5. Export types from __init__.py.

Concurrency Model

Multiple notebooklm processes (parallel CLI runs, an in-process keepalive beside a cron-driven notebooklm auth refresh, container start-up races, xargs -P fan-outs) can target the same NOTEBOOKLM_HOME simultaneously. The library coordinates with cross-process file locks (POSIX flock / Windows LockFileEx, via the filelock package) so reads and writes against shared on-disk state never tear or clobber a sibling's update.

All locks are sibling files next to the resource they guard (zero-byte, left on disk after release — filelock reuses them).

Lock file Owner Scope Acquisition
<profile>/storage_state.json.lock _auth/storage.py::save_cookies_to_storage Read-merge-write of storage_state.json (cookie sync after a rotation or 302) Blocking exclusive
<profile>/.storage_state.json.rotate.lock _auth/keepalive.py::_poke_session Cross-process dedup of the accounts.google.com/RotateCookies keepalive POST Non-blocking exclusive (LOCK_NB); skip on contention
<home>/.migration.lock migration.py::migrate_to_profiles One-shot legacy→profile layout migration on startup Blocking exclusive, 30s timeout (raises MigrationLockTimeoutError)
<profile>/context.json.lock _atomic_io.py::atomic_update_json through CLI context helpers Read-modify-write of the active-notebook/account-routing context for a profile Blocking exclusive, 10s timeout

Design notes:

  • Two layered storage locks (not one). The .lock and .rotate.lock files protect the same storage_state.json but serve different access patterns: a long-running save must not block — or be blocked by — a best-effort rotation poke. Keeping them separate prevents the keepalive from queueing behind a slow cookie write (and vice-versa).
  • Fail-open on lock infrastructure failure. When the lock file itself cannot be created (read-only home dir, NFS without flock, permission denied), _poke_session proceeds without coordination rather than wedging forever. A duplicate rotation across processes is bounded and harmless; a permanently-suppressed rotation is not.
  • Locks are sibling files, never the resource itself. filelock reuses the sentinel across invocations, so cleanup is not required — and a TOCTOU race between unlink and reacquire is avoided.
  • In-process serializers complement, not replace, file locks. _auth/keepalive.py::_poke_session also takes an asyncio.Lock keyed on (event_loop, profile) to dedupe an asyncio.gather fan-out before reaching the cross-process flock — the file lock only sees one contender per process per rate-limit window.

Path resolution for all locked resources flows through paths.py (get_storage_path, get_context_path, get_home_dir), so a --storage override or a different NOTEBOOKLM_PROFILE automatically yields a distinct lock sibling and the two invocations never contend.


Testing

Prerequisites

  1. Install dependencies (canonical contributor flow — see docs/installation.md#e-contributor for details):

    uv sync --frozen --extra browser --extra dev --extra markdown
    uv run playwright install chromium
    uv run pre-commit install
    

    The browser extra is required for the default uv run pytest suite because several unit tests import and patch playwright.sync_api. The command uv sync --frozen --extra dev installs the test tools, but not Playwright.

    Adapter tests need their extras. The MCP unit tests (tests/unit/mcp/) and REST suite (tests/server/) importorskip fastmcp / fastapi, so without --extra mcp --extra server they silently skip — you'll see a green run that never exercised the adapter surface. Add both extras (CI installs --extra mcp --extra server --extra impersonate) to run them.

    CI runs the same lint gate with uv run pre-commit run --all-files, so local hook results should match the quality job.

  2. Authenticate:

    notebooklm login
    
  3. Create read-only test notebook (required for E2E tests):

    • Create notebook at NotebookLM
    • Add multiple sources (text, URL, etc.)
    • Generate artifacts (audio, quiz, etc.)
    • Set env var: export NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID="your-id"

Quick Reference

# Unit + integration tests (no auth needed)
uv run pytest

# Same suite in parallel — the full suite is ~10.6k tests and runs single-process
# by default (slow). pytest-xdist (a dev dep) cuts wall-clock to ~12 min. CI runs
# `-n auto --dist loadgroup`; loadgroup keeps @pytest.mark.xdist_group tests pinned
# to one worker. Mirror it locally when running the whole suite.
uv run pytest -n auto --dist loadgroup

# Fast local loop — skip repo-wide audit / release-gate checks (~40s saved).
# CI still runs these; the marker just lets you iterate quickly.
uv run pytest tests/unit tests/integration -m "not repo_lint"

# E2E tests (requires auth + test notebook)
uv run pytest tests/e2e -m readonly        # Read-only tests only
uv run pytest tests/e2e -m "not variants"  # Skip parameter variants
uv run pytest tests/e2e --include-variants # All tests including variants

# Select a profile for E2E tests
uv run pytest tests/e2e -m e2e --profile work

The repo_lint marker tags cassette-shape lint, public-surface scans, docstring/install-doc drift guards, version-sync, and CI-script audits. These are valuable release/CI guardrails but cost ~3045s locally. See CONTRIBUTING.md for the canonical fast-loop guidance.

Selecting a profile for E2E tests

The E2E suite picks up the active NotebookLM profile from (highest precedence first):

  1. --profile <name> pytest flag
  2. NOTEBOOKLM_PROFILE environment variable
  3. default_profile from ~/.notebooklm/config.json
  4. default

The auto-created notebook ID cache files (generation_notebook_id, multi_source_notebook_id) are written under the active profile directory (~/.notebooklm/profiles/<name>/), so each profile keeps its own cache and never reuses notebook IDs from another Google account.

Notebook ID env vars are profile-agnostic

The notebook ID env vars (NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID, NOTEBOOKLM_GENERATION_NOTEBOOK_ID, NOTEBOOKLM_MULTI_SOURCE_NOTEBOOK_ID) are not profile-scoped — they're read as-is regardless of which profile is active. If you set them in .env and switch profiles, the test will try to access notebooks that don't exist in the other Google account.

Recommendation: leave the generation/multi-source env vars unset and let the per-profile cache files handle it. Only NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID needs to be set; if you switch profiles often, override it inline:

NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID=<work-nb-id> \
  uv run pytest tests/e2e -m e2e --profile work

Test Structure

tests/
├── unit/                            # No network, fast, mock everything
│   ├── app/                         # _app/ transport-neutral core
│   ├── cli/                         # CLI command tests
│   └── mcp/                         # MCP adapter unit tests (importorskip fastmcp)
├── server/                          # REST adapter suite — FastAPI routes (importorskip fastapi)
├── _guardrails/                     # Architecture/invariant gates (custom AST + filesystem lint)
├── _baselines/                      # Regenerable-baseline registry (ADR-0022): derive/store/compare
├── fixtures/
│   └── baselines/                   # Committed derived baselines (types_all.json, ungated_surface.json)
├── integration/                     # Mocked HTTP responses + VCR cassettes
│   ├── test_artifacts_integration.py # ArtifactsAPI integration
│   ├── test_artifacts_drift.py      # CREATE_ARTIFACT payload drift guard
│   ├── test_auth_refresh_vcr.py     # Auth refresh token VCR test
│   ├── test_auto_refresh.py         # Keepalive/refresh integration
│   ├── test_chat_delete_conversation_vcr.py
│   ├── test_chat_multi_source_vcr.py
│   ├── test_chat_passage_resolver.py
│   ├── test_cli_session_local.py
│   ├── test_download_multi_artifact.py
│   ├── test_error_paths_vcr.py      # Synthetic and VCR error paths
│   ├── test_get_summary_drift.py    # GET_NOTEBOOK_SUMMARY drift guard
│   ├── test_notebooks_integration.py # NotebooksAPI integration
│   ├── test_notes_integration.py     # NotesAPI integration
│   ├── test_notes_idempotency.py
│   ├── test_polling_vcr.py
│   ├── test_research_deep_poll_vcr.py
│   ├── test_research_idempotency.py
│   ├── test_save_chat_as_note_integration.py
│   ├── test_session_integration.py  # Client init + RPC plumbing
│   ├── test_settings_integration.py  # SettingsAPI integration
│   ├── test_settings_vcr.py
│   ├── test_sharing_integration.py   # SharingAPI integration
│   ├── test_sharing_vcr.py
│   ├── test_skill_packaging.py      # Packaging smoke (skills, entry-points)
│   ├── test_sources_integration.py   # SourcesAPI integration
│   ├── test_vcr_comprehensive.py    # End-to-end VCR walkthrough
│   ├── test_vcr_example.py          # VCR pattern reference
│   ├── test_vcr_real_api.py         # VCR against real-API cassettes
│   ├── cli_vcr/                     # CLI → Client → RPC VCR tests
│   ├── mcp_vcr/                     # MCP adapter VCR tier (replays CLI cassettes)
│   └── concurrency/                 # Cross-process / asyncio races
└── e2e/                             # Real API calls (requires auth; incl. test_mcp*.py, test_cli_live.py)

The *_drift.py tests are payload-shape canaries: they decode a recorded RPC response (or assemble a synthetic one) and assert the live decoder still produces the expected dataclass. They fail loudly when Google changes a payload field, so the failure shows up here before users hit it.

Architecture & invariant gates (tests/_guardrails/)

tests/_guardrails/ holds the project's custom lint gates — pytest tests that enforce architectural decisions a general-purpose linter can't express. They are not style checks; each file encodes one project-specific invariant, usually the executable half of an ADR ("enforce, don't document" — un-enforced consistency is the failure mode this directory exists to prevent).

What belongs here vs tests/unit/. This directory is the home for a pure gate — a file whose whole purpose is enforcing a repo-wide invariant, with no module-under-test. A unit test that only embeds a boundary assertion among behavioral checks stays in tests/unit/ (see Boundary Guardrails above). Pure architecture gates — e.g. test_cli_boundary.py, test_cassette_shapes.py, test_public_surface.py — have been consolidated into this directory; the gate halves of former hybrids live alongside them (e.g. test_public_surface_manifest.py, test_no_facade_reach_in.py).

How they differ from ruff / mypy. Ruff and mypy run in the quality job and enforce generic rules (style, unused imports, types) from a fixed catalogue. The tests/_guardrails/ gates are collected by the normal uv run pytest run and enforce bespoke rules by doing their own analysis: most parse the source with ast.parse (or scan files with regex / rglob), and some import the module and reflect on the live object — something a purely-static linter cannot do.

A representative slice (run ls tests/_guardrails/ for the full set):

Gate Enforces
test_no_raw_positional_rpc_indexing.py No chained positional indexing (x[0][9][3]) of batchexecute payloads outside the sanctioned _row_adapters/ — the project's #1 fragility class
test_rpc_method_ids_only_in_types.py Obfuscated RPC IDs live only in rpc/types.py (the source of truth)
test_no_forbidden_monkeypatches.py The forbidden monkeypatch shapes under tests/ (ADR-0007)
test_no_inline_deprecation_warnings.py No inline warnings.warn(..., DeprecationWarning) outside _deprecation.py (ADR-0018)
test_cli_rpc_envelope.py Every RPC-touching Click leaf command (call graph reaches NotebookLMClient) routes its errors into the JSON envelope
test_module_size_ratchet.py No module grows past the size budget (ADR-0008) — a burn-down ratchet
test_v080_release_gate.py The v0.8.0 breaking-change set flips in lockstep at the version bump
test_adr_reference_format.py ADR references are 4-digit and resolve to a real docs/adr/NNNN-*.md
test_cli_boundary.py CLI modules import only public notebooklm surface — no notebooklm._* / notebooklm.rpc.* / _private reach-in
test_no_facade_reach_in.py Feature APIs and service modules don't reach into Session internals or runtime-import facade APIs
test_public_surface_manifest.py The documented public-import manifest + re-export identity pins for notebooklm / auth / types / shims stay intact

Conventions when adding a gate:

  • One invariant per file, with a module docstring that states the rule, why it matters (cite the ADR), and how a violation is fixed. The assertion message is the contributor's first — and often only — explanation, so make it actionable.
  • Make the detector a pure function and self-test it against known good/bad inputs in the same file, so the gate can't silently become vacuous (a regex that matches nothing must fail its own self-test, not pass everything).
  • Shrink-only allowlists. A gate that would fail on pre-existing violations may grandfather them in an allowlist — but it must be a one-way ratchet that only shrinks (e.g. test_module_size_ratchet.py, tests/scripts/check_method_coverage.py). The rule lands without a giant cleanup PR, and the gate fails when an allowlisted entry becomes clean so it gets removed.
  • Scan yourself too. A gate that shows the wrong form in its examples should use placeholders (or build them at runtime) rather than excluding its own file, so it still polices its own references (test_adr_reference_format.py).

Most gates are fast and run in the normal loop; the slow repo-wide cassette scan (test_cassettes_clean.py) carries the repo_lint marker (see Quick Reference).

Trade-off. Because some gates import internals and reflect on them, they couple more tightly to implementation than a static linter — a behavior-preserving refactor can still trip one. That coupling is deliberate: it catches architecture drift that ruff and mypy structurally cannot see.

Updating baselines

Some gates freeze a snapshot of a value the code already derives, so a public surface change is a deliberate, diff-visible act. These regenerable baselines (ADR-0022) are registered in tests/_baselines/registry.py and committed under tests/fixtures/baselines/ (plus the CLI contract at tests/fixtures/cli_contract_baseline.json):

Baseline Derives from Committed file
types_all notebooklm.types.__all__ tests/fixtures/baselines/types_all.json
ungated_surface collected __all__ of each ungated public module tests/fixtures/baselines/ungated_surface.json
cli_contract build_cli_contract() tests/fixtures/cli_contract_baseline.json

The freeze test test_baseline_matches_committed_file (in tests/_guardrails/test_public_surface_manifest.py) asserts each committed file equals derive(). When you intentionally change a public surface — e.g. add an export to notebooklm.types.__all__ or a CLI option — that test fails. Regenerate the committed files in the same PR:

python scripts/regen_baselines.py
git diff tests/fixtures/baselines tests/fixtures/cli_contract_baseline.json

Review the diff — each changed line is the deliberate acknowledgement of the surface change. Regen is dev-only: it shells pytest … --update-baselines, which both the wrapper and the update_baselines fixture refuse to run when a CI environment is detected. CI never regenerates — it only diffs.

VCR Testing (Recorded HTTP)

VCR tests record HTTP interactions for offline, deterministic replay. We have two levels:

Client-level VCR tests (tests/integration/test_vcr_*.py):

  • Test Python API methods directly
  • Verify RPC encoding/decoding with real responses

CLI VCR tests (tests/integration/cli_vcr/):

  • Test the full CLI → Client → RPC path
  • Use Click's CliRunner with VCR cassettes
  • Verify CLI commands work end-to-end without mocking the client
# Run all VCR tests
uv run pytest tests/integration/

# Run only CLI VCR tests
uv run pytest tests/integration/cli_vcr/

Sensitive data (cookies, tokens, emails) is automatically scrubbed from cassettes.

Cassette recording

Maintainers re-record cassettes against the live API when an RPC payload shape changes. Recording is opt-in (NOTEBOOKLM_VCR_RECORD=1) and requires a valid notebooklm login session.

Two notebook env vars steer which notebook the recording session targets. Neither UUID is committed — both are per-maintainer secrets (notebook IDs are linkable to a Google account):

Env var Used by Notebook role
NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID read-heavy cassettes (list, download, get) A maintainer-owned notebook pre-populated with sources + artifacts. Tests only READ from it.
NOTEBOOKLM_GENERATION_NOTEBOOK_ID mutation/generation cassettes (add source, generate, delete) A separate maintainer-owned notebook used only for destructive/generation flows, so the read-only notebook stays pristine.

One-time setup — generation notebook

Run the setup script once per Google account that records cassettes:

uv run python tests/scripts/setup-generation-notebook.py

The script is idempotent: it reuses the notebook whose title matches GENERATION_NOTEBOOK_TITLE (defined in tests/scripts/setup-generation-notebook.py) if one already exists, otherwise creates it. It prints the notebook UUID and an export line. Copy the export line into your maintainer environment (e.g. ~/.zshrc or a profile-specific .env file you do NOT commit):

export NOTEBOOKLM_GENERATION_NOTEBOOK_ID=<printed-uuid>

The script is a manual maintainer helper — CI never runs it.

Recording a cassette

# Re-record (or record-new) cassettes; sensitive data auto-scrubbed
NOTEBOOKLM_VCR_RECORD=1 uv run pytest tests/integration/test_vcr_*.py -v

Recording reads your real ~/.notebooklm profile. Normally the suite pins NOTEBOOKLM_HOME at a throwaway tmp dir (autouse _isolate_notebooklm_home in tests/conftest.py) so runs are reproducible and never touch your real profile. Under NOTEBOOKLM_VCR_RECORD=1, @pytest.mark.vcr tests instead read the real profile — so get_vcr_auth() (via AuthTokens.from_storage()) and the CLI auth path resolve live credentials to record against. CLI-VCR tests additionally skip their mock_auth_for_vcr patch in record mode for the same reason. Replay runs and non-VCR tests stay isolated (a stray NOTEBOOKLM_VCR_RECORD on a normal run never un-isolates a non-VCR test). The test must carry the vcr marker — most do via a module-level pytestmark. Before #1263 this deferral did not exist and cassettes could only be recorded with a standalone script.

Limitation: a few cli_vcr tests (settings / profile / doctor) re-pin NOTEBOOKLM_HOME at their own tmp dir to isolate config/profile writes. They override this deferral and so are not auto-recordable through pytest — re-record those with a standalone script (or, as a future enhancement, inject NOTEBOOKLM_AUTH_JSON from the real storage so auth is resolved independently of NOTEBOOKLM_HOME).

The scrubbing pipeline (tests/vcr_config.py) redacts cookies, CSRF tokens, emails, and other sensitive patterns before the cassette hits disk. Verify the result with the cassette guard before committing:

# Verify recorded cassettes are clean of credentials
uv run python tests/scripts/check_cassettes_clean.py

Long-running recordings (deep research, multi-minute polling)

Recording a cassette that polls a multi-minute server-side operation — the Deep Research lifecycle (test_research_deep_poll_vcr.py) is the canonical example — hits a few non-obvious snags. Lessons from the v0.8 full-lifecycle re-record (PR #1566):

  • httpx.PoolTimeout after ~1520 min of idle polling. The default ConnectionLimits(keepalive_expiry=30.0) keeps an idle pooled connection around long enough to be silently dropped server-side, and the next acquire stalls. In record mode only, build the client with a shorter keepalive and a generous read timeout: NotebookLMClient(auth, timeout=60.0, limits=ConnectionLimits(keepalive_expiry=10.0)). Note async_client_factory is not a public constructor kwarg — use the public timeout= / limits= seams.
  • pytest-timeout kills the run. The global per-test timeout aborts a ~30-min recording. Mark the recording test @pytest.mark.timeout(3600).
  • start() task_id ≠ the poll-reported task_id. Deep Research's kickoff id is not the id POLL_RESEARCH echoes back, so a filtered research.poll(task_id=…) returns NOT_FOUND every poll. The record loop must mirror wait_for_completion: first poll unfiltered, then pin the poll-reported id.
  • Trim with a byte-exact text slice, not yaml.safe_dump. Long deep-research poll bodies accumulate large markdown, so trim redundant middle in_progress polls to stay under the cassette size cap. Re-serializing via yaml.safe_dump re-wraps long scalars and breaks Windows YAML parsing (CI catches it) — slice the VCR-native YAML text instead.
  • Cleanliness is necessary-not-sufficient. After recording, run the cassette guard (above) and manually grep the new file for live cookie/token/email shapes (SID / HSID / SAPISIDHASH / Bearer / the account email) — the name-anchored scrubber can miss credentials in un-allowlisted fields.

Synthetic error cassettes

Warning

Error cassettes generated through this plumbing are SYNTHETIC. They validate the client's exception-mapping branches (RateLimitError, ServerError, the auth-refresh path), NOT Google's actual error response shapes. If you need to validate a real-world error shape, capture a live recording instead — these synthetic shapes are intentionally minimal.

The NOTEBOOKLM_VCR_RECORD_ERRORS env var opts a recording session into substituting the next outgoing batchexecute RPC with a synthetic error response. Three modes are supported:

Mode HTTP status Maps to
429 429 RateLimitError (after retry budget exhausted)
5xx 500 ServerError (after retry budget exhausted)
expired_csrf 400 auth-refresh path (NotebookLM uses 400, not 401)

The plumbing has three opt-in layers:

  1. Env var: NOTEBOOKLM_VCR_RECORD_ERRORS=<mode> activates the ErrorInjectionMiddleware in the middleware chain (the env var is consulted when the client opens).
  2. Pytest marker: @pytest.mark.synthetic_error("<mode>") sets the env var for the duration of a single test (auto-reverted on teardown). Note that the synthetic_error marker is registered dynamically in tests/conftest.py:149 (rather than statically listed in pyproject.toml).
  3. Filename prefix: cassettes recorded under this mode MUST be named error_synthetic_<mode>_<slug>.yaml — use tests.cassette_patterns.synthetic_error_cassette_name(mode, slug) to build the filename so reviewers can tell synthetic shapes apart from real recordings at a glance.

Example recording session (this is the workflow a maintainer uses to record the actual error cassettes — the transport-wrapper module itself ships only the plumbing):

NOTEBOOKLM_VCR_RECORD=1 \
NOTEBOOKLM_VCR_RECORD_ERRORS=429 \
  uv run pytest tests/integration/test_error_paths_vcr.py

Production behavior is unchanged when NOTEBOOKLM_VCR_RECORD_ERRORS is unset — the transport wrapper is only constructed when the env var resolves to a recognized mode, and a typo'd value resolves to None (the recording session continues without substitution).

Per-method RPC coverage gate

tests/scripts/check_method_coverage.py enforces, on every PR, that each member of RPCMethod has both:

  1. A test reference — at least one file under tests/ (excluding the gate script itself) mentions the enum member by its qualified name (RPCMethod.LIST_NOTEBOOKS) OR by its raw RPC id string value ("wXbhsf").
  2. A cassette covering the RPC id — at least one cassette YAML under tests/cassettes/ contains the RPC id string in its body.

The gate is a pure-text static check (no pytest, no network) and runs in the quality job of test.yml.

Adding a new RPCMethod? Ship it with:

  • a unit or integration test that imports the enum member (or asserts on its raw id), AND
  • at least one cassette whose recorded request/response body contains the RPC id.

Pre-existing gaps. A small PREEXISTING_GAPS set inside the script can grandfather methods that lacked coverage when the gate first landed. It is currently empty. The set is a one-way ratchet — it must not grow. When you backfill coverage for a grandfathered method, delete its entry from PREEXISTING_GAPS in the same PR. The gate fails when a stale PREEXISTING_GAPS entry has acquired full coverage so maintainers remove it.

# Run locally before pushing changes that touch RPCMethod
uv run python tests/scripts/check_method_coverage.py

E2E Fixtures

Fixture Use Case
read_only_notebook_id List/download existing artifacts
temp_notebook Add/delete sources (auto-cleanup)
generation_notebook_id Generate artifacts (CI-aware cleanup)

Rate Limiting

NotebookLM has undocumented rate limits. Generation tests may be skipped when rate limited:

  • Use uv run pytest tests/e2e -m readonly for quick validation
  • Wait a few minutes between full test runs
  • SKIPPED (Rate limited by API) is expected behavior, not failure

Writing New Tests

Need network?
├── No → tests/unit/
├── Mocked → tests/integration/
└── Real API → tests/e2e/
    └── What notebook?
        ├── Read-only → read_only_notebook_id + @pytest.mark.readonly
        ├── CRUD → temp_notebook
        └── Generation → generation_notebook_id
            └── Parameter variant? → add @pytest.mark.variants

Logging and observability

Levels — when to emit what

  • WARNING — data loss, protocol drift, schema mismatch, unexpected non-2xx that isn't auth-recoverable. Actionable.
  • INFO — coarse-grained lifecycle events (login complete, profile switched). Rare in library code; CLI uses INFO for user-facing progress.
  • DEBUG — expected fallbacks, hot-path parser branches, polling status, request/response metadata. Off by default; enable via NOTEBOOKLM_LOG_LEVEL=DEBUG or notebooklm -vv.
  • Silent + comment — best-effort discovery loops (browser cookie scan, alternative profile locations). except body is pass or continue with a single-line # best-effort: <what we tried> comment.

Credential redaction

The package handler installed by configure_logging() has a RedactingFilter attached. It runs for every record reaching the handler, including records originating in child loggers (notebooklm._rpc_executor, notebooklm._transport_errors, notebooklm._chat, etc.) via Python logging's default propagation. The filter scrubs:

  • CSRF tokens (at=...)
  • Session IDs (f.sid=...)
  • Google session cookies (SAPISID, SID, HSID, SSID, __Secure-1PSID, __Secure-3PSID)
  • Authorization: Bearer <token> headers
  • Cookie: <jar> headers

The filter pre-renders record.exc_info traceback into a scrubbed record.exc_text while preserving record.exc_info itself. The live exception object is not mutated.

To add a new secret pattern: edit _REDACT_PATTERNS in src/notebooklm/_logging.py and add a unit test in tests/unit/test_logging.py before merging.

Attaching your own handler

notebooklm propagates to root by default, so caplog, basicConfig, and similar workflows work without configuration. To capture notebooklm logs in a dedicated handler:

import logging
from notebooklm._logging import apply_redaction

handler = logging.handlers.SysLogHandler(...)
apply_redaction(handler)
logging.getLogger("notebooklm").addHandler(handler)

apply_redaction() attaches the RedactingFilter and wraps the formatter so your handler also benefits from credential scrubbing.

Style — always lazy formatting

Use %-style log calls, not f-strings:

logger.warning("Failed for %s in %.2fs", name, elapsed)  # OK
logger.warning(f"Failed for {name} in {elapsed:.2f}s")    # BAD

f-string eager evaluation defeats lazy formatting and (although the filter would still scrub via record.getMessage()) makes profile-time cost unconditional.

Third-party loggers

httpx, urllib3, and asyncio can emit at DEBUG with full URLs and headers containing notebooklm-py credentials.

For httpx and urllib3, configure_logging() (run automatically at package import) attaches a logger-level RedactingFilter to each. That filter runs before records propagate to ancestor loggers, so a library consumer who enables those loggers via logging.basicConfig(level=logging.DEBUG) gets credential-scrubbed request URLs and headers with no extra setup — and without notebooklm-py adding any handler of its own to those loggers.

If you also want those loggers to emit through notebooklm-py's default handler (the CLI does this when -vv is set), call install_redaction, which adds both the filter and a default StreamHandler:

from notebooklm.log import install_redaction
install_redaction("httpx", "urllib3")

To cover additional third-party loggers (e.g. asyncio) or libraries that set propagate=False on internal loggers (rare), pass the names explicitly:

install_redaction("asyncio")
install_redaction("httpx._client", "urllib3.connectionpool")

Trade-offs

The RedactingFilter preserves record.exc_info (the live exception object) so handlers like Sentry can still access it. However:

  • Standard logging.Formatter uses record.exc_text (scrubbed by our filter) and does NOT re-render from exc_info. Safe.
  • Custom formatters that ignore exc_text and read exc_info directly may render an unredacted traceback. Mitigation: wrap such handlers with apply_redaction() so the formatter is decorated and post-scrubs the final output regardless of which exception attribute it reads.
  • Records propagate to root by default (notebooklm.propagate = True) so caplog and basicConfig work without changes. Our filter mutates the record before propagation, so downstream handlers (including root's) see the scrubbed version. Caveat: if a user attaches an unredacted handler directly to a child logger (notebooklm._rpc_executor), that handler fires before propagation reaches our parent handler. Mitigation: apply_redaction(child_handler).
  • Applications that want notebooklm logs isolated from root can set logging.getLogger('notebooklm').propagate = False themselves.

CI/CD

Workflows

Workflow Trigger Purpose
test.yml Push/PR Unit tests, linting, type checking
nightly.yml Daily 6 AM UTC (main), manual dispatch for release/* E2E tests with real API
rpc-health.yml Daily 7 AM UTC (main), manual dispatch for release/* RPC method ID monitoring (see stability.md)
testpypi-publish.yml Manual dispatch Publish to TestPyPI
verify-package.yml Manual dispatch Verify TestPyPI or PyPI install + E2E
publish.yml Tag push Publish to PyPI

Setting Up Nightly E2E Tests

  1. Get storage state: cat ~/.notebooklm/storage_state.json
  2. Add GitHub secrets:
    • NOTEBOOKLM_AUTH_JSON: Storage state JSON
    • NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID: Your test notebook ID

Scheduled canaries target main only. Release canaries are manual: dispatch nightly.yml or rpc-health.yml with custom_branch=release/vX.Y.Z.

Maintaining Secrets

Task Frequency
Refresh credentials Every 1-2 weeks
Check nightly results Daily

Workflow secret gates

Every workflow that consumes user-provided secrets (secrets.NOTEBOOKLM_AUTH_JSON, secrets.NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID, secrets.CLAUDE_CODE_OAUTH_TOKEN, …) is wrapped in at least one of three gates so that a non-maintainer cannot exfiltrate credentials by dispatching a workflow on a feature branch:

Gate Where Mechanism
environment: protected-readonly Job-level GitHub Environment hosting the canonical secret values. Bind it unconditionally (environment: protected-readonly) so every trigger — scheduled cron and workflow_dispatch alike — sees the same secret. Note: the earlier conditional form (${{ github.event_name == 'workflow_dispatch' && 'protected-readonly' || '' }}) silently broke scheduled crons once the secrets stopped existing at repo level (issue #1009); the same env binding is now the single source of truth. If you want to block workflow_dispatch behind manual approval, add a required reviewers rule on the environment — but be aware scheduled runs will then queue at the same gate.
needs.<job>.outputs.is_standard == 'true' Job/step-level if: Pin secret-using jobs or steps to standard branches (main / release/* / scheduled cron). Non-standard branches skip outright — no secret values land in the runner env.
github.event.sender.login == 'teng-lin' Job-level if: Pin webhook-triggered workflows (e.g. claude.yml) to a specific maintainer actor. Any other actor's trigger never reaches the secret-bearing steps.

scripts/check_workflow_secret_gates.py (wired into the test.yml quality job) asserts every workflow file in .github/workflows/ satisfies at least one of the above gates for every secrets.* reference (except secrets.GITHUB_TOKEN, which is covered separately by scripts/check_workflow_permissions.py).

The checker also rejects the conditional environment: shape outright:

# REJECTED (silently broke #1009 once the secret was migrated env-only)
environment: ${{ github.event_name == 'workflow_dispatch' && 'protected-readonly' || '' }}

# REQUIRED — unconditional binding
environment: protected-readonly

The empty-string fallback in the expression form means "no environment", so secrets that live only in environments resolve to empty under that branch. Binding the environment unconditionally is the single source of truth.

Additionally, every job that consumes NOTEBOOKLM_AUTH_JSON runs a fail-fast preflight step (if [ -z "$NOTEBOOKLM_AUTH_JSON" ]; then exit 1) before the test/script step. Without the preflight, an empty secret would let pytest skip every auth-requiring test silently and the job would land green with 0 tests run (issue #1009). The preflight surfaces an ::error:: annotation linked to the secret-config misconfig so the failure is visible in the GitHub UI rather than hidden behind "0 passed".

One-time GitHub Environment setup

The protected-readonly environment must be configured in the GitHub repository settings before any workflow that references it can run with an approval gate.

Important — silent auto-creation: GitHub Actions silently creates a referenced environment that doesn't exist, with no protection rules, the first time a workflow references it. A typo in the environment name (e.g. protectd-readonly) or a never-configured environment would therefore bypass maintainer approval at runtime even though the workflow YAML appears to gate on it. The static checker scripts/check_workflow_secret_gates.py pins the accepted environment names to an explicit allow-list (_APPROVED_ENVIRONMENTS) to prevent typos from passing CI — but the runtime gate still depends on the manual setup below being done correctly. Verify by triggering a workflow_dispatch and confirming the run pauses at "Waiting for review" before any secret is exposed.

This is a manual UI/API step — Pull Requests cannot create environments on their own.

  1. Open the repository on GitHub and navigate to Settings → Environments → New environment.
  2. Name the environment protected-readonly (exact spelling — the workflow YAML files match this string verbatim, and the checker enforces the same spelling).
  3. Under Deployment protection rules, enable Required reviewers and add the maintainer GitHub account (e.g. teng-lin) to the reviewer list.
  4. Leave Wait timer at 0 minutes (manual approval is the gate; we don't need a cool-down).
  5. Save. The environment is now ready; the next workflow_dispatch against verify-package.yml, verify-artifacts.yml, rpc-health.yml, or nightly.yml will pause at the maintainer-approval prompt before any secret resolves.
  6. Smoke-test the gate. Dispatch one of the workflows above from a non-maintainer account (or from the maintainer account if no second account is available — the approval prompt should still fire) and confirm the run pauses at "Waiting for review" instead of immediately acquiring secrets. If the run does not pause, the environment was not configured correctly; do not rely on the gate until this smoke-test passes.

For automation-driven setup (e.g. infrastructure-as-code), the same configuration can be applied via the GitHub REST API:

gh api -X PUT \
  /repos/teng-lin/notebooklm-py/environments/protected-readonly \
  -f 'wait_timer=0' \
  -f 'reviewers[][type]=User' \
  -F 'reviewers[][id]=<github-user-id-for-teng-lin>'

Adding a new secret-bearing workflow

When introducing a workflow that touches secrets.*:

  1. Pick the gate shape that matches the trigger surface:
    • workflow_dispatch only → job-level environment: protected-readonly.
    • workflow_dispatch + schedule → also job-level environment: protected-readonly (unconditional — issue #1009). Pair with an upstream is_standard gate so a non-maintainer's feature-branch dispatch can't reach the secret-bearing job at all.
    • Webhook-triggered (issue_comment, etc.) → job-level if: pinning sender.login to the maintainer.
    • Multi-branch CI (push, pull_request, nightly) → step-level if: referencing an upstream is_standard output.
  2. Run python scripts/check_workflow_secret_gates.py locally to verify the gate is recognised.
  3. If the new workflow references the protected-readonly environment for the first time, double-check the Environment exists (see "One-time GitHub Environment setup" above). GitHub Actions will silently auto-create a referenced environment that doesn't exist, with no protection rules, so a never-configured protected-readonly environment would let the workflow run without any approval gate — exactly the opposite of what the YAML implies. The static checker rejects unapproved names via _APPROVED_ENVIRONMENTS, but it cannot verify that GitHub-side configuration has actually been applied; that verification is the maintainer's responsibility per the smoke-test step in "One-time GitHub Environment setup".

Troubleshooting CI/CD Auth

First step: Run notebooklm auth check --json in your workflow to diagnose issues.

"NOTEBOOKLM_AUTH_JSON environment variable is set but empty"

Cause: The NOTEBOOKLM_AUTH_JSON env var is set to an empty string.

Solution:

  • Ensure the GitHub secret is properly configured
  • Check the secret isn't empty or whitespace-only
  • Verify the workflow syntax: ${{ secrets.NOTEBOOKLM_AUTH_JSON }}

"must contain valid Playwright storage state with a 'cookies' key"

Cause: The JSON in NOTEBOOKLM_AUTH_JSON is missing the required structure.

Solution: Ensure your secret contains valid Playwright storage state JSON:

{
  "cookies": [
    {"name": "SID", "value": "...", "domain": ".google.com", ...},
    ...
  ],
  "origins": []
}

"Cannot run 'login' when NOTEBOOKLM_AUTH_JSON is set"

Cause: You're trying to run notebooklm login in CI/CD where NOTEBOOKLM_AUTH_JSON is set.

Why: The login command saves to a file, which conflicts with environment-based auth.

Solution:

  • Don't run login in CI/CD - use the env var for auth instead
  • If you need to refresh auth, do it locally and update the secret

Session expired in CI/CD

Cause: Google sessions expire periodically (typically every 1-2 weeks).

Solution:

  1. Re-run notebooklm login locally
  2. Copy the contents of ~/.notebooklm/storage_state.json
  3. Update your GitHub secret

Multiple accounts in CI/CD

Use separate secrets and set NOTEBOOKLM_AUTH_JSON per job:

jobs:
  account-1:
    env:
      NOTEBOOKLM_AUTH_JSON: ${{ secrets.NOTEBOOKLM_AUTH_ACCOUNT1 }}
    steps:
      - run: notebooklm list

  account-2:
    env:
      NOTEBOOKLM_AUTH_JSON: ${{ secrets.NOTEBOOKLM_AUTH_ACCOUNT2 }}
    steps:
      - run: notebooklm list

Debugging CI/CD auth issues

Add diagnostic steps to your workflow:

- name: Debug auth
  run: |
    # Comprehensive auth check (preferred)
    notebooklm auth check --json

    # Check if env var is set (without revealing content)
    if [ -n "$NOTEBOOKLM_AUTH_JSON" ]; then
      echo "NOTEBOOKLM_AUTH_JSON is set (length: ${#NOTEBOOKLM_AUTH_JSON})"
    else
      echo "NOTEBOOKLM_AUTH_JSON is not set"
    fi

The auth check --json output shows:

  • Whether storage/env var is being used
  • Which cookies are present
  • Cookie domains (important for regional users)
  • Any validation errors

Getting Help

  • Check existing implementations in _*.py files
  • Look at test files for expected structures
  • See RPC Development Guide for protocol details
  • See CONTRIBUTING.md for install, lint, and PR workflow
  • Open an issue with captured request/response (sanitized)