
Notebooklm
- 8k installs
- 18.5k repo stars
- Updated August 5, 2026
- teng-lin/notebooklm-py
CLI wrapper for Google NotebookLM API providing programmatic notebook management, source ingestion (URLs, files, YouTube, Drive), deep web research, artifact generation (audio, video, slides, reports, quizzes), and multi
About
Complete programmatic API for Google NotebookLM exposed as a Python CLI. Developers use it to automate notebook workflows that exceed the web UI: batch-add sources (URLs, YouTube, PDFs, audio, video), run deep web research, generate all artifact types (podcasts, videos, slide decks, infographics, quizzes, flashcards, reports, mind maps, data tables), and download results in multiple formats including PPTX, CSV, and JSON. The CLI handles auth via browser OAuth, supports parallel workflows with explicit notebook IDs and per-profile context isolation, and exposes features unavailable in the web UI: source fulltext retrieval, citation context extraction, slide-by-slide revision, and PPTX export. Long operations (15-45 min for video, 15-30 min for deep research) integrate with subagents via artifact wait and research wait commands that poll to completion. Includes profile management for multi-account use, server-side auth refresh for unattended keepalive, and fire-and-forget generation patterns that return task IDs immediately.
- Generate all NotebookLM artifacts programmatically: podcasts (4 formats), videos (3 formats + Veo 3 cinematic), slide de
- Web research mode runs deep topic searches (20+ sources, 15-30 min) and imports results automatically, enabling autonomo
- Surfaces capabilities not in web UI: export slide decks as editable PPTX, revise individual slides with natural-language
- Parallel-safe design: explicit notebook IDs on all scoped commands, per-profile context files, unique NOTEBOOKLM_HOME pe
- Long operations return task IDs immediately for subagent patterns: artifact wait and research wait poll to completion (t
Notebooklm by the numbers
- 7,976 all-time installs (skills.sh)
- +264 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #109 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Security screen: HIGH risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
notebooklm capabilities & compatibility
Requires Google account with NotebookLM access. Standard: 50 sources/notebook, Plus: 100, Pro: 300, Ultra: 600. Veo 3 cinematic video requires Google AI Ultra s
- Capabilities
- batch source ingestion (urls, youtube, pdfs, aud · deep web research with automatic source import ( · generate all artifact types: audio (4 formats), · download artifacts in multiple formats (mp3, mp4 · source fulltext retrieval and citation context e · profile management for multi account use and par · server side auth refresh and browser cookie re e · slide by slide revision with natural language pr
- Works with
- google drive · chrome
- Use cases
- research · web search · web scraping · documentation · copywriting
- Platforms
- macOS · Linux · Windows · WSL
- Runs
- Runs locally
- Pricing
- Free
What notebooklm says it does
Complete programmatic access to Google NotebookLM—including capabilities not exposed in the web UI. Create notebooks, add sources (URLs, YouTube, PDFs, audio, video, images), chat with content, genera
npx skills add https://github.com/teng-lin/notebooklm-py --skill notebooklmAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 8k |
|---|---|
| repo stars | ★ 18.5k |
| Security audit | 1 / 3 scanners passed |
| Last updated | August 5, 2026 |
| Repository | teng-lin/notebooklm-py ↗ |
What it does
Automate Google NotebookLM via CLI: batch-import URLs/docs, run web research, generate podcasts/videos/slides/reports, export artifacts. Surfaces capabilities not in web UI like PPTX export and
Who is it for?
Automating research-to-content pipelines (URL collection → deep research → podcast/video generation → download). Batch source management for large notebooks. Extracting citation contexts from chat references. Parallel ag
Skip if: Real-time interactive chat (web UI faster). Visual artifact editing (use web UI or export PPTX). Sub-30-second operations where web UI overhead is acceptable. Tasks requiring WYSIWYG preview before generation.
When should I use this skill?
User requests podcast/video/quiz generation, deep web research, batch source import, PPTX export, citation extraction, or mentions NotebookLM by name. Activates on phrases like 'create a podcast about X', 'summarize thes
What you get
Developers automate multi-step NotebookLM workflows: batch-import 50+ sources, trigger deep research that finds and analyzes 20+ web sources autonomously, generate podcasts/videos/slides with single commands, export arti
- Authenticated CLI session (storage_state.json)
- Created notebooks with imported sources
- Generated artifacts (audio MP3, video MP4, slide PDF/PPTX, infographic PNG, report MD, quiz/flashcards JSON/MD/HTML, min
By the numbers
- 80+ supported output languages for artifact generation
- 4 podcast formats (deep-dive, brief, critique, debate), 3 video formats plus Veo 3 cinematic
- 9 infographic styles, 4 report templates, 2 mind-map kinds
Files
NotebookLM Automation
Complete programmatic access to Google NotebookLM—including capabilities not exposed in the web UI. Create notebooks, add sources (URLs, YouTube, PDFs, audio, video, images), chat with content, generate all artifact types, and download results in multiple formats.
Installation
From PyPI (Recommended for AI agents — Python-version-aware):
pip install "notebooklm-py[browser]" # mandatory; errors must propagate
# [cookies] (rookiepy) is optional and known to FAIL TO BUILD on Python 3.13+.
# Skip it deliberately on 3.13+ rather than swallowing the error — that lets
# *real* install failures (typos, network, PyPI outages) surface for the agent.
if python -c "import sys; sys.exit(0 if sys.version_info < (3, 13) else 1)"; then
pip install "notebooklm-py[cookies]" # errors propagate
else
echo "Skipping [cookies] on Python 3.13+ (rookiepy unavailable). Use 'notebooklm login' interactively."
fiFull install matrix (extras, headless servers, contributor flow): Installation guide on GitHub.
From GitHub (use latest release tag, NOT main branch):
# Get the latest release tag (requires curl + jq)
if ! command -v jq >/dev/null; then
echo "jq is required to read the latest release tag" >&2
exit 1
fi
LATEST_TAG=$(
curl -fsSL https://api.github.com/repos/teng-lin/notebooklm-py/releases/latest |
jq -r '.tag_name'
)
# Includes [browser] so the interactive `notebooklm login` flow works.
pip install "notebooklm-py[browser] @ git+https://github.com/teng-lin/notebooklm-py@${LATEST_TAG}"⚠️ DO NOT install from main branch (pip install git+https://github.com/teng-lin/notebooklm-py). The main branch may contain unreleased/unstable changes. Always use PyPI or a specific release tag, unless you are testing unreleased features.
Skill install methods:
notebooklm skill installinstalls this skill into the supported local agent directories managed by the CLI.npx skills add teng-lin/notebooklm-pyinstalls this skill from the GitHub repository into compatible agent skill directories.- If you are already reading this file inside an agent skill directory, the skill is already installed. You only need the Python package and authentication below.
CLI-managed install:
notebooklm skill installPrerequisites
IMPORTANT: Before using any command, you MUST authenticate:
notebooklm login # Opens browser for Google OAuth
notebooklm list # Verify authentication worksIf commands fail with authentication errors, re-run notebooklm login.
CI/CD, Multiple Accounts, and Parallel Agents
For automated environments, multiple accounts, or parallel agent workflows:
| Variable | Purpose |
|---|---|
NOTEBOOKLM_HOME | Custom config directory (default: ~/.notebooklm) |
NOTEBOOKLM_PROFILE | Active profile name (default: default) |
NOTEBOOKLM_AUTH_JSON | Inline auth JSON - no file writes needed |
CI/CD setup: Set NOTEBOOKLM_AUTH_JSON from a secret containing your storage_state.json contents.
Multiple accounts: Use named profiles (notebooklm profile create work, then notebooklm -p work login). Alternatively, use different NOTEBOOKLM_HOME directories per account.
Parallel agents: The CLI stores notebook context per profile (~/.notebooklm/profiles/<profile>/context.json, with a legacy fallback to ~/.notebooklm/context.json for the implicit default profile). Multiple concurrent agents that share a profile and use notebooklm use can overwrite each other's context — use one of the isolation strategies below.
Solutions for parallel workflows: 1. Always use explicit notebook ID (recommended): Pass -n <notebook_id> / --notebook <notebook_id> on notebook-scoped commands instead of relying on use 2. Per-agent isolation via profiles: export NOTEBOOKLM_PROFILE=agent-$ID (each profile gets its own context file) 3. Per-agent isolation via home: Set unique NOTEBOOKLM_HOME per agent: export NOTEBOOKLM_HOME=/tmp/agent-$ID 4. Use full UUIDs: Avoid partial IDs in automation (they can become ambiguous)
Agent Setup Verification
Before starting workflows, verify auth is in place. Use `--test --json` (not bare `--json`) — bare --json only proves the cookie file parses; --test makes a network call and proves the cookies still authenticate against Google.
1. notebooklm auth check --test --json → require BOTH "status": "ok" AND "checks.token_fetch": true. Bare "status": "ok" (without --test) is a false-positive trap — a stale cookie file passes the parse check. 2. notebooklm list --json → expect valid JSON (may be empty for new accounts). 3. If auth fails or is missing → run `notebooklm login` first. This is the primary auth path: opens a browser, the user signs in to Google once, and the resulting storage_state.json is reused on every subsequent run. Works on any environment with a display.
- For headless contexts where opening a browser is not feasible, use
notebooklm login --browser-cookies <browser>instead — extracts the user's already-logged-in cookies from Chrome/Firefox/etc. (requires the[cookies]extra; rookiepy may not install on Python 3.13+). Usechrome::<profile-name-or-directory>to target one Chromium user-profile, orfirefox::<container-name>/firefox::noneto target one Firefox container. - To survey signed-in Google accounts before picking one:
notebooklm auth inspect --browser <browser>(read-only; pass-vto see which Chromium user-profile each account came from, or--jsonfor tooling). Scoped forms such asnotebooklm auth inspect --browser 'chrome::Profile 1'inspect only that browser profile. - Re-run step 1 after login to confirm.
4. If auth was working but cookies went stale (Google rotated SIDTS, or you signed in fresh in the browser) → refresh the active profile in place instead of full re-login:
notebooklm auth refresh— server-side SIDTS refresh against the existingstorage_state.json. Cheap and silent; safe to run on a schedule (cron / launchd / systemd) at 15–20 min cadence to keep an unattended profile warm.notebooklm auth refresh --browser-cookies <browser>— re-extract cookies from a running browser and match them back to the profile's recorded email incontext.json. Use when the on-diskstorage_state.jsonis too stale for the server-side refresh path but you've just signed back into Google in the browser. For Chromium-family browsers with multiple user-profiles (Chrome'sDefault,Profile 1, …), refresh fans out across all profiles to find the email — same path asauth inspect(issue #571). Usechrome::<profile-name-or-directory>when you already know the exact browser profile.- Both forms preserve the same
--profile(no new profile is created).
Note: notebooklm status reports context state (selected notebook); do not use it to verify auth.When This Skill Activates
Explicit: User says "/notebooklm", "use notebooklm", or mentions the tool by name
Intent detection: Recognize requests like:
- "Create a podcast about [topic]"
- "Summarize these URLs/documents"
- "Generate a quiz from my research"
- "Turn this into an audio overview"
- "Create flashcards for studying"
- "Generate a video explainer"
- "Make an infographic"
- "Create a mind map of the concepts"
- "Download the quiz as markdown"
- "Add these sources to NotebookLM"
Autonomy Rules
Run automatically (no confirmation):
notebooklm status- check contextnotebooklm auth check- diagnose auth issuesnotebooklm auth inspect- list Google accounts visible to a browser (read-only)notebooklm auth refresh- server-side SIDTS refresh of the active profile (no new profile, no destructive writes)notebooklm auth refresh --browser-cookies <browser>- re-extract cookies from a browser into the active profile (rebuildsstorage_state.jsonfor the same--profile, not a new one)notebooklm list- list notebooksnotebooklm source list- list sourcesnotebooklm artifact list- list artifactsnotebooklm language list- list supported languagesnotebooklm language get- get current languagenotebooklm language set- set language (global setting)notebooklm artifact wait- wait for artifact completion (in subagent context)notebooklm source wait- wait for source processing (in subagent context)notebooklm research status- check research statusnotebooklm research wait- wait for research (in subagent context)notebooklm use <id>- set context (⚠️ SINGLE-AGENT ONLY - use-nflag in parallel workflows)notebooklm create- create notebooknotebooklm ask "..."- chat queries (without--save-as-note)notebooklm suggest-prompts- AI-suggested prompts for a notebook (read-only, no state change)notebooklm history- display conversation history (read-only)notebooklm source add- add sourcesnotebooklm profile list- list profilesnotebooklm profile create- create profilenotebooklm profile switch- switch active profilenotebooklm doctor- check environment health
Ask before running:
notebooklm delete,source delete,source delete-by-title,source clean,note delete,artifact delete,label delete,share remove,auth logout,clear,profile delete, orask --new- destructive or state-changing. Once approved, pass--yes/-ywhere the command supports it. Most destructive--jsoncommands still require explicit--yesand otherwise return a structured confirmation error (CONFIRM_REQUIREDorVALIDATION_ERROR, depending on the command family); current exceptions includeshare remove --jsonandask --new --json, which skip the prompt for non-interactive callers.notebooklm generate *- long-running, may failnotebooklm download *- writes to filesystemnotebooklm artifact wait- long-running (when in main conversation)notebooklm source wait- long-running (when in main conversation)notebooklm research wait- long-running (when in main conversation)notebooklm research cancel <run_id>- state-changing; cancels a running research job (an in-progress job transitions to FAILED). Fire-and-forget: it does not confirm success — re-check withnotebooklm research status.notebooklm ask "..." --save-as-note- writes a notenotebooklm history --save- writes a note
Quick Reference
| Task | Command |
|---|---|
| Authenticate | notebooklm login |
| Authenticate from browser cookies | notebooklm login --browser-cookies <browser> |
| Authenticate from one Chromium profile | notebooklm login --browser-cookies 'chrome::Profile 1' |
| Authenticate from one Firefox container | notebooklm login --browser-cookies 'firefox::Work' |
| Import every signed-in account into its own profile | notebooklm login --browser-cookies <browser> --all-accounts |
| Inspect signed-in accounts (read-only, by email) | notebooklm auth inspect --browser <browser> |
| Inspect one browser profile/container | notebooklm auth inspect --browser 'chrome::Profile 1' |
| Diagnose auth issues | notebooklm auth check |
| Diagnose auth (full) | notebooklm auth check --test |
| Refresh active profile in place (server-side) | notebooklm auth refresh |
| Refresh active profile from a re-signed-in browser | notebooklm auth refresh --browser-cookies <browser> |
| Refresh from one Chromium profile | notebooklm auth refresh --browser-cookies 'chrome::Profile 1' |
| One-shot cookie keepalive (for cron) | notebooklm auth refresh --quiet |
| List notebooks | notebooklm list |
| Create notebook | notebooklm create "Title" |
| Set context | notebooklm use <notebook_id> |
| Show context | notebooklm status |
| Add URL source | notebooklm source add "https://..." |
| Add file | notebooklm source add ./file.pdf |
| Add YouTube | notebooklm source add "https://youtube.com/..." |
| List sources | notebooklm source list |
| List sources in a label | notebooklm source list --label <label_id_or_name> |
| Delete source by ID | notebooklm source delete <source_id> |
| Delete source by exact title | notebooklm source delete-by-title "Exact Title" |
| Wait for source processing | notebooklm source wait <source_id> |
| List labels | notebooklm label list |
| Expand label to sources | notebooklm label sources <label_id_or_name> |
| Generate labels | notebooklm label generate --scope unlabeled |
| Create label | notebooklm label create "Topic" |
| Add sources to label | notebooklm label add <label_id_or_name> <source_id>... |
| Remove sources from label | notebooklm label remove <label_id_or_name> <source_id>... |
| Delete label | notebooklm label delete <label_id_or_name> --yes |
| Web research (fast) | notebooklm source add-research "query" |
| Web research (deep) | notebooklm source add-research "query" --mode deep --no-wait |
| Web research (query from file) | notebooklm source add-research --prompt-file research_query.txt --mode deep |
| Check research status | notebooklm research status |
| Wait for research | notebooklm research wait --import-all |
| Cancel research | notebooklm research cancel <run_id> (run_id = the task_id from research status) |
| Suggest questions to ask | notebooklm suggest-prompts |
| Chat | notebooklm ask "question" |
| Chat (long prompt from file) | notebooklm ask --prompt-file question.txt |
| Chat (specific sources) | notebooklm ask "question" -s src_id1 -s src_id2 |
| Chat (with references) | notebooklm ask "question" --json |
| Chat (save answer as note) | notebooklm ask "question" --save-as-note |
| Chat (save with title) | notebooklm ask "question" --save-as-note --note-title "Title" |
| Show conversation history | notebooklm history |
| Save all history as note | notebooklm history --save |
| Continue specific conversation | notebooklm ask "question" -c <conversation_id> |
| Save history with title | notebooklm history --save --note-title "My Research" |
| Get source fulltext | notebooklm source fulltext <source_id> |
| Get source guide | notebooklm source guide <source_id> |
| Generate podcast | notebooklm generate audio "instructions" |
| Generate (long prompt from file) | notebooklm generate audio --prompt-file instructions.txt |
| Generate podcast (JSON) | notebooklm generate audio --json |
| Generate podcast (specific sources) | notebooklm generate audio -s src_id1 -s src_id2 |
| Generate video | notebooklm generate video "instructions" |
| Generate report | notebooklm generate report --format briefing-doc |
| Generate report (append instructions) | notebooklm generate report --format study-guide --append "Target audience: beginners" |
| Generate quiz | notebooklm generate quiz |
| Revise a slide | notebooklm generate revise-slide "prompt" --artifact <id> --slide 0 |
| Check artifact status | notebooklm artifact list |
| Wait for completion | notebooklm artifact wait <artifact_id> |
| Delete artifact | notebooklm artifact delete <artifact_id> --yes |
| Download audio | notebooklm download audio ./output.mp3 |
| Download video | notebooklm download video ./output.mp4 |
| Download cinematic video | notebooklm download cinematic-video ./cinematic.mp4 (alias for download video) |
| Download infographic | notebooklm download infographic ./infographic.png |
| Download slide deck (PDF) | notebooklm download slide-deck ./slides.pdf |
| Download slide deck (PPTX) | notebooklm download slide-deck ./slides.pptx --format pptx |
| Download report | notebooklm download report ./report.md |
| Download mind map | notebooklm download mind-map ./map.json |
| Download data table | notebooklm download data-table ./data.csv |
| Download quiz | notebooklm download quiz quiz.json |
| Download quiz (markdown) | notebooklm download quiz --format markdown quiz.md |
| Download flashcards | notebooklm download flashcards cards.json |
| Download flashcards (markdown) | notebooklm download flashcards --format markdown cards.md |
| Delete notebook | notebooklm delete -n <id> (add --yes to skip the prompt non-interactively) |
| List languages | notebooklm language list |
| Get language | notebooklm language get |
| Set language | notebooklm language set zh_Hans |
| List profiles | notebooklm profile list |
| Create profile | notebooklm profile create work |
| Switch profile | notebooklm profile switch work |
| Delete profile | notebooklm profile delete old --yes (-y; --confirm is a deprecated alias) |
| Rename profile | notebooklm profile rename old new |
| Use profile (one-off) | notebooklm -p work list |
| Health check | notebooklm doctor |
| Health check (auto-fix) | notebooklm doctor --fix |
Parallel safety: Use explicit notebook IDs in parallel workflows. Notebook-scoped commands broadly support -n/--notebook (ask/history, source, artifact, generate, download, note, label, share, research, and notebook delete/rename/summary/metadata). Download commands also support -a/--artifact. For chat, use -c <conversation_id> to target a specific conversation.
Partial IDs: Use first 6+ characters of UUIDs. Must be unique prefix (fails if ambiguous). Works for ID-based commands such as use, source delete, and wait. For exact source-title deletion, use source delete-by-title "Title". For automation, prefer full UUIDs to avoid ambiguity.
Command Output Formats
Commands with --json return structured data for parsing:
Create notebook:
$ notebooklm create "Research" --json
{"notebook": {"id": "abc123de-...", "title": "Research", "created_at": null}}
# parse with: jq -r .notebook.idAdd source:
$ notebooklm source add "https://example.com" --json
{"source": {"id": "def456...", "title": "Example", "type": "web_page", "url": "https://example.com"}}
# parse with: jq -r .source.id
# Note: no `status` field on add — use `source list --json` or `source wait` to check processing state.Generate artifact:
$ notebooklm generate audio "Focus on key points" --json
{"task_id": "xyz789...", "status": "pending"}
# When run with --wait, completed status also includes a `url` field.Chat with references:
$ notebooklm ask "What is X?" --json
{"answer": "X is... [1] [2]", "conversation_id": "...", "turn_number": 1, "is_follow_up": false, "references": [{"source_id": "abc123...", "citation_number": 1, "cited_text": "Relevant passage from source..."}, {"source_id": "def456...", "citation_number": 2, "cited_text": "Another passage..."}]}Source fulltext (get indexed content):
$ notebooklm source fulltext <source_id> --json
{"source_id": "...", "title": "...", "kind": "web_page", "content": "Full indexed text...", "url": null, "char_count": 12345}Understanding citations: The cited_text in references is often a snippet or section header, not the full quoted passage. The start_char/end_char positions reference NotebookLM's internal chunked index, not the raw fulltext. Use SourceFulltext.find_citation_context() to locate citations:
fulltext = await client.sources.get_fulltext(notebook_id, ref.source_id)
matches = fulltext.find_citation_context(ref.cited_text) # Returns list[(context, position)]
if matches:
context, pos = matches[0] # First match; check len(matches) > 1 for duplicatesExtract IDs: Singular endpoints wrap their result in an envelope — parse .notebook.id (from create), .source.id (from source add), or .task_id (from generate *). The chat --json references list uses .references[].source_id.
Generation Types
Common generate options vary by subcommand:
-n, --notebooktargets the notebook.-s, --sourcelimits generation to specific source(s) on content generators (notrevise-slide).--languagesets output language where supported (defaults to configured language oren).--wait,--timeout, and--intervalare shared polling controls where waiting is supported.--jsonreturns machine-readable output.--retry Nautomatically retries rate limits on supported subcommands (notmind-map).--prompt-file PATHreads description/query text from a file onask, generation subcommands exceptmind-map, andsource add-research.
| Type | Command | Options | Download |
|---|---|---|---|
| Podcast | generate audio | `--format [deep-dive\ | brief\ |
| Video | generate video | `--format [explainer\ | brief\ |
| Slide Deck | generate slide-deck | `--format [detailed\ | presenter], --length [default\ |
| Slide Revision | generate revise-slide "prompt" --artifact <id> --slide N | --wait, --notebook | (re-downloads parent deck) |
| Infographic | generate infographic | `--orientation [landscape\ | portrait\ |
| Report | generate report | `--format [briefing-doc\ | study-guide\ |
| Mind Map | generate mind-map | `--kind [interactive\ | note-backed]` (³) (default: interactive) |
| Data Table | generate data-table | description required | .csv |
| Quiz | generate quiz | `--difficulty [easy\ | medium\ |
| Flashcards | generate flashcards | `--difficulty [easy\ | medium\ |
¹ --append only customizes the built-in templates. With --format custom, pass the prompt as the positional DESCRIPTION argument (notebooklm generate report "PROMPT" --format custom); --append is silently ignored in that mode (the CLI prints a warning).
³ Two kinds of mind map (issue #1256). generate mind-map --kind interactive (the default) creates the interactive studio artifact (what the web app now makes); it is polled to completion. generate mind-map --kind note-backed creates the note-backed kind — a JSON node tree, generated synchronously. Both emit the same {mind_map, note_id, kind} JSON, list under artifact list --type mind-map, and export via download mind-map. --instructions applies only to the note-backed kind.
⁴ Cinematic video (Veo 3). generate video --format cinematic generates AI documentary footage via Veo 3; it ignores `--style`, takes ~30-40 min, and requires a Google AI Ultra subscription. Also exposed as the generate cinematic-video alias (which forces --format cinematic and a longer default timeout). Download with download video or the download cinematic-video alias.
² Portrait / vertical slide decks via prompt. Slide-deck has no --orientation flag (unlike infographic). Treat portrait decks as skill-level prompt guidance, not a typed CLI/API contract: NotebookLM currently honors orientation cues written into the DESCRIPTION positional argument. Including phrases like "9:16 portrait", "vertical layout", "portrait mobile format", or "vertical 9:16 layout" can make NotebookLM render each slide as a 9:16 portrait image. Empirically:
- The
.pptxcanvas itself may stay 16:9, but each slide's embedded image can be rendered as 9:16 portrait — useful for vertical/mobile video material extracted viapython-pptx. - Orientation is steered once at generation time.
generate revise-slideedits content within an existing slide but does not change its orientation; if a slide falls back to landscape (occasional inconsistency), regenerate the whole deck rather than revising the single page. - Combine with an explicit page count in the prompt (e.g.
"Create exactly 8 pages, using a vertical 9:16 portrait layout") for the most predictable output.
# Skill prompt hint: ask NotebookLM to render each slide as a 9:16 portrait image
notebooklm generate slide-deck "Create an 8-page deck in 9:16 portrait orientation for mobile viewing" --length defaultFeatures Beyond the Web UI
These capabilities are available via CLI but not in NotebookLM's web interface:
| Feature | Command | Description |
|---|---|---|
| Batch downloads | download <type> --all | Download all artifacts of a type at once |
| Quiz/Flashcard export | download quiz --format json | Export as JSON, Markdown, or HTML (web UI only shows interactive view) |
| Mind map extraction | download mind-map | Export hierarchical JSON for visualization tools |
| Data table export | download data-table | Download structured tables as CSV |
| Slide deck as PPTX | download slide-deck --format pptx | Download slide deck as editable .pptx (web UI only offers PDF) |
| Slide revision | generate revise-slide "prompt" --artifact <id> --slide N | Modify individual slides with a natural-language prompt |
| Report template append | generate report --format study-guide --append "..." | Append custom instructions to built-in format templates without losing the format type |
| Source fulltext | source fulltext <id> | Retrieve the indexed text content of any source |
| Save chat to note | ask "..." --save-as-note / history --save | Save Q&A answers or conversation history as notebook notes |
| Programmatic sharing | share commands | Manage sharing permissions without the UI |
Common Workflows
Research to Podcast (Interactive)
Time: 5-10 minutes total
1. notebooklm create "Research: [topic]" — if fails: check auth with `notebooklm login` 2. notebooklm source add for each URL/document — if one fails: log warning, continue with others 3. Wait for sources: notebooklm source list --json until all status=READY — required before generation 4. notebooklm generate audio "Focus on [specific angle]" (confirm when asked) — if rate limited: wait 5 min, retry once 5. Note the artifact ID returned 6. Check notebooklm artifact list later for status 7. notebooklm download audio ./podcast.mp3 when complete (confirm when asked)
Research to Podcast (Automated with Subagent)
Time: 5-10 minutes, but continues in background
When user wants full automation (generate and download when ready):
1. Create notebook and add sources as usual 2. Wait for sources to be ready (use source wait or check source list --json) 3. Run notebooklm generate audio "..." --json → parse task_id from output 4. Spawn a background agent using Task tool:
Task(
prompt="Wait for artifact {task_id} in notebook {notebook_id} to complete, then download.
Use: notebooklm artifact wait {task_id} -n {notebook_id} --timeout 1200
Then: notebooklm download audio ./podcast.mp3 -a {task_id} -n {notebook_id}",
subagent_type="general-purpose"
)5. Main conversation continues while agent waits
Error handling in subagent:
- If
artifact waitreturns exit code 2 (timeout): Report timeout, suggest checkingartifact list - If download fails: Check if artifact status is COMPLETED first
Benefits: Non-blocking, user can do other work, automatic download on completion
Document Analysis
Time: 1-2 minutes
1. notebooklm create "Analysis: [project]" 2. notebooklm source add ./doc.pdf (or URLs) 3. notebooklm ask "Summarize the key points" 4. notebooklm ask "What are the main arguments?" 5. Continue chatting as needed
Bulk Import
Time: Varies by source count
1. notebooklm create "Collection: [name]" 2. Add multiple sources:
notebooklm source add "https://url1.com"
notebooklm source add "https://url2.com"
notebooklm source add ./local-file.pdf3. notebooklm source list to verify
Source limits: Varies by plan—Standard: 50, Plus: 100, Pro: 300, Ultra: 600 sources per notebook. See NotebookLM plans for details. The CLI does not enforce these limits; they are applied by your NotebookLM account. Supported types: PDFs, YouTube URLs, web URLs, Google Docs, text files, Markdown, Word docs, EPUB, audio files, video files, images
Bulk Import with Source Waiting (Subagent Pattern)
Time: Varies by source count
When adding multiple sources and needing to wait for processing before chat/generation:
1. Add sources with --json to capture IDs (parse with jq -r .source.id):
notebooklm source add "https://url1.com" --json # → {"source": {"id": "abc...", ...}}
notebooklm source add "https://url2.com" --json # → {"source": {"id": "def...", ...}}2. Spawn a background agent to wait for all sources:
Task(
prompt="Wait for sources {source_ids} in notebook {notebook_id} to be ready.
For each: notebooklm source wait {id} -n {notebook_id} --timeout 600
Report when all ready or if any fail.",
subagent_type="general-purpose"
)3. Main conversation continues while agent waits 4. Once sources are ready, proceed with chat or generation
Why wait for sources? Sources must be indexed before chat or generation. Takes ~30 seconds to several minutes per source (see the processing-times table below).
Deep Web Research (Subagent Pattern)
Time: 15-30+ minutes, runs in background
Deep research finds and analyzes web sources on a topic:
1. Create notebook: notebooklm create "Research: [topic]" 2. Start deep research (non-blocking):
notebooklm source add-research "topic query" --mode deep --no-wait3. Spawn a background agent to wait and import:
Task(
prompt="Wait for research in notebook {notebook_id} to complete and import sources.
Use: notebooklm research wait -n {notebook_id} --import-all --timeout 1800
Report how many sources were imported.",
subagent_type="general-purpose"
)4. Main conversation continues while agent waits 5. When agent completes, sources are imported automatically
Alternative (blocking): For simple cases, omit --no-wait:
notebooklm source add-research "topic" --mode deep --import-all
# Blocks until research completes (deep mode: 15-30+ min)When to use each mode:
--mode fast: Specific topic, quick overview needed (5-10 sources, seconds)--mode deep: Broad topic, comprehensive analysis needed (20+ sources, 15-30+ min)
Research sources:
--from web: Search the web (default)--from drive: Search Google Drive
Output Style
Progress updates: Brief status for each step
- "Creating notebook 'Research: AI'..."
- "Adding source: https://example.com..."
- "Starting audio generation... (task ID: abc123)"
Fire-and-forget for long operations:
- Start generation, return artifact ID immediately
- Do NOT poll or wait in main conversation - generation takes 5-45 minutes (see timing table)
- User checks status manually, OR use subagent with
artifact wait
JSON output: Use --json flag for machine-readable output:
notebooklm list --json
notebooklm auth check --test --json # use --test for network-validated auth (see § Agent Setup Verification)
notebooklm source list --json
notebooklm artifact list --jsonJSON schemas (key fields):
notebooklm list --json:
{"notebooks": [{"index": 1, "id": "...", "title": "...", "is_owner": true, "created_at": "..."}], "count": 1}notebooklm auth check --test --json (use --test to drive the network token-fetch — bare --json would leave "token_fetch": null):
{"status": "ok", "checks": {"storage_exists": true, "json_valid": true, "cookies_present": true, "sid_cookie": true, "token_fetch": true}, "details": {"storage_path": "...", "auth_source": "file", "cookies_found": ["SID", "HSID", "..."], "cookie_domains": [".google.com"]}}notebooklm source list --json:
{"notebook_id": "...", "notebook_title": "...", "sources": [{"index": 1, "id": "...", "title": "...", "type": "web_page", "url": "...", "status": "ready|processing|error", "status_id": 1, "created_at": "..."}], "count": 1}notebooklm artifact list --json:
{"notebook_id": "...", "notebook_title": "...", "artifacts": [{"index": 1, "id": "...", "title": "...", "type": "Audio", "type_id": 1, "status": "in_progress|pending|completed|unknown", "status_id": 1, "created_at": "..."}], "count": 1}Status values:
- Sources:
processing→ready(orerror) - Artifacts:
pendingorin_progress→completed(orunknown)
Error Handling
On failure, offer the user a choice: 1. Retry the operation 2. Skip and continue with something else 3. Investigate the error
Error decision tree:
| Error | Cause | Action |
|---|---|---|
| Auth/cookie error | Session expired | Run notebooklm auth check then notebooklm login |
| "No notebook context" | Context not set | Use -n <id> or --notebook <id> flag (parallel), or notebooklm use <id> (single-agent) |
| "No result found for RPC ID" | Rate limiting | Wait 5-10 min, retry |
GENERATION_FAILED | Google rate limit | Wait and retry later |
| Download fails | Generation incomplete | Check artifact list for status |
| Invalid notebook/source ID | Wrong ID | Run notebooklm list to verify |
| RPC protocol error | Google changed APIs | May need CLI update |
Exit Codes
All commands use consistent exit codes:
| Code | Meaning | Action |
|---|---|---|
| 0 | Success | Continue |
| 1 | Error (not found, processing failed) | Check stderr, see Error Handling |
| 2 | Timeout (wait commands only) | Extend timeout or check status manually |
Examples:
source waitreturns 1 if source not found or processing failedartifact waitreturns 2 if timeout reached before completiongeneratereturns 1 if rate limited (check stderr for details)
Long Prompts
When a prompt or query exceeds shell command-line length limits, use --prompt-file to read it from a file:
notebooklm ask --prompt-file ./long_question.txt
notebooklm generate report --prompt-file ./custom_report_prompt.txt
notebooklm source add-research --prompt-file ./research_query.txt --mode deep--prompt-file is mutually exclusive with the positional text argument. The file is read as UTF-8 with trailing whitespace stripped. Supported on: ask, all generate subcommands (except mind-map), and source add-research.
Note:--prompt-filereads a prompt/query text file, not a source document. To upload a file as a notebook source, usesource add ./file.pdf.
Known Limitations
Rate limiting: Audio, video, quiz, flashcards, infographic, and slide deck generation may fail due to Google's rate limits. This is an API limitation, not a bug.
Reliable operations: These always work:
- Notebooks (list, create, delete, rename)
- Sources (add, list, delete)
- Chat/queries
- Mind-map, study-guide, report, data-table generation
Unreliable operations: These may fail with rate limiting:
- Audio (podcast) generation
- Video generation
- Quiz and flashcard generation
- Infographic and slide deck generation
Workaround: If generation fails: 1. Check status: notebooklm artifact list 2. Retry after 5-10 minutes 3. Use the NotebookLM web UI as fallback
Processing times vary significantly. Use the subagent pattern for long operations:
| Operation | Typical time | Suggested timeout |
|---|---|---|
| Source processing | 30s - 10 min | 600s |
| Research (fast) | 30s - 2 min | 180s |
| Research (deep) | 15 - 30+ min | 1800s |
| Notes | instant | n/a |
| Mind-map | instant (sync) | n/a |
| Quiz, flashcards | 5 - 15 min | 900s |
| Report, data-table | 5 - 15 min | 900s |
| Audio generation | 10 - 20 min | 1200s |
| Video generation | 15 - 45 min | 2700s |
Polling intervals: When checking status manually, poll every 15-30 seconds to avoid excessive API calls.
Language Configuration
Language setting controls the output language for generated artifacts (audio, video, etc.).
Important: Language is a GLOBAL setting that affects all notebooks in your account.
# List all 80+ supported languages with native names
notebooklm language list
# Show current language setting
notebooklm language get
# Set language for artifact generation
notebooklm language set zh_Hans # Simplified Chinese
notebooklm language set ja # Japanese
notebooklm language set en # English (default)Common language codes:
| Code | Language |
|---|---|
en | English |
zh_Hans | 中文(简体) - Simplified Chinese |
zh_Hant | 中文(繁體) - Traditional Chinese |
ja | 日本語 - Japanese |
ko | 한국어 - Korean |
es | Español - Spanish |
fr | Français - French |
de | Deutsch - German |
pt_BR | Português (Brasil) |
Override per command: Use --language flag on generate commands:
notebooklm generate audio --language ja # Japanese podcast
notebooklm generate video --language zh_Hans # Chinese videoOffline mode: Use --local flag to skip server sync:
notebooklm language set zh_Hans --local # Save locally only
notebooklm language get --local # Read local config onlyTroubleshooting
notebooklm --help # Main commands
notebooklm auth check # Diagnose auth issues
notebooklm auth check --test # Full auth validation with network test
notebooklm source --help # Source management
notebooklm research --help # Research status/wait/cancel
notebooklm generate --help # Content generation
notebooklm artifact --help # Artifact management
notebooklm download --help # Download content
notebooklm language --help # Language settingsDiagnose auth: notebooklm auth check - shows cookie domains, storage path, validation status Re-authenticate: notebooklm login Check version: notebooklm --version Refresh a CLI-managed install: notebooklm skill install
# NotebookLM Configuration
# Copy this file to .env and fill in your values
# =============================================================================
# Configuration Environment Variables
# =============================================================================
# Custom home directory for all config files (optional)
# Default: ~/.notebooklm
# NOTEBOOKLM_HOME=/custom/path
# Inline authentication JSON for CI/CD (optional)
# When set, authentication is read from this variable instead of a file
# Get the value from: cat $NOTEBOOKLM_HOME/storage_state.json (default: ~/.notebooklm)
# NOTEBOOKLM_AUTH_JSON='{"cookies":[...]}'
# Enable RPC debug logging (optional)
# NOTEBOOKLM_DEBUG_RPC=1
# =============================================================================
# E2E Testing Configuration
# =============================================================================
# Required for E2E tests: Your READ-ONLY test notebook ID
# Create a notebook at https://notebooklm.google.com with:
# - Multiple sources (text, URL, PDF, etc.)
# - Some pre-generated artifacts (audio, quiz, etc.)
# Copy the notebook ID from the URL: notebooklm.google.com/notebook/YOUR_ID
# This notebook is used for READ-ONLY tests (list, get, download operations)
NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID=your-notebook-id-here
# Optional: Generation test notebook ID
# If not set, a notebook will be auto-created and its ID stored in
# NOTEBOOKLM_HOME/generation_notebook_id for reuse across test runs.
# NOTEBOOKLM_GENERATION_NOTEBOOK_ID=your-generation-notebook-id
# CodeQL configuration for notebooklm-py.
#
# The default Python query pack flags ``print(... <sensitive value> ...)`` as
# "clear-text logging of sensitive information" whenever a value tagged as
# secret reaches a print statement, even if the value passed through a
# project-local sanitiser first. ``notebooklm._logging.scrub_secrets`` IS
# such a sanitiser — it applies the same redaction pattern set used by
# ``RedactingFilter`` on the logging pipeline (cookies, CSRF tokens, OAuth
# bearers, Set-Cookie response headers, etc.) — but CodeQL's default
# allow-list doesn't know about it.
#
# Rather than peppering the codebase with per-line ``# noqa`` comments,
# disable the ``py/clear-text-logging-sensitive-data`` query for paths
# where scrub_secrets is the load-bearing redactor. The query still runs
# everywhere else in the codebase, so a new clear-text leak in
# (say) ``src/notebooklm/_*.py`` would still surface.
#
# Scoped narrowly to the rpc-health canary because (a) it's the only
# place where decoded RPC responses are printed for diagnostics, and
# (b) that path already routes through scrub_secrets at every site —
# see the comments at each print site in scripts/check_rpc_health.py.
#
# References:
# * https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning
# * https://codeql.github.com/codeql-query-help/python/py-clear-text-logging-sensitive-data/
# Exclude the rule globally. The standard CodeQL config schema for
# ``query-filters`` does NOT accept a ``paths`` sub-key — attempting to
# scope the suppression to a specific file via that field silently
# evaluates to "no exclusion". Since the only Python sites that hit
# ``py/clear-text-logging-sensitive-data`` in this codebase are the
# scrub_secrets-wrapped print paths in ``scripts/check_rpc_health.py``
# (the rpc-health canary script, which is the only place we deliberately
# echo decoded RPC responses for diagnostics), and every such site is
# already routed through ``scrub_secrets``, a global exclude is the
# correct trade-off. The rule remains a noisy false-positive generator
# for any sanitiser CodeQL doesn't recognise, and we don't have a custom
# sanitiser-model query pack to teach it about scrub_secrets.
query-filters:
- exclude:
id: py/clear-text-logging-sensitive-data
paths-ignore:
# scripts/check_rpc_health.py is a maintenance canary that prints
# decoded RPC bodies for human diagnosis when the API drifts. Every
# print site there calls scrub_secrets first. Belt-and-braces: also
# mark the file as path-ignored so any future CodeQL rule that
# similarly mis-models the local sanitiser won't gate the PR.
- 'scripts/check_rpc_health.py'
version: 2
updates:
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
groups:
dev-dependencies:
patterns:
- "pytest*"
- "mypy"
- "ruff"
- "vcrpy"
commit-message:
prefix: "deps"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
# Group all GitHub Actions updates into a single PR per week. Each PR
# touches multiple ``uses:`` lines, so grouping reduces review noise and
# keeps SHA-pinned third-party actions (publish/testpypi/claude/rpc-health
# /nightly/verify-package) moving together with their first-party
# ``actions/*`` peers. See ``scripts/check_action_pinning.py`` for the
# invariant Dependabot must keep honoring.
groups:
actions:
patterns:
- "*"
commit-message:
prefix: "ci"
Description
A clear description of the bug.
Steps to Reproduce
1. ... 2. ... 3. ...
Expected Behavior
What you expected to happen.
Actual Behavior
What actually happened. Include the full error message or traceback if applicable.
Paste error output hereEnvironment
- OS: (e.g., macOS 15, Ubuntu 24.04, Windows 11)
- Python version: (e.g., 3.12)
- notebooklm-py version: (run
notebooklm --version) - Install method: (pip, uv, pipx)
- Surface: (CLI, Python API, MCP, REST server, desktop extension, docs)
Debug Output
If applicable, run the failing command with -vv for verbose logging and paste the relevant output:
notebooklm -vv <your-command-here>For auth/context issues, also include these outputs with cookies, emails, notebook titles, and paths redacted as needed:
notebooklm doctor --json
notebooklm status --paths --json
notebooklm auth check --test --jsonChecklist
- [ ] I verified this bug exists on the latest version of notebooklm-py
- [ ] I searched existing issues and this is not a duplicate
blank_issues_enabled: false
Use Case
Describe the problem or workflow this feature would address.
Target Surface
- [ ] CLI
- [ ] Python API
- [ ] MCP
- [ ] REST server
- [ ] Desktop extension
- [ ] Documentation
Proposed Solution
How you'd like this to work (CLI usage, API example, etc.).
Alternatives Considered
Any alternative approaches you've thought of.
Summary
Brief description of the changes.
Related Issue
Closes #<issue_number>
Changes
- ...
Test Plan
- [ ] I tested these changes locally
- [ ] Tests pass (
uv run pytest --cov=src/notebooklm --cov-report=term-missing --cov-report=json:coverage.json --cov-fail-under=90) - [ ] Linting and formatting pass (
uv run pre-commit run --all-files) - [ ] Type checking passes (
uv run mypy src/notebooklm --ignore-missing-imports) - [ ] If this PR changes architectural shape, an ADR has been added or updated.
Notes
Any additional context, trade-offs, or design decisions.
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
permissions:
contents: read
jobs:
claude:
# AND-gate on both the actor (``sender.login``) and the keyword. For the
# ``issues`` event we additionally require ``issue.user.login`` to match
# — otherwise an attacker could open an issue with ``@claude`` in the
# body and wait for ``teng-lin`` to assign it: ``sender`` would then be
# ``teng-lin`` while the prompt text remains attacker-authored. Keeping
# the author check pins both the trigger and the prompt to the same
# trusted account.
if: |
github.event.sender.login == 'teng-lin' && (
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && github.event.issue.user.login == 'teng-lin' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
)
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write # Required to post inline review-thread comments (not just a sticky issue comment)
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
fetch-depth: 1
persist-credentials: false
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@2fee15510437d71399d9139ed60433470484a8fb # @ v1.0.153
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Allow the GitHub inline-comment tool so a `@claude review` lands its
# findings as inline PR review-thread comments (visible + addressable
# like gemini/coderabbit), instead of only a single sticky issue
# comment that the merge gate can miss. `--allowedTools` is additive in
# this action (base Read/Glob/Grep/LS stay included). See docs/usage.md.
claude_args: '--allowedTools "mcp__github_inline_comment__create_inline_comment"'
name: CodeQL
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: '0 12 * * 1' # Monday at noon UTC
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
security-events: write
contents: read
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: python
# ``py/clear-text-logging-sensitive-data`` flags every path
# where a value tagged as secret reaches a print statement,
# even when the project's local sanitiser
# ``notebooklm._logging.scrub_secrets`` has already redacted
# the value. ``codeql-config.yml`` scopes the suppression
# narrowly to ``scripts/check_rpc_health.py`` (the only
# script that prints decoded RPC responses for diagnostics);
# the query still runs everywhere else.
config-file: .github/codeql-config.yml
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
# Runs pip-audit against the locked environment so a CVE landing in a
# transitive dep surfaces in CI rather than at next user install. Any
# unresolved advisory fails the job (a hard merge gate on PRs, a red main
# build on push/schedule).
name: dependency-audit
on:
push:
branches: [main]
pull_request:
paths:
- "pyproject.toml"
- "uv.lock"
- ".github/workflows/dependency-audit.yml"
schedule:
# Nightly at 06:00 UTC so transitive CVEs surface even when no PR
# touches the manifest.
- cron: "0 6 * * *"
workflow_dispatch: {}
permissions:
contents: read
jobs:
pip-audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: astral-sh/setup-uv@v7
with:
enable-cache: true
- name: Sync locked env (browser + dev + markdown)
# Retried to ride out transient registry/network blips: a real
# lockfile problem fails all 3 attempts identically, so this only
# absorbs flakes — it never masks a genuine resolution error.
shell: bash
run: |
attempt=1; max=3
until uv sync --frozen --extra browser --extra dev --extra markdown; do
if [ "$attempt" -ge "$max" ]; then
echo "::error::uv sync failed after $max attempts" >&2
exit 1
fi
echo "::warning::uv sync attempt $attempt failed; retrying in $((attempt * 15))s" >&2
sleep $((attempt * 15))
attempt=$((attempt + 1))
done
- name: Install pip-audit into the locked env
# Pin pip-audit on the current major (2.x) so two consecutive nightly
# runs use the same advisory-DB query logic — without this, a silent
# bump to 3.x could change strictness without an accompanying PR.
run: uv pip install 'pip-audit>=2.7.0,<3'
- name: pip-audit (locked env)
# Hard merge gate: any unresolved advisory fails the job. If a
# transient advisory genuinely needs to be ignored, pin the fix in
# uv.lock or use pip-audit's --ignore-vuln for a tracked exception.
# Audit the exported lock graph instead of the installed environment so
# unreleased local package versions do not fail strict collection.
run: |
set -o pipefail
uv export --frozen --extra browser --extra dev --extra markdown --format requirements-txt --no-emit-project \
| uv run pip-audit --strict --require-hashes --disable-pip -r /dev/stdin
name: Nightly E2E Tests
on:
schedule:
# Main branch: 6 AM UTC (10 PM PST / 1 AM EST)
- cron: '0 6 * * *'
workflow_dispatch:
inputs:
test_filter:
description: 'Specific test to run (e.g., tests/e2e/test_downloads.py::TestDownloadReport)'
required: false
default: ''
custom_branch:
description: 'Override branch to test (leave empty to test the branch you triggered from)'
required: false
default: ''
permissions:
contents: read
jobs:
# Determine which branch to test
resolve-branch:
runs-on: ubuntu-latest
if: github.repository == 'teng-lin/notebooklm-py'
outputs:
branch: ${{ steps.resolve.outputs.branch }}
is_standard: ${{ steps.resolve.outputs.is_standard }}
steps:
- name: Resolve target branch
id: resolve
# Route every workflow_dispatch input + GitHub-context value through
# the env block so crafted values (e.g. ``"; rm -rf / ; #``) reach
# the shell as literal strings, not as inlined script. ``GITHUB_OUTPUT``
# is set automatically by the runner.
env:
EVENT_NAME: ${{ github.event_name }}
CUSTOM_BRANCH: ${{ inputs.custom_branch }}
REF_NAME: ${{ github.ref_name }}
run: |
set -euo pipefail
if [ "$EVENT_NAME" = "schedule" ]; then
# Scheduled runs test main only. Release branches are manual.
TARGET="main"
else
# Manual: use custom_branch if set, otherwise use triggering branch
if [ -n "$CUSTOM_BRANCH" ]; then
TARGET="$CUSTOM_BRANCH"
else
TARGET="$REF_NAME"
fi
fi
echo "branch=$TARGET" >> "$GITHUB_OUTPUT"
# Check if it's main or a release branch
case "$TARGET" in
main|release/*)
echo "is_standard=true" >> "$GITHUB_OUTPUT"
;;
*)
echo "is_standard=false" >> "$GITHUB_OUTPUT"
;;
esac
echo "Resolved branch: $TARGET"
# Run E2E tests on the resolved branch
e2e:
name: E2E Tests (${{ needs.resolve-branch.outputs.branch }}/${{ matrix.os }})
needs: resolve-branch
if: needs.resolve-branch.outputs.is_standard == 'true'
runs-on: ${{ matrix.os }}
# Secret-bearing job. Two-layer gating:
#
# 1. ``needs.resolve-branch.outputs.is_standard`` — set by the upstream
# ``resolve-branch`` job. Only ``main`` and ``release/*`` branches
# (or scheduled cron triggers, which resolve to one of those) flip
# this to ``true``; any other branch keeps it ``false`` and the
# job-level ``if:`` above skips this whole job (no secret values
# land in the runner env). The step-level ``if:`` guards on the
# secret-bearing steps below are belt-and-suspenders so the gate
# stays visible if the job-level guard is ever loosened.
# 2. ``environment: protected-readonly`` (unconditional). The secrets
# this job consumes (``NOTEBOOKLM_AUTH_JSON`` and friends) live only
# in that environment, so the binding has to be unconditional —
# issue #1009 surfaced the scheduled cron failing when the previous
# conditional (``workflow_dispatch``-only) form fell back to
# repo-level secrets that no longer exist. Add a ``required
# reviewers`` rule on the environment if you want to block
# workflow_dispatch behind manual approval; scheduled runs would
# then queue too, so today this env carries no protection rules.
# See docs/development.md → "Workflow secret gates".
environment: protected-readonly
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
env:
# Pin Playwright's browser install dir to a single workspace-relative
# path on every OS — see test.yml for the rationale (sidesteps the
# actions/cache@v5 Windows dual-path restore flake).
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.playwright-browsers
concurrency:
group: nightly-${{ needs.resolve-branch.outputs.branch }}-${{ matrix.os }}
cancel-in-progress: true
steps:
- uses: actions/checkout@v7
with:
ref: ${{ needs.resolve-branch.outputs.branch }}
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
cache: 'pip'
- name: Install uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # @ v7.0.0
- name: Install dependencies
# `uv sync --frozen` uses `uv.lock` for deterministic dep resolution
# (same as the contributor workflow in docs/installation.md). Extras
# cover the full E2E surface: `browser` = playwright, `dev` = pytest +
# plugins, `markdown` = markdownify (used by source-conversion tests).
# Retried to ride out transient registry/network blips: a real lockfile
# problem fails all 3 attempts identically, so this only absorbs flakes
# — it never masks a genuine resolution error.
shell: bash
run: |
attempt=1; max=3
until uv sync --frozen --extra browser --extra dev --extra markdown; do
if [ "$attempt" -ge "$max" ]; then
echo "::error::uv sync failed after $max attempts" >&2
exit 1
fi
echo "::warning::uv sync attempt $attempt failed; retrying in $((attempt * 15))s" >&2
sleep $((attempt * 15))
attempt=$((attempt + 1))
done
- name: Get Playwright version
id: playwright-version
shell: bash
# `uv pip show` / bare `pip show` after activation can resolve outside
# `.venv` when uv has not seeded pip into the project venv, yielding an
# empty version that collapses the browser cache key to
# `playwright-${{ matrix.os }}--ws`. `importlib.metadata` reads
# `.venv`'s actual installed dist-info via the active interpreter, so
# the version is always correct.
run: |
VERSION=$(uv run python -c 'import importlib.metadata as m; print(m.version("playwright"))')
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Cache Playwright browsers
uses: actions/cache@v5
with:
path: ${{ github.workspace }}/.playwright-browsers
# ``-ws`` suffix invalidates archives keyed to the prior dual-path
# stanza so first restores after this change land on the new path.
key: playwright-${{ matrix.os }}-${{ steps.playwright-version.outputs.version }}-ws
- name: Install Playwright browsers
run: uv run playwright install chromium
- name: Install Playwright system dependencies (Linux)
if: runner.os == 'Linux'
run: uv run playwright install-deps chromium
# Fail-fast preflight. Without this, an empty NOTEBOOKLM_AUTH_JSON
# (env-binding misconfig, missing repo-level fallback, etc.) lets the
# E2E suite proceed; pytest then skips every auth-requiring test
# silently and the job lands green with 0 tests run (issue #1009).
- name: Verify auth secret is present
if: needs.resolve-branch.outputs.is_standard == 'true'
env:
NOTEBOOKLM_AUTH_JSON: ${{ secrets.NOTEBOOKLM_AUTH_JSON }}
NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID: ${{ secrets.NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID }}
shell: bash
run: |
if [ -z "${NOTEBOOKLM_AUTH_JSON:-}" ]; then
echo "::error::NOTEBOOKLM_AUTH_JSON resolved to empty. Env binding or secret config is broken — see docs/development.md → Workflow secret gates."
exit 1
fi
if [ -z "${NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID:-}" ]; then
echo "::error::NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID resolved to empty."
exit 1
fi
- name: Run E2E tests
id: e2e
# Hard gate: only standard branches (main/release/*, or scheduled cron
# runs which resolve to one of those) expose credentials. Any other
# branch — including ad-hoc workflow_dispatch on a feature branch —
# gets ``is_standard == 'false'`` from resolve-branch and skips here
# outright. The job-level ``environment:`` adds the maintainer approval
# layer for workflow_dispatch runs that DO resolve as standard.
if: needs.resolve-branch.outputs.is_standard == 'true'
# Don't fail the job here — the retry step below gets a 10-min cool-down
# shot at any failures, and its exit code is what marks the job red.
continue-on-error: true
env:
NOTEBOOKLM_AUTH_JSON: ${{ secrets.NOTEBOOKLM_AUTH_JSON }}
NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID: ${{ secrets.NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID }}
NOTEBOOKLM_GENERATION_NOTEBOOK_ID: ${{ secrets.NOTEBOOKLM_GENERATION_NOTEBOOK_ID }}
# Route every workflow_dispatch input + upstream needs.* value through
# the env block so crafted values (e.g. ``"; echo PWN``) reach pytest
# as literal strings, not as inlined shell. ``TARGET_BRANCH`` is
# derived from ``inputs.custom_branch`` upstream; ``TEST_FILTER`` is
# the direct workflow_dispatch input (empty for scheduled runs).
TEST_FILTER: ${{ inputs.test_filter }}
TARGET_BRANCH: ${{ needs.resolve-branch.outputs.branch }}
MATRIX_OS: ${{ matrix.os }}
shell: bash
run: |
echo "Testing branch: $TARGET_BRANCH"
# --reruns 1 catches sub-minute network blips; longer chat-throttle
# recovery is handled by the cool-down retry step below.
if [ -n "$TEST_FILTER" ]; then
# Run specific test(s) when filter provided.
# ``--`` separator forces $TEST_FILTER to be parsed as positional
# file/path args, never as pytest options (e.g. a crafted ``--co``
# or ``-k "expr"`` value cannot inject pytest flags).
uv run pytest -s -v --tb=short --reruns 1 --reruns-delay 30 -- "$TEST_FILTER"
elif [ "$MATRIX_OS" = "ubuntu-latest" ]; then
# Linux: run all E2E tests except variants
uv run pytest tests/e2e -m "not variants" -s -v --tb=short --reruns 1 --reruns-delay 30
else
# Windows: run only read-only tests (safer, avoids write operations)
uv run pytest tests/e2e -m "readonly and not variants" -s -v --tb=short --reruns 1 --reruns-delay 30
fi
- name: Retry failed E2E tests after 10-min cool-down
# Tests still throttled after the cool-down become skips via the
# _install_chat_rate_limit_skip fixture in tests/e2e/conftest.py.
# Same ``is_standard`` hard gate as the primary E2E step above — the
# retry must never expose secrets on a non-standard branch even if a
# previous step somehow ran (e.g. re-run with edited workflow inputs).
if: |
needs.resolve-branch.outputs.is_standard == 'true'
&& steps.e2e.outcome == 'failure'
env:
NOTEBOOKLM_AUTH_JSON: ${{ secrets.NOTEBOOKLM_AUTH_JSON }}
NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID: ${{ secrets.NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID }}
NOTEBOOKLM_GENERATION_NOTEBOOK_ID: ${{ secrets.NOTEBOOKLM_GENERATION_NOTEBOOK_ID }}
shell: bash
run: |
# Missing lastfailed after a failed e2e step means pytest crashed
# before writing the cache (import error, OOM, etc.) — that's a real
# failure, not a recoverable rate-limit, so fail the job rather than
# silently going green.
if [ ! -f .pytest_cache/v/cache/lastfailed ]; then
echo "::error::No lastfailed cache from previous step; pytest likely crashed before running tests."
exit 1
fi
echo "Initial E2E run failed. Sleeping 600s before retrying failed tests."
sleep 600
# Pin ``tests/e2e`` so pyproject's ``addopts = --ignore=tests/e2e``
# doesn't drop every lastfailed entry; otherwise pytest's default
# ``--last-failed-no-failures=all`` would expand to the full non-e2e
# suite under NOTEBOOKLM_AUTH_JSON. ``=none`` makes a missing cache
# fail fast instead.
uv run pytest tests/e2e --last-failed --last-failed-no-failures=none -s -v --tb=short
name: Publish to PyPI
on:
push:
tags:
- "v*"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
build-and-test:
name: Build wheel and run release smoke
runs-on: ubuntu-latest
# Smoke install pulls `[browser,dev,markdown]` extras (pytest, playwright,
# mypy, ruff, markdownify, transitive deps). A compromised dep here is a
# supply-chain concern but cannot mint a Trusted Publishing OIDC token,
# because this job is not granted `id-token: write`. The publish job
# below carries that scope and runs nothing except the trusted PyPA
# action. See issue #820 for the threat model.
permissions:
contents: read
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up Python 3.12
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Validate tag matches version
run: |
TAG_VERSION=${GITHUB_REF#refs/tags/v}
TOML_VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])")
if [ "$TAG_VERSION" != "$TOML_VERSION" ]; then
echo "Error: Tag version ($TAG_VERSION) doesn't match pyproject.toml ($TOML_VERSION)"
exit 1
fi
echo "Version validated: $TOML_VERSION"
- name: Install build tools
run: |
python -m pip install --upgrade pip
python -m pip install build==1.5.0
- name: Build package
run: |
python -m build
- name: Upload distribution artifacts
# Capture pristine wheel + sdist BEFORE any third-party code (smoke
# install, playwright, pytest) runs against `dist/`. If we uploaded
# after the smoke install, a compromised dev/test dep could mutate
# `dist/` post-build, and the publish job would upload tampered bytes
# — attested with the project's trusted OIDC identity. Uploading here
# snapshots the build output before any attacker-controlled code has
# touched the runner. Smoke install/test below still runs against
# `dist/` for behavior validation but can no longer influence what
# the publish job receives.
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # @ v7
with:
name: notebooklm-py-dist
path: dist/
if-no-files-found: error
# One-day retention is intentional: publish consumes this artifact
# immediately. See issue #829 for the recovery trade-off.
retention-days: 1
- name: Install built wheel + canonical contributor extras in a clean venv
# Canonical contributor extras (`[browser,dev,markdown]`, equivalent to
# `[all]`) match the install path documented in `docs/installation.md`,
# so the release smoke exercises the same surface area users will see.
# Plain `[dev]` skipped playwright + markdownify, masking packaging bugs
# in `[browser]`/`[markdown]` until users hit them post-release.
run: |
python -m venv smoke-venv
WHEEL=$(ls dist/notebooklm_py-*.whl)
smoke-venv/bin/pip install "${WHEEL}[browser,dev,markdown]"
smoke-venv/bin/python -c "import notebooklm; print(notebooklm.__version__)"
- name: Install Playwright browser for smoke
# `[browser]` only installs the playwright Python package; the chromium
# binary + Linux system libs are needed for the unit-test smoke
# (tests/unit/test_windows_compatibility.py drives sync_playwright()).
run: |
smoke-venv/bin/playwright install chromium
smoke-venv/bin/playwright install-deps chromium
- name: Run unit tests against wheel
run: smoke-venv/bin/pytest tests/unit -q --no-cov -x
publish:
name: Publish to PyPI
needs: build-and-test
runs-on: ubuntu-latest
environment: release
# Minimum-scope publish job: only the trusted PyPA action runs here. No
# `actions/checkout`, no Python install, no third-party dependencies in
# the runner environment — so the OIDC token request that `id-token:
# write` enables has no co-tenant code that could exfiltrate it.
permissions:
id-token: write
contents: read
steps:
- name: Download distribution artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # @ v8.0.1
with:
name: notebooklm-py-dist
path: dist/
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # @ v1.14.0
with:
# PEP 740 attestations: pypa/gh-action-pypi-publish generates an
# in-toto attestation for the uploaded artifacts and publishes it
# via PyPI's Trusted Publishing flow (OIDC, no API token).
attestations: true
name: RPC Health Check
on:
schedule:
# Main branch: 7 AM UTC daily (1 hour after main nightly E2E tests)
- cron: '0 7 * * *'
workflow_dispatch:
inputs:
custom_branch:
description: 'Override branch to test (leave empty to test the branch you triggered from)'
required: false
default: ''
permissions:
contents: read
jobs:
# Determine which branch to test
resolve-branch:
runs-on: ubuntu-latest
if: github.repository == 'teng-lin/notebooklm-py'
outputs:
branch: ${{ steps.resolve.outputs.branch }}
is_standard: ${{ steps.resolve.outputs.is_standard }}
steps:
- name: Resolve target branch
id: resolve
env:
EVENT_NAME: ${{ github.event_name }}
CUSTOM_BRANCH: ${{ inputs.custom_branch }}
REF_NAME: ${{ github.ref_name }}
run: |
set -euo pipefail
if [ "$EVENT_NAME" = "schedule" ]; then
# Scheduled runs test main only. Release branches are manual.
TARGET="main"
else
if [ -n "$CUSTOM_BRANCH" ]; then
TARGET="$CUSTOM_BRANCH"
else
TARGET="$REF_NAME"
fi
fi
echo "branch=$TARGET" >> "$GITHUB_OUTPUT"
case "$TARGET" in
main|release/*)
echo "is_standard=true" >> "$GITHUB_OUTPUT"
;;
*)
echo "is_standard=false" >> "$GITHUB_OUTPUT"
;;
esac
echo "Resolved branch: $TARGET"
health-check:
name: RPC Health Check (${{ needs.resolve-branch.outputs.branch }})
needs: resolve-branch
if: needs.resolve-branch.outputs.is_standard == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
concurrency:
group: rpc-health-${{ needs.resolve-branch.outputs.branch }}
cancel-in-progress: true
# Secret-bearing job. ``NOTEBOOKLM_AUTH_JSON`` and friends live only in
# the ``protected-readonly`` GitHub Environment (issue #1009: relying on
# repo-level fallback for the scheduled-cron branch broke when those
# secrets were migrated env-only). Binding the env unconditionally is
# what gives every trigger — scheduled cron, workflow_dispatch from a
# maintainer, etc. — access to the same secret values. Workflow_dispatch
# is still hard-gated by ``needs.resolve-branch.outputs.is_standard``
# above so a feature-branch dispatch never reaches this job at all.
# Add a ``required reviewers`` rule on the environment if you want to
# block workflow_dispatch behind manual approval; scheduled runs would
# then queue too, so today this env carries no protection rules.
# See docs/development.md → "Workflow secret gates".
environment: protected-readonly
steps:
- uses: actions/checkout@v7
with:
ref: ${{ needs.resolve-branch.outputs.branch }}
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
cache: 'pip'
- name: Install uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # @ v7.0.0
- name: Install dependencies
# `uv sync --frozen` reproduces the lockfile-pinned dep tree. The RPC
# health script (`scripts/check_rpc_health.py`) only needs the core
# runtime deps — no `[browser]`/`[dev]`/`[markdown]` extras required.
# Retried to ride out transient registry/network blips: a real lockfile
# problem fails all 3 attempts identically, so this only absorbs flakes
# — it never masks a genuine resolution error.
shell: bash
run: |
attempt=1; max=3
until uv sync --frozen; do
if [ "$attempt" -ge "$max" ]; then
echo "::error::uv sync failed after $max attempts" >&2
exit 1
fi
echo "::warning::uv sync attempt $attempt failed; retrying in $((attempt * 15))s" >&2
sleep $((attempt * 15))
attempt=$((attempt + 1))
done
# Fail-fast preflight. The script itself rejects empty
# NOTEBOOKLM_AUTH_JSON downstream, but checking at the workflow layer
# surfaces ``::error::`` annotations linked to the secret-config
# misconfig before the ``continue-on-error`` step swallows the exit
# code (issue #1009).
- name: Verify auth secret is present
env:
NOTEBOOKLM_AUTH_JSON: ${{ secrets.NOTEBOOKLM_AUTH_JSON }}
NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID: ${{ secrets.NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID }}
shell: bash
run: |
if [ -z "${NOTEBOOKLM_AUTH_JSON:-}" ]; then
echo "::error::NOTEBOOKLM_AUTH_JSON resolved to empty. Env binding or secret config is broken — see docs/development.md → Workflow secret gates."
exit 1
fi
if [ -z "${NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID:-}" ]; then
echo "::error::NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID resolved to empty."
exit 1
fi
- name: Run RPC Health Check
id: health
continue-on-error: true
shell: bash
env:
NOTEBOOKLM_AUTH_JSON: ${{ secrets.NOTEBOOKLM_AUTH_JSON }}
NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID: ${{ secrets.NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID }}
NOTEBOOKLM_GENERATION_NOTEBOOK_ID: ${{ secrets.NOTEBOOKLM_GENERATION_NOTEBOOK_ID }}
TARGET_BRANCH: ${{ needs.resolve-branch.outputs.branch }}
run: |
echo "Testing branch: $TARGET_BRANCH"
set +e
uv run python scripts/check_rpc_health.py --full 2>&1 | tee health-report.txt
exit_code=${PIPESTATUS[0]}
echo "exit_code=${exit_code}" >> "$GITHUB_OUTPUT"
exit "$exit_code"
- name: Scrub secrets from health-report.txt
# Defence-in-depth: the script itself avoids printing credential-shaped
# data, but raw response bodies and exception tracebacks can still
# surface session/CSRF/cookie material on regressions. We re-run the
# report through ``notebooklm._logging.scrub_secrets`` before any
# downstream consumer reads it (Add Summary, Create Issue on …,
# Upload Report). Idempotent — safe to re-apply on subsequent runs.
if: always()
shell: bash
run: |
if [ ! -f health-report.txt ]; then
echo "::warning::health-report.txt not produced; skipping scrub."
exit 0
fi
uv run python - <<'PY'
from pathlib import Path
from notebooklm._logging import scrub_secrets
report = Path("health-report.txt")
original = report.read_text(encoding="utf-8", errors="replace")
scrubbed = scrub_secrets(original)
if scrubbed != original:
report.write_text(scrubbed, encoding="utf-8")
# ``abs`` because replacement tokens (``***``) may be longer
# OR shorter than the redacted secret depending on the
# pattern, so the raw delta can be negative; the magnitude
# is what's interesting for sanity-checking the scrub.
print(
f"Scrubbed health-report.txt "
f"(delta {abs(len(original) - len(scrubbed))} chars)."
)
else:
print("No secret-shaped substrings detected in health-report.txt.")
PY
- name: Add Summary
if: always()
shell: bash
run: |
echo "## RPC Health Check Results" >> "$GITHUB_STEP_SUMMARY"
grep -A 10 "^SUMMARY$" health-report.txt >> "$GITHUB_STEP_SUMMARY" || echo "No summary found" >> "$GITHUB_STEP_SUMMARY"
# Bundle-drift monitor (capture_rpc_registry.py). NOT a PR gate — it depends
# on Google's live external JS bundle, which can rotate at any time — so it
# rides the nightly/issue-filing track here. ``--check`` gates id rotation
# (ABSENT), ``--check-enums`` gates studio enum CHANGED/STALE (a selectable
# format renumbered/retired — the #1597 class). NEW/UNPARSED, quota codes and
# proto assertions print for visibility but never fail. ``continue-on-error``
# so a drift hit files an issue rather than red-failing the canary outright.
- name: Run bundle drift monitor
id: bundle
continue-on-error: true
shell: bash
env:
NOTEBOOKLM_AUTH_JSON: ${{ secrets.NOTEBOOKLM_AUTH_JSON }}
run: |
set +e
uv run python scripts/capture_rpc_registry.py --check --check-enums 2>&1 \
| tee bundle-drift-report.txt
exit_code=${PIPESTATUS[0]}
echo "exit_code=${exit_code}" >> "$GITHUB_OUTPUT"
exit "$exit_code"
- name: Scrub secrets from bundle-drift-report.txt
# The bundle is public CDN content, but the discovery homepage read is
# authenticated and a transport error could echo a request URL; scrub
# before any downstream consumer reads the file (same posture as the
# health-report scrub above). Idempotent.
if: always()
shell: bash
run: |
if [ ! -f bundle-drift-report.txt ]; then
echo "::warning::bundle-drift-report.txt not produced; skipping scrub."
exit 0
fi
uv run python - <<'PY'
from pathlib import Path
from notebooklm._logging import scrub_secrets
report = Path("bundle-drift-report.txt")
original = report.read_text(encoding="utf-8", errors="replace")
scrubbed = scrub_secrets(original)
if scrubbed != original:
report.write_text(scrubbed, encoding="utf-8")
print(f"Scrubbed bundle-drift-report.txt (delta {abs(len(original) - len(scrubbed))} chars).")
else:
print("No secret-shaped substrings detected in bundle-drift-report.txt.")
PY
- name: Check for existing bundle drift issue
if: steps.bundle.outputs.exit_code != '0'
id: dup_bundle
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if ! count=$(gh issue list --repo "$GITHUB_REPOSITORY" \
--state open --label automated --label rpc-breakage \
--search 'in:title "Studio enum / RPC drift detected"' \
--json number --jq 'length'); then
echo "::warning::Bundle drift dedup probe failed; allowing issue creation"
count=0
fi
echo "open=${count}" >> "$GITHUB_OUTPUT"
- name: Create Issue on bundle drift
if: |
steps.bundle.outputs.exit_code != '0'
&& steps.dup_bundle.outputs.open == '0'
uses: peter-evans/create-issue-from-file@fca9117c27cdc29c6c4db3b86c48e4115a786710 # @ v6.0.0
with:
title: "Studio enum / RPC drift detected"
content-filepath: bundle-drift-report.txt
labels: rpc-breakage, automated
- name: Upload bundle drift report
if: always()
uses: actions/upload-artifact@v7
with:
name: rpc-bundle-drift-report
path: bundle-drift-report.txt
retention-days: 30
# Each issue-creating step is paired with a dedup probe that counts
# open issues sharing the same title + automated label. Without this,
# back-to-back failing runs (manual reruns, cron + workflow_dispatch)
# farm one fresh issue per run instead of letting the open one
# accumulate context.
- name: Check for existing RPC Mismatch issue
if: steps.health.outputs.exit_code == '1'
id: dup_mismatch
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if ! count=$(gh issue list --repo "$GITHUB_REPOSITORY" \
--state open --label automated --label rpc-breakage \
--search 'in:title "RPC ID Mismatch Detected"' \
--json number --jq 'length'); then
echo "::warning::RPC mismatch dedup probe failed; allowing issue creation"
count=0
fi
echo "open=${count}" >> "$GITHUB_OUTPUT"
- name: Create Issue on RPC Mismatch
if: |
steps.health.outputs.exit_code == '1'
&& steps.dup_mismatch.outputs.open == '0'
uses: peter-evans/create-issue-from-file@fca9117c27cdc29c6c4db3b86c48e4115a786710 # @ v6.0.0
with:
title: "RPC ID Mismatch Detected"
content-filepath: health-report.txt
labels: bug, rpc-breakage, automated
- name: Check for existing Auth Failure issue
if: steps.health.outputs.exit_code == '2'
id: dup_auth
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if ! count=$(gh issue list --repo "$GITHUB_REPOSITORY" \
--state open --label automated \
--search 'in:title "RPC Health Check: Authentication Failure"' \
--json number --jq 'length'); then
echo "::warning::Auth failure dedup probe failed; allowing issue creation"
count=0
fi
echo "open=${count}" >> "$GITHUB_OUTPUT"
- name: Create Issue on Auth Failure
if: |
steps.health.outputs.exit_code == '2'
&& steps.dup_auth.outputs.open == '0'
uses: peter-evans/create-issue-from-file@fca9117c27cdc29c6c4db3b86c48e4115a786710 # @ v6.0.0
with:
title: "RPC Health Check: Authentication Failure"
content-filepath: health-report.txt
labels: bug, automated
- name: Check for existing non-transient ERROR issue
if: steps.health.outputs.exit_code == '3'
id: dup_error
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if ! count=$(gh issue list --repo "$GITHUB_REPOSITORY" \
--state open --label automated --label rpc-error \
--search 'in:title "RPC Health Check: Non-transient ERROR detected"' \
--json number --jq 'length'); then
echo "::warning::Non-transient ERROR dedup probe failed; allowing issue creation"
count=0
fi
echo "open=${count}" >> "$GITHUB_OUTPUT"
- name: Extract failing methods for ERROR issue
if: |
steps.health.outputs.exit_code == '3'
&& steps.dup_error.outputs.open == '0'
id: error_details
shell: bash
run: |
# Pull the affected-methods list out of the RESULT line emitted by the script.
affected=$(grep -m1 "non-transient ERROR detected in methods:" health-report.txt \
| sed -E 's/.*methods: //' || true)
if [ -z "$affected" ]; then
affected="(see report)"
fi
{
echo "## Non-transient ERROR detected"
echo ""
echo "**Affected methods:** ${affected}"
echo ""
echo "**Commit:** ${GITHUB_SHA}"
echo ""
echo "Full report attached below (rate-limit / \`RESOURCE_EXHAUSTED\` errors"
echo "are filtered out — anything listed here is a real failure that needs"
echo "investigation: timeouts, parse failures, unexpected HTTP errors)."
echo ""
echo "---"
echo ""
cat health-report.txt
} > error-issue-body.md
- name: Create Issue on Non-transient ERROR
if: |
steps.health.outputs.exit_code == '3'
&& steps.dup_error.outputs.open == '0'
uses: peter-evans/create-issue-from-file@fca9117c27cdc29c6c4db3b86c48e4115a786710 # @ v6.0.0
with:
title: "RPC Health Check: Non-transient ERROR detected"
content-filepath: error-issue-body.md
labels: rpc-error, bug, automated
# Exit code 4 = the ``sqTeoe`` cohort tripwire flipped (GetArtifactCustomization
# Choices returned non-null). Our account migrated to the new studio-customization
# surface; the VideoStyle / format codes in rpc/types.py must be re-captured.
# GATED-null is the expected steady state and never reaches this branch.
- name: Check for existing cohort-flip issue
if: steps.health.outputs.exit_code == '4'
id: dup_cohort
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if ! count=$(gh issue list --repo "$GITHUB_REPOSITORY" \
--state open --label automated --label rpc-breakage \
--search 'in:title "Studio customization cohort flipped"' \
--json number --jq 'length'); then
echo "::warning::Cohort-flip dedup probe failed; allowing issue creation"
count=0
fi
echo "open=${count}" >> "$GITHUB_OUTPUT"
- name: Create Issue on cohort flip
if: |
steps.health.outputs.exit_code == '4'
&& steps.dup_cohort.outputs.open == '0'
uses: peter-evans/create-issue-from-file@fca9117c27cdc29c6c4db3b86c48e4115a786710 # @ v6.0.0
with:
title: "Studio customization cohort flipped — re-capture VideoStyle codes"
content-filepath: health-report.txt
labels: rpc-breakage, automated
- name: Upload Report
if: always()
uses: actions/upload-artifact@v7
with:
name: rpc-health-report
path: health-report.txt
retention-days: 30
- name: Fail if health check failed
if: steps.health.outcome == 'failure'
run: |
echo "RPC Health Check failed (exit code: ${{ steps.health.outputs.exit_code }}). See report above."
exit 1
name: Test
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
inputs:
custom_branch:
description: 'Branch/ref to test (leave empty to use the triggering ref). Use this to run the full gate against a release branch that conflicts with main and so cannot run via pull_request.'
required: false
default: ''
concurrency:
# Include the dispatch input so a manual run against a release branch gets its
# own group instead of sharing (and cancelling) the push/PR run on the
# triggering ref. Empty for push/pull_request, so their behavior is unchanged.
group: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.custom_branch }}
cancel-in-progress: true
permissions:
contents: read
jobs:
quality:
name: Code Quality
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
# Honor the workflow_dispatch custom_branch input; falls back to the
# triggering ref for push/pull_request (input is empty there).
ref: ${{ inputs.custom_branch || github.ref }}
fetch-depth: 0
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
cache: 'pip'
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
- name: Install dependencies
shell: bash
run: |
# Retry to ride out transient registry/network blips during dep
# resolution: a single PyPI/GitHub hiccup was hard-failing the leg.
# `uv sync` has no internal retry across the full resolve+download.
# A real lockfile problem fails all 3 attempts identically, so this
# only absorbs flakes — it never masks a genuine resolution error.
attempt=1; max=3
until uv sync --frozen --extra browser --extra dev --extra markdown; do
if [ "$attempt" -ge "$max" ]; then
echo "::error::uv sync failed after $max attempts" >&2
exit 1
fi
echo "::warning::uv sync attempt $attempt failed; retrying in $((attempt * 15))s" >&2
sleep $((attempt * 15))
attempt=$((attempt + 1))
done
- name: Run pre-commit checks
run: uv run pre-commit run --all-files
- name: Run type checking
run: uv run mypy src/notebooklm --ignore-missing-imports
- name: Assert workflow permissions are scoped
run: uv run python scripts/check_workflow_permissions.py
- name: Assert workflow secret-bearing jobs are gated
# Companion to the permissions check: prevents a new
# ``${{ secrets.NAME }}`` reference from landing without picking up
# a job-level ``environment:`` declaration or a step-level
# ``is_standard`` guard. See docs/development.md →
# "Workflow secret gates".
run: uv run python scripts/check_workflow_secret_gates.py
- name: Assert third-party actions in privileged workflows are SHA-pinned
# Supply-chain gate: every third-party action (non ``actions/*``) in
# publish / testpypi-publish / claude / rpc-health / nightly /
# verify-package must use a 40-char commit SHA, not a floating
# ``@v1`` / ``@release/v1`` / branch ref. Dependabot bumps the
# SHAs weekly via the grouped ``actions`` updates in
# ``.github/dependabot.yml``.
run: uv run python scripts/check_action_pinning.py
- name: Assert no deprecation targets the shipping version
# Release gate: a DeprecationWarning must never name the version in
# pyproject.toml as its removal target. Lapsed shims are allowlisted
# inside the script with a tracking issue.
run: uv run python scripts/check_deprecation_targets.py
- name: Assert coverage thresholds match
run: uv run python scripts/check_coverage_thresholds.py
- name: Assert CI install matches CONTRIBUTING.md
run: uv run python scripts/check_ci_install_parity.py
- name: Assert repo-structure map (docs/architecture.md) is fresh
run: uv run python scripts/check_claude_md_freshness.py
- name: Assert doc module references are fresh
# Companion to the CLAUDE.md freshness gate: guards the rest of the docs
# against stale module paths. Every relative link into src/notebooklm/
# must resolve, and every inline module ref in the live docs must point at
# a real module (rare historical mentions are allowlisted, shrink-only).
run: uv run python scripts/check_docs_module_refs.py
- name: Audit public API compatibility
# ``--check-stale`` also fails when an allowlist entry matches no break
# against the baseline (it is already in the baseline). This forces the
# allowlist to be pruned at each release boundary instead of silently
# accumulating cruft. See docs/releasing.md → prune-allowlist-at-release.
run: uv run python scripts/audit_public_api_compat.py --check-stale
- name: Assert per-method RPC coverage
# Static check: each RPCMethod member must have at least one test
# reference AND at least one cassette body containing its RPC id.
# Pre-existing gaps are grandfathered via PREEXISTING_GAPS inside the
# script — that set is a one-way ratchet and must not grow.
run: uv run python tests/scripts/check_method_coverage.py
- name: Verify e2e test fixtures
run: uv run pytest tests/e2e --collect-only -q
mcp:
# The MCP server ships behind the optional `mcp` extra, so the canonical
# install (browser+dev+markdown) used by the main matrix omits fastmcp and the
# MCP suites (tests/unit/mcp, tests/integration/mcp_vcr) are skipped there via
# importorskip. This dedicated job installs the `mcp` extra and runs them, so
# the MCP adapter — including the research_import LBwxtb leg (#1541) — has real
# CI coverage across all three OSes.
name: MCP suite (${{ matrix.os }})
runs-on: ${{ matrix.os }}
needs: quality
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@v7
with:
ref: ${{ inputs.custom_branch || github.ref }}
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
cache: 'pip'
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
- name: Install dependencies (mcp extra)
# Retried to ride out transient registry/network blips: a real lockfile
# problem fails all 3 attempts identically, so this only absorbs flakes
# — it never masks a genuine resolution error. (The windows leg of this
# job has flaked on the `Install uv` / dep-resolution network hop.)
shell: bash
run: |
attempt=1; max=3
until uv sync --frozen --extra dev --extra mcp; do
if [ "$attempt" -ge "$max" ]; then
echo "::error::uv sync failed after $max attempts" >&2
exit 1
fi
echo "::warning::uv sync attempt $attempt failed; retrying in $((attempt * 15))s" >&2
sleep $((attempt * 15))
attempt=$((attempt + 1))
done
- name: Run MCP suite
# The MCP package is invisible to the main coverage job: that job installs
# only browser+dev+markdown (no `mcp` extra), so every `tests/unit/mcp`
# test is skipped there and `src/notebooklm/mcp/**` lands in coverage.json
# at 0%. This job is the only one that exercises the adapter, so it owns
# the coverage gate too. The threshold is the project-wide 90% floor (the
# same value `scripts/check_coverage_thresholds.py` pins everywhere — keep
# it identical or that drift guard fails); the adapter currently sits at
# ~96%, so this gate catches a regression back toward the old 0% blind spot.
run: >-
uv run pytest tests/unit/mcp tests/integration/mcp_vcr -q
--cov=src/notebooklm/mcp --cov-report=term-missing --cov-fail-under=90
server:
# The REST server ships behind the optional ``server`` extra, so the
# canonical install (browser+dev+markdown) used by the main matrix omits
# fastapi and ``tests/server`` is skipped there via ``importorskip``. This
# dedicated job installs the ``server`` extra and runs the suite across all
# three OSes — without it the adapter (incl. Unix-vs-Windows file handling)
# would have zero CI coverage.
name: REST server (${{ matrix.os }})
runs-on: ${{ matrix.os }}
needs: quality
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@v7
with:
ref: ${{ inputs.custom_branch || github.ref }}
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
cache: 'pip'
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
- name: Install dependencies (server extra)
# Retried to ride out transient registry/network blips: a real lockfile
# problem fails all 3 attempts identically, so this only absorbs flakes
# — it never masks a genuine resolution error.
shell: bash
run: |
attempt=1; max=3
until uv sync --frozen --extra dev --extra server; do
if [ "$attempt" -ge "$max" ]; then
echo "::error::uv sync failed after $max attempts" >&2
exit 1
fi
echo "::warning::uv sync attempt $attempt failed; retrying in $((attempt * 15))s" >&2
sleep $((attempt * 15))
attempt=$((attempt + 1))
done
- name: Run REST server suite
# Same blind spot as the MCP job: the main coverage job omits the `server`
# extra, so `tests/server` is skipped there and `src/notebooklm/server/**`
# registers 0% in coverage.json. This dedicated job owns the server
# coverage gate. The threshold is the project-wide 90% floor (kept
# identical everywhere so `check_coverage_thresholds.py` stays green); the
# adapter currently sits at ~92%, so this catches a slide back toward 0%.
run: >-
uv run pytest tests/server -q
--cov=src/notebooklm/server --cov-report=term-missing --cov-fail-under=90
test:
name: Test (${{ matrix.os }}, Python ${{ matrix.python-version }})
runs-on: ${{ matrix.os }}
needs: quality
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
env:
# Pin Playwright's browser install dir to a single workspace-relative
# path on every OS. The previous dual-path cache stanza (``~/.cache``
# + ``~/AppData/Local``) intermittently failed the cache restore on
# Windows with ``actions/cache@v5`` — the missing Linux path tripped
# the post-restore validation on a cache hit. Pinning the path here
# lets the cache stanza below carry exactly one entry on every OS.
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.playwright-browsers
steps:
- uses: actions/checkout@v7
with:
# Honor the workflow_dispatch custom_branch input; falls back to the
# triggering ref for push/pull_request (input is empty there).
ref: ${{ inputs.custom_branch || github.ref }}
persist-credentials: false
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
- name: Install dependencies
shell: bash
run: |
# Retry to ride out transient registry/network blips during dep
# resolution: a single PyPI/GitHub hiccup on one matrix entry was
# hard-failing the whole leg while the other 14 passed. `uv sync`
# has no internal retry across the full resolve+download. A real
# lockfile problem fails all 3 attempts identically, so this only
# absorbs flakes — it never masks a genuine resolution error.
attempt=1; max=3
until uv sync --frozen --extra browser --extra dev --extra markdown; do
if [ "$attempt" -ge "$max" ]; then
echo "::error::uv sync failed after $max attempts" >&2
exit 1
fi
echo "::warning::uv sync attempt $attempt failed; retrying in $((attempt * 15))s" >&2
sleep $((attempt * 15))
attempt=$((attempt + 1))
done
- name: Get Playwright version
id: playwright-version
shell: bash
run: |
echo "version=$(uv pip show playwright | grep '^Version:' | cut -d' ' -f2)" >> $GITHUB_OUTPUT
- name: Cache Playwright browsers
uses: actions/cache@v5
# Tolerate transient cache-backend failures: a miss already falls
# through to the install step below, and a backend hiccup should be
# treated the same. Without this, a 29s backend flake on a single
# matrix entry hard-fails the job and skips the entire test run.
continue-on-error: true
with:
path: ${{ github.workspace }}/.playwright-browsers
# ``-ws`` suffix invalidates archives keyed to the prior dual-path
# stanza so first restores after this change land on the new path.
key: playwright-${{ matrix.os }}-${{ steps.playwright-version.outputs.version }}-ws
- name: Install Playwright browsers
run: uv run playwright install chromium
- name: Install Playwright system dependencies (Linux)
if: runner.os == 'Linux'
shell: bash
run: |
if ! timeout 180s uv run playwright install-deps chromium; then
echo "::warning::Playwright system dependency installation timed out or failed."
echo "::warning::Continuing so the non-browser test matrix can run."
fi
- name: Assert cassettes are sanitized
# Runs on all matrix entries (ubuntu, macos, windows × 5 Python versions).
# An earlier bash-only gate was replaced by a portable Python invocation
# so the check runs uniformly across the cross-platform test matrix.
#
# ``--strict`` flips the repair-allowlist from "best-effort suppressor"
# to "must be empty" — the phase-2 cassette cleanup is done, so any
# entry sneaking back in is a CI failure (P1-5).
# ``--recursive`` extends the scan from ``tests/cassettes/*.yaml`` to
# ``tests/cassettes/**/*.yaml`` so a recorder can't smuggle a leak
# into a nested folder like ``tests/cassettes/gzip_coverage/`` (P1-5).
run: uv run python tests/scripts/check_cassettes_clean.py --strict --recursive
- name: Check fixtures for credential leaks
# The cassette guard above is scoped to ``tests/cassettes/*.yaml``. Golden
# RPC fixtures live under ``tests/fixtures/`` as ``.json`` (and one
# captured ``.html`` page), which embed the same WIZ_global_data shapes —
# a Google API key smuggled into a golden HTML fixture would otherwise
# slip past CI entirely (the GET_INTERACTIVE_HTML.json class). ``--secrets
# -only`` scans .json/.html/.yaml for high-severity credential shapes
# (Google auth tokens + API keys) without tripping on the intentional
# placeholder content (``"Scrubbed ..."`` names, test emails) that fills
# those fixtures.
run: uv run python tests/scripts/check_cassettes_clean.py --secrets-only --recursive tests/fixtures
- name: Run tests with coverage
# Emit coverage.json so the per-file floor check below can read it
# without re-running the suite. ``term-missing`` is kept so build logs
# still show the missing-lines summary.
run: uv run pytest --cov=src/notebooklm --cov-report=term-missing --cov-report=json:coverage.json --cov-fail-under=90
- name: Assert per-file coverage floors
# Linux-only because ``coverage.json`` on Windows records backslash
# paths (e.g. ``src\notebooklm\cli\doctor.py``) that won't match the
# forward-slash keys in ``[tool.notebooklm.per_file_coverage_floors]``
# in pyproject.toml. macOS uses forward slashes and would also work,
# but a single OS is enough — the floors are platform-independent.
if: runner.os == 'Linux'
run: uv run python scripts/check_coverage_thresholds.py --coverage-json coverage.json
name: Publish to TestPyPI
on:
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
build-and-test:
name: Build wheel and run release smoke
runs-on: ubuntu-latest
# Smoke install pulls `[browser,dev,markdown]` extras (pytest, playwright,
# mypy, ruff, markdownify, transitive deps). A compromised dep here is a
# supply-chain concern but cannot mint a Trusted Publishing OIDC token,
# because this job is not granted `id-token: write`. The publish job
# below carries that scope and runs nothing except the trusted PyPA
# action. See issue #820 for the threat model.
permissions:
contents: read
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up Python 3.12
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Get version from pyproject.toml
id: version
run: |
VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])")
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Publishing version: $VERSION"
- name: Install build tools
run: |
python -m pip install --upgrade pip
python -m pip install build==1.5.0
- name: Build package
run: python -m build
- name: Upload distribution artifacts
# Capture pristine wheel + sdist BEFORE any third-party code (smoke
# install, playwright, pytest) runs against `dist/`. If we uploaded
# after the smoke install, a compromised dev/test dep could mutate
# `dist/` post-build, and the publish job would upload tampered bytes
# — attested with the project's trusted OIDC identity. Uploading here
# snapshots the build output before any attacker-controlled code has
# touched the runner. Smoke install/test below still runs against
# `dist/` for behavior validation but can no longer influence what
# the publish job receives.
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # @ v7
with:
name: notebooklm-py-dist
path: dist/
if-no-files-found: error
# One-day retention is intentional: publish consumes this artifact
# immediately. See issue #829 for the recovery trade-off.
retention-days: 1
- name: Install built wheel + canonical contributor extras in a clean venv
# Canonical contributor extras (`[browser,dev,markdown]`, equivalent to
# `[all]`) match the install path documented in `docs/installation.md`,
# so the release smoke exercises the same surface area users will see.
# Plain `[dev]` skipped playwright + markdownify, masking packaging bugs
# in `[browser]`/`[markdown]` until users hit them post-release.
run: |
python -m venv smoke-venv
WHEEL=$(ls dist/notebooklm_py-*.whl)
smoke-venv/bin/pip install "${WHEEL}[browser,dev,markdown]"
smoke-venv/bin/python -c "import notebooklm; print(notebooklm.__version__)"
- name: Install Playwright browser for smoke
# `[browser]` only installs the playwright Python package; the chromium
# binary + Linux system libs are needed for the unit-test smoke
# (tests/unit/test_windows_compatibility.py drives sync_playwright()).
run: |
smoke-venv/bin/playwright install chromium
smoke-venv/bin/playwright install-deps chromium
- name: Run unit tests against wheel
run: smoke-venv/bin/pytest tests/unit -q --no-cov -x
publish:
name: Publish to TestPyPI
needs: build-and-test
runs-on: ubuntu-latest
environment: testpypi
# Minimum-scope publish job: only the trusted PyPA action runs here. No
# `actions/checkout`, no Python install, no third-party dependencies in
# the runner environment — so the OIDC token request that `id-token:
# write` enables has no co-tenant code that could exfiltrate it.
permissions:
id-token: write
contents: read
steps:
- name: Download distribution artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # @ v8.0.1
with:
name: notebooklm-py-dist
path: dist/
- name: Upload to TestPyPI
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # @ v1.14.0
with:
repository-url: https://test.pypi.org/legacy/
# PEP 740 attestations: pypa/gh-action-pypi-publish generates an
# in-toto attestation for the uploaded artifacts and publishes it
# via PyPI's Trusted Publishing flow (OIDC, no API token).
attestations: true
- name: Summary
run: |
echo "## Published to TestPyPI" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Version:** ${{ needs.build-and-test.outputs.version }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Package:** https://test.pypi.org/project/notebooklm-py/${{ needs.build-and-test.outputs.version }}/" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Next Step:** Run the **Verify Package** workflow with source=testpypi" >> $GITHUB_STEP_SUMMARY
name: Verify Generated Artifacts
on:
schedule:
# Run at 8 AM UTC daily (2 hours after nightly e2e tests)
- cron: '0 8 * * *'
workflow_dispatch: # Allow manual trigger
permissions:
contents: read
jobs:
verify:
name: Verify Artifacts
runs-on: ubuntu-latest
if: github.repository == 'teng-lin/notebooklm-py'
# Secret-bearing job. ``NOTEBOOKLM_AUTH_JSON`` and friends live only in
# the ``protected-readonly`` GitHub Environment (issue #1009), so the
# env binding is unconditional — every trigger sees the same secret
# values. Add a ``required reviewers`` rule on the environment if you
# want to block workflow_dispatch behind manual approval; scheduled
# runs would queue too, so the env carries no protection rules today.
# See docs/development.md → "Workflow secret gates".
environment: protected-readonly
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
cache: 'pip'
- name: Install uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # @ v7.0.0
- name: Install dependencies
# `uv sync --frozen` reproduces the lockfile-pinned dep tree. The
# inline verification script below only touches the public client API
# (no playwright / pytest / markdownify), so no extras are needed.
run: uv sync --frozen
# Fail-fast preflight. Without this, ``NotebookLMClient.from_storage()``
# below would die with a confusing "no storage" error when the secret
# resolves empty (issue #1009).
- name: Verify auth secret is present
env:
NOTEBOOKLM_AUTH_JSON: ${{ secrets.NOTEBOOKLM_AUTH_JSON }}
NOTEBOOKLM_GENERATION_NOTEBOOK_ID: ${{ secrets.NOTEBOOKLM_GENERATION_NOTEBOOK_ID }}
shell: bash
run: |
if [ -z "${NOTEBOOKLM_AUTH_JSON:-}" ]; then
echo "::error::NOTEBOOKLM_AUTH_JSON resolved to empty. Env binding or secret config is broken — see docs/development.md → Workflow secret gates."
exit 1
fi
if [ -z "${NOTEBOOKLM_GENERATION_NOTEBOOK_ID:-}" ]; then
echo "::error::NOTEBOOKLM_GENERATION_NOTEBOOK_ID resolved to empty."
exit 1
fi
- name: Verify artifacts exist
env:
NOTEBOOKLM_AUTH_JSON: ${{ secrets.NOTEBOOKLM_AUTH_JSON }}
NOTEBOOKLM_GENERATION_NOTEBOOK_ID: ${{ secrets.NOTEBOOKLM_GENERATION_NOTEBOOK_ID }}
run: |
uv run python -c "
import asyncio
import os
import sys
from notebooklm import NotebookLMClient
# Type ID to display name mapping
TYPE_NAMES = {
1: 'Audio',
2: 'Report', # Study Guide, Briefing Doc, Blog Post
3: 'Video',
4: 'Quiz/Flashcards',
5: 'Mind Map',
7: 'Infographic',
8: 'Slide Deck',
9: 'Data Table',
}
# Expected type IDs from generation tests
# Note: Type 2 is Reports (study guide), Type 4 is Quiz+Flashcards
EXPECTED_TYPES = {1, 2, 3, 4, 5, 7, 8, 9}
async def verify():
async with await NotebookLMClient.from_storage() as client:
nb_id = os.environ['NOTEBOOKLM_GENERATION_NOTEBOOK_ID']
print(f'Checking notebook: {nb_id}')
# List artifacts
artifacts = await client.artifacts.list(nb_id)
print(f'\nTotal artifacts: {len(artifacts)}')
# Group by type and status
by_type = {}
for a in artifacts:
key = a._artifact_type
if key not in by_type:
by_type[key] = []
by_type[key].append(a)
print('\nArtifacts by type:')
for t in sorted(by_type.keys()):
items = by_type[t]
type_name = TYPE_NAMES.get(t, f'Unknown({t})')
print(f' {type_name} (type {t}): {len(items)}')
for a in items:
status = a.status_str
variant_info = f', variant={a._variant}' if a._variant else ''
print(f' - {a.title} ({status}{variant_info})')
# Check expected types
found = set(by_type.keys())
missing = EXPECTED_TYPES - found
print(f'\nExpected types: {len(EXPECTED_TYPES)}')
print(f'Found types: {len(found)}')
if missing:
missing_names = [TYPE_NAMES.get(t, str(t)) for t in missing]
print(f'\nWARNING: Missing artifact types: {missing_names}')
# Check for completed artifacts
completed = sum(1 for a in artifacts if a.is_completed)
processing = sum(1 for a in artifacts if a.is_processing)
failed = sum(1 for a in artifacts if a.is_failed)
print(f'\nStatus summary:')
print(f' Completed: {completed}')
print(f' Processing: {processing}')
print(f' Failed: {failed}')
# List notes
notes = await client.notes.list(nb_id)
print(f'\nTotal notes: {len(notes)}')
for n in notes[:10]:
print(f' - {n.title or \"(untitled)\"}')
if len(notes) > 10:
print(f' ... and {len(notes) - 10} more')
# Fail if too many failures or no artifacts at all
if len(artifacts) == 0:
print('\nERROR: No artifacts found!')
sys.exit(1)
if failed > len(artifacts) // 2:
print(f'\nERROR: Too many failed artifacts ({failed}/{len(artifacts)})')
sys.exit(1)
print('\nVerification complete!')
asyncio.run(verify())
"
name: Verify Package
on:
workflow_dispatch:
inputs:
source:
description: 'Package source'
required: true
default: 'testpypi'
type: choice
options:
- testpypi
- pypi
permissions:
contents: read
jobs:
verify:
name: Verify from ${{ inputs.source }}
runs-on: ubuntu-latest
# Secret-bearing job (steps below reference secrets.NOTEBOOKLM_AUTH_JSON
# + secrets.NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID for the live E2E pass). The
# workflow is workflow_dispatch-only, so every run is human-initiated;
# this `protected-readonly` environment requires a maintainer to approve
# the dispatch before any secret is exposed. Non-maintainer dispatches
# block at the approval prompt rather than acquiring tokens.
# See docs/development.md → "Workflow secret gates".
environment: protected-readonly
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up Python 3.12
uses: actions/setup-python@v6
with:
python-version: "3.12"
cache: 'pip'
- name: Install uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # @ v7.0.0
- name: Get version from pyproject.toml
id: version
run: |
VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])")
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Sync locked deps + non-cookies extras
# Pull every runtime + tooling dep from `uv.lock` (the same lockfile
# contributors install from) so the verify step exercises the full
# non-cookies dep tree rather than whatever pip happens to resolve at
# smoke time. `cookies` stays excluded because rookiepy has Python
# 3.13+ install issues.
# The published wheel itself is force-reinstalled in the next step.
run: >
uv sync --frozen
--extra browser
--extra dev
--extra markdown
--extra mcp
--extra server
- name: Install published wheel from TestPyPI (--no-deps)
if: inputs.source == 'testpypi'
shell: bash
run: |
# --no-deps proves the wheel was actually uploaded to TestPyPI — the
# previous `--extra-index-url https://pypi.org/simple/` fallback would
# silently succeed by resolving an older published version from PyPI
# when the TestPyPI upload itself was broken or missing.
# --reinstall replaces the editable install left behind by `uv sync`
# with the actual built wheel under test. --no-cache + --only-binary
# ensure we test the freshly-uploaded wheel, never a cached sdist.
#
# `uv pip install --python .venv/bin/python` is load-bearing: a
# `source .venv/bin/activate && pip install …` chain falls back to
# the runner's system pip when uv has not seeded pip into `.venv`,
# which then writes outside the venv and leaves the editable install
# in place — masking a broken/missing TestPyPI upload.
uv pip install --python .venv/bin/python \
--no-deps --reinstall --no-cache --only-binary=:all: \
--index-url https://test.pypi.org/simple/ \
"notebooklm-py==${{ steps.version.outputs.version }}"
- name: Install published wheel from PyPI (--no-deps)
if: inputs.source == 'pypi'
shell: bash
run: |
# Symmetric with the TestPyPI step: --no-deps + --reinstall swaps the
# locked editable install for the actual PyPI wheel without
# disturbing the dep tree resolved from `uv.lock`. See the TestPyPI
# step above for why `uv pip install --python .venv/bin/python` is
# used instead of activating the venv and calling bare `pip`.
uv pip install --python .venv/bin/python \
--no-deps --reinstall --no-cache --only-binary=:all: \
"notebooklm-py==${{ steps.version.outputs.version }}"
- name: Verify version
shell: bash
run: |
source .venv/bin/activate
INSTALLED=$(python -c "from notebooklm import __version__; print(__version__)")
EXPECTED="${{ steps.version.outputs.version }}"
if [ "$INSTALLED" != "$EXPECTED" ]; then
echo "Version mismatch: installed=$INSTALLED expected=$EXPECTED"
exit 1
fi
echo "Version verified: $INSTALLED"
- name: Verify CLI
shell: bash
run: |
source .venv/bin/activate
notebooklm --version
notebooklm --help
- name: Verify imports
shell: bash
run: |
source .venv/bin/activate
python -c "from notebooklm import NotebookLMClient, Notebook, Source, Artifact"
echo "Core imports verified"
- name: Install Playwright browsers
shell: bash
run: |
source .venv/bin/activate
# Test deps come from the non-cookies extras installed via
# `uv sync --frozen` above. Just need to provision the Chromium browser
# binary + Linux system libs.
playwright install chromium
playwright install-deps chromium
- name: Run unit tests
shell: bash
run: |
source .venv/bin/activate
pytest tests/unit -v
- name: Run integration tests
shell: bash
run: |
source .venv/bin/activate
pytest tests/integration -v
- name: Skip E2E tests (fork)
if: github.repository != 'teng-lin/notebooklm-py'
run: |
echo "::warning::E2E tests skipped - secrets not available in fork repositories"
echo "## E2E Tests Skipped" >> $GITHUB_STEP_SUMMARY
echo "E2E tests were not run because this workflow is executing on a fork." >> $GITHUB_STEP_SUMMARY
- name: Run E2E tests
id: e2e
if: github.repository == 'teng-lin/notebooklm-py'
# Don't fail the job here — the retry step below gets a 10-min cool-down
# shot at any failures, and its exit code is what marks the job red.
continue-on-error: true
shell: bash
env:
NOTEBOOKLM_AUTH_JSON: ${{ secrets.NOTEBOOKLM_AUTH_JSON }}
NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID: ${{ secrets.NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID }}
run: |
source .venv/bin/activate
if [ -z "$NOTEBOOKLM_AUTH_JSON" ]; then
echo "::error::NOTEBOOKLM_AUTH_JSON secret is not configured"
exit 1
fi
if [ -z "$NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID" ]; then
echo "::error::NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID secret is not configured"
exit 1
fi
# --reruns 1 --reruns-delay 30 catches sub-minute network blips;
# longer chat-throttle recovery is handled by the cool-down retry below.
pytest tests/e2e -m "not variants" --reruns 1 --reruns-delay 30 -v
- name: Retry failed E2E tests after 10-min cool-down
# Tests still throttled after the cool-down become skips via the
# _install_chat_rate_limit_skip fixture in tests/e2e/conftest.py.
if: steps.e2e.outcome == 'failure'
shell: bash
env:
NOTEBOOKLM_AUTH_JSON: ${{ secrets.NOTEBOOKLM_AUTH_JSON }}
NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID: ${{ secrets.NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID }}
run: |
source .venv/bin/activate
# Missing lastfailed after a failed e2e step means pytest crashed
# before writing the cache (import error, OOM, etc.) — fail the job
# rather than silently going green.
if [ ! -f .pytest_cache/v/cache/lastfailed ]; then
echo "::error::No lastfailed cache from previous step; pytest likely crashed before running tests."
exit 1
fi
echo "Initial E2E run failed. Sleeping 600s before retrying failed tests."
sleep 600
uv run pytest tests/e2e --last-failed --last-failed-no-failures=none -s -v --tb=short
__pycache__/
*.py[cod]
*.class
.venv/
env/
venv/
.pytest_cache/
.coverage
coverage.json
htmlcov/
dist/
build/
*.egg-info/
.DS_Store
.env
.notebooklm/
captured_rpcs/
.worktrees/
.worktree/
worktrees/
.sisyphus/
.scratch/
.claude/
# Auto-generated by claude-mem plugin
**/CLAUDE.md
!/CLAUDE.md
# Investigation artifacts
investigate*.py
# Playwright browser binaries when PLAYWRIGHT_BROWSERS_PATH is set to a
# workspace-relative path (used by CI; harmless locally if it ever lands).
.playwright-browsers/
INVESTIGATION*.md
investigation_output/
downloads/
# VCR cassettes - committed after security review
# Cassettes are scrubbed of sensitive data (cookies, tokens, user IDs, emails)
# See tests/vcr_config.py for scrubbing patterns
# Local working notes (process scratch — not shipped)
docs/scratch/
# Ruff is pinned once in pyproject.toml ([project.optional-dependencies].dev:
# `ruff==…`).
# These local hooks invoke that same interpreter's ruff via `uv run`, so the
# pre-commit version can never drift from the version used by CI and locally.
repos:
- repo: local
hooks:
- id: ruff
name: ruff
entry: uv run ruff check --force-exclude --fix
language: system
types_or: [python, pyi]
require_serial: true
- id: ruff-format
name: ruff-format
entry: uv run ruff format --force-exclude
language: system
types_or: [python, pyi]
require_serial: true
Repository Guidelines
Status: Active Last Updated: 2026-06-11
Project Structure & Module Organization
src/notebooklm/ contains the async client and typed APIs. Internal feature modules use _ prefixes such as _sources.py, _artifacts.py, _app/, and _runtime/; src/notebooklm/cli/ holds Click adapters, src/notebooklm/mcp/ and src/notebooklm/server/ hold the opt-in MCP and REST adapters, and src/notebooklm/rpc/ handles protocol encoding and decoding. Tests are split by scope: tests/unit/, tests/integration/, tests/server/, and tests/e2e/. Recorded HTTP fixtures live in tests/cassettes/. Examples are in examples/, and diagnostics live in scripts/.
Build, Test, and Development Commands
Canonical contributor install (full guide: docs/installation.md):
uv sync --frozen --extra browser --extra dev --extra markdown
source .venv/bin/activate
uv run playwright install chromium
uv run pytest
uv run pytest -n auto --dist=worksteal # optional faster local run
uv run ruff check .
uv run ruff format .
uv run mypy src/notebooklm
uv run pre-commit run --all-filesRun uv run pytest tests/e2e -m readonly only after notebooklm login and setting test notebook env vars.
Coding Style & Naming Conventions
Target Python 3.10+, 4-space indentation, and double quotes. Ruff enforces formatting and import order with a 100-character line length. Keep module and test file names in snake_case; prefer descriptive Click command names that match existing groups such as source, label, artifact, and research. Preserve the internal/public split: _*.py and _*/ for implementation, exported types in src/notebooklm/__init__.py.
Testing Guidelines
Put pure logic in tests/unit/, REST adapter coverage in tests/server/, VCR-backed flows in tests/integration/, and authenticated NotebookLM coverage in tests/e2e/. Name tests test_<behavior>.py and record cassettes with NOTEBOOKLM_VCR_RECORD=1 uv run pytest tests/integration/ -v (the integration suite uses vcrpy throughout — there is no test_vcr_*.py glob). Coverage is expected to stay at or above the configured 90% threshold.
Commit, PR, and Agent Notes
Follow the existing commit style: feat(cli): ..., fix(cli): ..., refactor(test): ..., style: .... PRs should include a short summary, linked issue when relevant, and the commands run locally.
For Codex or other parallel agents:
- Prefer
--jsonoutput and pass explicit notebook IDs instead of relying onnotebooklm use. - Isolate concurrent runs with
NOTEBOOKLM_PROFILE=agent-<id>so each agent gets its own context file under~/.notebooklm/profiles/<name>/. Fall back toNOTEBOOKLM_HOME=/tmp/agent-<id>only when separate home directories are required. - In headless environments where Playwright login is impractical, authenticate with
notebooklm login --browser-cookies <browser>(requirespip install "notebooklm-py[cookies]").
{
"manifest_version": "0.3",
"name": "notebooklm-mcp",
"display_name": "NotebookLM (notebooklm-py)",
"version": "0.8.0",
"description": "Connect Claude to Google NotebookLM: manage notebooks and sources, chat over your notebooks, generate podcasts/videos/quizzes/mind maps, and run research.",
"long_description": "This extension connects an MCP host (e.g. Claude Desktop) to Google NotebookLM via the Model Context Protocol, backed by the `notebooklm-py` Python client.\n\n**Prerequisites:**\n1. Install `uv` (the launcher runs `uvx`): `curl -LsSf https://astral.sh/uv/install.sh | sh`\n2. Authenticate once: `uvx --from \"notebooklm-py[mcp]\" notebooklm login` (or `notebooklm login` if installed), then select your Google profile.\n\nThe bundled `run_server.py` launcher locates `uvx` across common install paths and runs `uvx --from \"notebooklm-py[mcp]\" notebooklm-mcp`, so no global install is required.\n\n**Features:**\n- Create, list, describe, rename, and delete notebooks\n- Add sources (URL, YouTube, Google Drive, text, files) and read their content\n- Chat / ask questions over a notebook\n- Generate Audio Overviews (podcasts), videos, quizzes, flashcards, reports, and mind maps\n- Research topics via web or Drive and import the results",
"author": {
"name": "Teng Lin",
"email": "teng.lin@gmail.com",
"url": "https://github.com/teng-lin"
},
"repository": {
"type": "git",
"url": "https://github.com/teng-lin/notebooklm-py"
},
"homepage": "https://github.com/teng-lin/notebooklm-py",
"documentation": "https://github.com/teng-lin/notebooklm-py#readme",
"support": "https://github.com/teng-lin/notebooklm-py/issues",
"server": {
"type": "python",
"entry_point": "run_server.py",
"mcp_config": {
"command": "python3",
"args": [
"${__dirname}/run_server.py"
],
"env": {},
"platform_overrides": {
"win32": {
"command": "python"
}
}
}
},
"tools": [
{
"name": "notebook_list",
"description": "List all notebooks"
},
{
"name": "notebook_create",
"description": "Create a new notebook"
},
{
"name": "source_add",
"description": "Add a source (URL, YouTube, Drive, text, or file)"
},
{
"name": "chat_ask",
"description": "Ask a question over a notebook's sources"
},
{
"name": "artifact_generate",
"description": "Generate audio, video, quizzes, flashcards, reports, or mind maps"
},
{
"name": "research_start",
"description": "Research a topic via web or Drive search"
}
],
"tools_generated": true,
"keywords": [
"notebooklm",
"google",
"podcast",
"research",
"notebook",
"ai",
"mcp"
],
"license": "MIT",
"compatibility": {
"claude_desktop": ">=0.10.0",
"platforms": [
"darwin",
"win32",
"linux"
],
"runtimes": {
"python": ">=3.10"
}
}
}
Related skills
How it compares
Use notebooklm for programmatic NotebookLM pipelines; use generic web research skills when browser-only exploration is sufficient.
FAQ
How do I authenticate for the first time?
Run 'notebooklm login' - it opens a browser for Google OAuth. Resulting cookies are saved to ~/.notebooklm/profiles/default/storage_state.json and reused on every subsequent run. For headless contexts use --browser-cookies to extract from Chrome/Firefox. Verify with 'notebooklm a
How do I handle parallel agent workflows without context collisions?
Use explicit notebook IDs on all commands (-n/--notebook flag) instead of relying on 'use' context. Alternatively, isolate agents via NOTEBOOKLM_PROFILE (each profile gets its own context.json) or NOTEBOOKLM_HOME (separate config directories). For automation, prefer full UUIDs ov
Why do artifact generation commands return immediately instead of waiting?
Generation takes 5-45 minutes (audio 10-20 min, video 15-45 min, deep research 15-30 min). Commands return task IDs immediately (fire-and-forget pattern). For automated completion, spawn a subagent with 'artifact wait <task_id>' or 'research wait'. For manual checks, poll 'artifa
Is Notebooklm safe to install?
skills.sh reports 1 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.