RPC & UI Reference
Status: Active
Last Updated: 2026-06-17
Source of Truth: src/notebooklm/rpc/types.py for method IDs; payload builders in src/notebooklm/ and golden tests under tests/unit/
Purpose: Complete reference for RPC methods, UI selectors, and payload structures
Note: Payload structures are extracted from the implementation builders in
src/notebooklm/and pinned by golden unit tests. Each payload includes a reference to its owning source file. The CREATE_ARTIFACT payloads below were re-verified against the live builders in_artifact/payloads.pyon 2026-06-11 (AUDIO, VIDEO_EXPLAINER, VIDEO_BRIEF, VIDEO_CINEMATIC, STUDY_GUIDE, BRIEFING_DOC, BLOG_POST, MIND_MAP, QUIZ, FLASHCARDS, INFOGRAPHIC, SLIDE_DECK, DATA_TABLE). Read-only notebook/source/artifact/chat/ sharing/settings RPCs were live-captured again on 2026-06-15. Home, Sources, chat, and Studio selectors were rechecked the same day against a live Chrome session on a probe notebook.
Quick Reference
RPC Method Status
| RPC ID | Method | Purpose | Implementation |
|---|---|---|---|
wXbhsf |
LIST_NOTEBOOKS | List all notebooks | _notebooks.py |
CCqFvf |
CREATE_NOTEBOOK | Create new notebook | _notebooks.py |
rLM1Ne |
GET_NOTEBOOK | Get notebook details + sources | _notebooks.py |
s0tc2d |
RENAME_NOTEBOOK | Rename, chat config, share access | _notebooks.py, _chat/api.py |
WWINqb |
DELETE_NOTEBOOK | Delete a notebook | _notebooks.py |
izAoDd |
ADD_SOURCE | Add URL/text/YouTube/Drive source | _source/add.py via _sources.py |
o4cbdc |
ADD_SOURCE_FILE | Register uploaded file (PDF, DOCX, EPUB, etc.) | _source/upload.py, _source/upload_payloads.py |
tGMBJ |
DELETE_SOURCE | Delete a source | _sources.py |
b7Wfje |
UPDATE_SOURCE | Rename source | _sources.py |
tr032e |
GET_SOURCE_GUIDE | Get source summary | _sources.py |
hizoJc |
GET_SOURCE | Get clean fulltext content of a source | _source/content.py |
agX4Bc |
CREATE_LABEL | AI-generate label groupings and create manual labels | _labels.py |
I3xc3c |
LIST_LABELS | List source labels for a notebook | _labels.py |
le8sX |
UPDATE_LABEL | Rename label, set emoji, add/remove sources | _labels.py, _label/params.py |
GyzE7e |
DELETE_LABEL | Delete one or more labels (batch) | _labels.py |
R7cb6c |
CREATE_ARTIFACT | Unified artifact generation | _artifacts.py |
gArtLc |
LIST_ARTIFACTS | List artifacts in a notebook | _artifacts.py |
V5N4be |
DELETE_ARTIFACT | Delete artifact | _artifacts.py |
KmcKPe |
REVISE_SLIDE | Revise an individual slide via prompt | _artifacts.py |
Rytqqe |
RETRY_ARTIFACT | Retry a failed Studio artifact in place | _artifacts.py |
hPTbtc |
GET_LAST_CONVERSATION_ID | Get most recent conversation ID | _chat/api.py |
khqZz |
GET_CONVERSATION_TURNS | Get Q&A turns for a conversation | _chat/api.py |
J7Gthc |
DELETE_CONVERSATION | Delete a conversation (web UI's "Delete history") | _chat/api.py |
otmP3b |
SUGGEST_PROMPTS | Get AI-suggested prompts for a notebook | _notebooks.py |
CYK0Xb |
CREATE_NOTE | Create a note (placeholder) | _notes.py |
cYAfTb |
UPDATE_NOTE | Update note content/title | _notes.py |
AH0mwd |
DELETE_NOTE | Delete a note | _notes.py |
cFji9 |
GET_NOTES_AND_MIND_MAPS | List notes and mind maps | _notes.py |
yyryJe |
GENERATE_MIND_MAP | Mind map generation | _artifacts.py |
VfAZjd |
SUMMARIZE | Get notebook summary | _notebooks.py |
FLmJqe |
REFRESH_SOURCE | Refresh URL/Drive source | _sources.py |
yR9Yof |
CHECK_SOURCE_FRESHNESS | Check if source needs refresh | _sources.py |
Ljjv0c |
START_FAST_RESEARCH | Start fast research | _research.py |
QA9ei |
START_DEEP_RESEARCH | Start deep research | _research.py |
e3bVqc |
POLL_RESEARCH | Poll research status | _research.py |
LBwxtb |
IMPORT_RESEARCH | Import research results | _research.py |
Zbrupe |
CANCEL_RESEARCH | Cancel in-flight research run | _research.py |
rc3d8d |
RENAME_ARTIFACT | Rename artifact | _artifacts.py |
Krh3pd |
EXPORT_ARTIFACT | Export to Docs/Sheets | _artifacts.py |
RGP97b |
SHARE_ARTIFACT | Legacy notebook/artifact share-link toggle | _sharing_manager.py |
QDyure |
SHARE_NOTEBOOK | Set notebook visibility (restricted/public) | _sharing.py |
JFMDGd |
GET_SHARE_STATUS | Get notebook share settings | _sharing.py |
ciyUvf |
GET_SUGGESTED_REPORTS | Get AI-suggested report formats | _artifacts.py |
v9rmvd |
GET_INTERACTIVE_HTML | Fetch quiz/flashcard HTML ([0][9][0]) / interactive mind-map tree ([0][9][3]) |
_artifact/downloads.py |
fejl7e |
REMOVE_RECENTLY_VIEWED | Remove notebook from recent list | _notebooks.py |
ZwVcOc |
GET_USER_SETTINGS | Get user settings including output language | _settings.py |
hT54vc |
SET_USER_SETTINGS | Set user settings (e.g., output language) | _settings.py |
Content Type Codes (ArtifactTypeCode)
| Code | Type | Used By |
|---|---|---|
| 1 | Audio | Audio Overview |
| 2 | Report | Briefing Doc, Study Guide, Blog Post |
| 3 | Video | Video Overview |
| 4 | Quiz/Flashcards (QUIZ_FLASHCARD alias) | Quiz (variant=2), Flashcards (variant=1) |
| 5 | Mind Map | Library synthetic type for note-backed mind maps |
| 7 | Infographic | Infographic |
| 8 | Slide Deck | Slide Deck |
| 9 | Data Table | Data Table |
Source Type Codes (file uploads & sources)
Internal integer codes returned by GET_NOTEBOOK / LIST_SOURCES and consumed by Source.from_api_response() (mapped to SourceType in src/notebooklm/types.py).
| Code | SourceType |
Used By |
|---|---|---|
| 1 | GOOGLE_DOCS |
Google Docs source |
| 2 | GOOGLE_SLIDES |
Google Slides source |
| 3 | PDF |
PDF upload |
| 4 | PASTED_TEXT |
Inline pasted text |
| 5 | WEB_PAGE |
Web URL source |
| 8 | MARKDOWN |
Markdown file |
| 9 | YOUTUBE |
YouTube URL |
| 10 | MEDIA |
Audio / video upload |
| 11 | DOCX |
Word document |
| 13 | IMAGE |
Image upload |
| 14 | GOOGLE_SPREADSHEET |
Google Sheets source |
| 16 | CSV |
CSV upload |
| 17 | EPUB |
EPUB upload (added in v0.4.0) |
Codes outside this map are surfaced as
SourceType.UNKNOWNand emitUnknownTypeWarningon first occurrence so unmapped types don't crash callers.
UI/Library Operation Parity
Use this table as the coverage index before adding or changing selectors. "UI covered" means the selector or flow is documented below from the 2026-06-15 live Chrome probe. "Library-only" means the Python API deliberately exposes a backend or local convenience that has no stable web-control equivalent in the capture.
| Library surface | UI parity status | Notes |
|---|---|---|
NotebooksAPI.list/create/get/rename/delete/remove_from_recent |
Partial UI coverage | Home create/card/action-menu selectors are covered. Rename/delete/remove-recent are represented by project action menus and RPC payloads; destructive menu items were not re-mutated in the live probe. |
NotebooksAPI.get_summary/get_description/get_metadata/get_raw/get_share_url |
Library-only/read-derived | Summary content is visible in the chat panel, but these are read/format helpers rather than direct UI controls. |
SourcesAPI.list/get/add_url/add_text/add_file/add_drive/delete/rename |
UI covered | Source cards, add-source modal tabs, upload/Drive entry points, source menus, and submit selectors are documented. |
SourcesAPI.get_guide/get_fulltext |
UI covered/read-derived | Opening a source exposes the source viewer, source guide toggle, title input, and source content; get_fulltext() is the programmatic extraction path. |
SourcesAPI.wait_*, refresh, check_freshness |
Library-only/partial UI | Wait methods are polling helpers. Refresh/freshness RPCs are documented, but no stable refresh selector was captured in the current source-list/label-list state. |
LabelsAPI.list/sources/generate/create/update/rename/set_emoji/add_sources/remove_sources/delete |
UI covered | Auto-label, Reorganize all sources, manual label creation, inline rename, emoji picker, delete, label panels, and source Move to label checkboxes are documented. |
ChatAPI.ask/get_history/delete_conversation/configure/save_answer_as_note |
UI covered | Chat input/send, options/delete history, configure dialog, and Save message to a note buttons are documented. get_conversation_id, cache methods, and history parsing are backend/local conveniences. |
ArtifactsAPI.generate_*, suggest_reports, list/get/get_prompt/delete/rename/share/export |
UI covered/partial | All live Studio generation tiles and option sets are documented. Artifact list/open/menu/view-prompt/share/delete selectors are covered; export/download/retry availability depends on artifact type/status. |
ArtifactsAPI.download_*, wait_for_completion, poll_status, revise_slide, retry_failed |
Library-only/conditional UI | Downloads, polling, and slide revision are programmatic conveniences. Retry requires a failed artifact row; the RPC is documented but no failed-row retry selector was present in the probe. |
NotesAPI.list/get/create/update/delete |
UI covered/partial | Add note, note row, note view close/title input, and note menu delete are documented. Rich body editing uses NotebookLM's internal editor; keep selectors conservative. |
MindMapsAPI.list/generate/rename/delete/get_tree |
UI covered/partial | Interactive mind map generation is the live Studio tile. Note-backed mind maps are a synthetic/library backing; tree extraction via GET_INTERACTIVE_HTML is programmatic. |
ResearchAPI.start/poll/wait/import_sources |
UI covered for start only | Source discovery corpus/mode/submit selectors map to fast/deep web/Drive research. Polling and import verification are backend workflow helpers. |
SettingsAPI.get/set_output_language, SharingAPI.get_status/set_public/set_view_level/add_user/update_user/remove_user |
UI covered/partial | Settings and Share dialogs are covered at entry/save/copy selectors. Programmatic user-permission mutations go beyond the captured UI selectors. |
| UI-only note operations | UI-only | Note menus expose Convert to source, Convert all notes to source, Export to Docs, and Export to Sheets; keep them documented as selectors unless/until a public library method owns those flows. |
Using Selector Lists
Selectors are provided as Python lists of fallback options. Try each in order:
async def try_selectors(page, selectors: list[str], action="click", timeout=5000):
"""Try multiple selectors until one works."""
for selector in selectors:
try:
element = page.locator(selector)
if action == "click":
await element.click(timeout=timeout)
elif action == "fill":
return element
return True
except Exception:
continue
raise Exception(f"None of the selectors worked: {selectors}")
# Example usage
await try_selectors(page, HOME_SELECTORS["create_notebook"])
Home / Notebook List
UI Selectors
HOME_SELECTORS = {
"create_notebook": [
"button[aria-label='Create new notebook']",
"button:has-text('Create new')",
"mat-card[role='button']:has-text('Create new notebook')",
],
"notebook_card": [
"mat-card:has(button[aria-label='Project Actions Menu'])",
"mat-card:has(button:has-text('more_vert'))",
"mat-card[role='button']:has(h3)",
"a[href*='/notebook/']",
],
"notebook_menu": [
"button[aria-label='Project Actions Menu']",
"button[aria-label*='More options']",
"button:has-text('more_vert')",
],
}
Live Home Components (2026-06-15)
HOME_MENU_SELECTORS = {
"settings": "button[aria-label='Settings']",
"search": "button[aria-label='Open search']",
"filter_tabs": [
"button:has-text('All')",
"button:has-text('My notebooks')",
"button:has-text('Featured notebooks')",
],
"view_toggle": [
"button[aria-label='Grid view']",
"button[aria-label='List view']",
],
"sort_menu": ".project-filter-button", # e.g. "Most recent"
"project_actions": "button[aria-label='Project Actions Menu']",
}
The home project action menu is now labeled Project Actions Menu; older
More options selectors did not match the live UI on 2026-06-15.
RPC: LIST_NOTEBOOKS (wXbhsf)
Source: _notebooks.py::list()
# Minimal client builder (`NotebooksAPI.list()`), accepted by the backend:
params = [
None, # 0
1, # 1: Fixed value
None, # 2
[2], # 3: Fixed flag
]
# Live web UI/CDP capture on 2026-06-15:
params = [
None,
1,
None,
[2, None, None, [1, None, None, None, None, None, None, None, None, None, [1]]],
None,
[[None, None, []], [[]], [None, []]],
]
RPC: CREATE_NOTEBOOK (CCqFvf)
Source: _notebooks.py::create()
params = [
title, # 0: Notebook title
None, # 1
None, # 2
[2, None, None, [1, None, None, None, None, None, None, None, None, None, [1]]],
# 3: Shared request-options wrapper (`build_template_block()`)
]
RPC: DELETE_NOTEBOOK (WWINqb)
Source: _notebooks.py::delete()
params = [
[notebook_id], # 0: Single-nested notebook ID
[2], # 1: Fixed flag
]
RPC: GET_NOTEBOOK (rLM1Ne)
Source: _notebooks.py::get(), _source/listing.py::SourceLister.list()
params = [
notebook_id, # 0
None, # 1
[2, None, None, [1, None, None, None, None, None, None, None, None, None, [1]]],
# 2: Shared request-options wrapper
None, # 3
0, # 4: Fixed value
]
The slot [2] wrapper replaced the older bare [2] read-path tail. Live
capture on 2026-06-15 confirmed the nested shape in f.req; keep
_notebooks.build_get_notebook_params() and _source/listing.py in sync.
The live web UI also sends a sixth filter/tail slot:
[[None, None, []]]. Initial page load used slot [4] == 0; a follow-up
refresh used slot [4] == 1. The client builder currently omits that sixth
slot because the backend accepts the compact form.
RPC: REMOVE_RECENTLY_VIEWED (fejl7e)
Source: _notebooks.py::remove_from_recent()
Remove a notebook from the recently viewed list (doesn't delete the notebook).
params = [notebook_id] # Just the notebook ID
# No source_path needed
await rpc_call(
RPCMethod.REMOVE_RECENTLY_VIEWED,
params,
allow_null=True,
)
# Response: None (no return value)
Sources Panel
UI Selectors
SOURCES_SELECTORS = {
"add_sources": [
"button[aria-label='Add source']",
"button:has-text('+ Add sources')",
"button:has-text('Add sources')",
],
"source_card": ".single-source-container",
"source_menu": [
"button.source-item-more-button[aria-label='More']",
"button[aria-label*='More options']",
],
"remove_source": "button:has-text('Remove source')",
"rename_source": "button:has-text('Rename source')",
}
ADD_SOURCE_MODAL = {
"modal": "mat-dialog-container[role='dialog']",
"upload_files": "button:has-text('Upload files')",
"website_tab": [
"button:has-text('Websites')",
"button:has-text('Website')",
],
"drive_tab": "button:has-text('Drive')",
"url_input": [
"textarea[aria-label='Enter URLs']",
"textarea[placeholder='Paste any links']",
"textarea[placeholder*='links']",
],
"copied_text_tab": "button:has-text('Copied text')",
"copied_text_input": [
"textarea[aria-label='Pasted text']",
"textarea[placeholder='Paste text here']",
],
"back_button": "button[aria-label='Back']",
"close_button": "button[aria-label='Close']",
"submit_button": "button:has-text('Insert')",
}
SOURCE_DISCOVERY_SELECTORS = {
"query": [
"textarea[placeholder='Search the web for new sources']",
"textarea[aria-label='Discover sources based on the inputted query']",
],
"corpus_menu": ".corpus-menu-trigger",
"corpus_web": ".corpus-option-web",
"corpus_drive": ".corpus-option-drive",
"research_mode_menu": ".researcher-menu-trigger",
"fast_research": ".research-option-fast-research",
"deep_research": ".research-option-deep-research",
"submit": "button.actions-enter-button[aria-label='Submit']",
"auto_label": [
"button[aria-label='Auto-label your sources by topic']",
"button[aria-label='Undo or re-label sources']",
],
}
DRIVE_PICKER_SELECTORS = {
"picker_iframe": "iframe[src*='picker'], iframe[src*='docs.google']",
}
SOURCE_VIEWER_SELECTORS = {
"open_source": "button.source-stretched-button",
"viewer": "source-viewer",
"title_input": "source-viewer input.title-input",
"close_view": [
"source-viewer button[mattooltip='Close source view']",
"button[mattooltip='Close source view']",
],
"open_in_new": "source-viewer button[aria-label='Open in new tab']",
"source_guide_toggle": "source-viewer button[aria-label='Close source guide']",
"guide_keyword_chip": "source-viewer button[role='option']",
}
Live source-add submit check (2026-06-15): copied text, Website URL
(https://example.com), and file upload all closed the modal and increased the
source-card count. Drive opens a Google picker iframe; selecting a Drive file
was not needed to verify the entry point.
Live source-viewer check (2026-06-15): opening a source showed
source-viewer, input.title-input, Open in new tab, a Close source guide
toggle, source-guide keyword chips with role='option', and a tooltiped close
button (mattooltip='Close source view').
RPC: ADD_SOURCE (izAoDd) - URL
Source: _source/add.py::SourceAddService.add_url_source()
# URL goes at position [2] in an 11-element source spec.
params = [
[[None, None, [url], None, None, None, None, None, None, None, 1]],
notebook_id, # 1: Notebook ID
[2, None, None, [1, None, None, None, None, None, None, None, None, None, [1]]],
# 2: Shared request-options wrapper
]
RPC: ADD_SOURCE (izAoDd) - Text
Source: _source/add.py::SourceAddService.add_text()
# [title, content] at position [1] in an 11-element source spec; slot [3] is
# the captured source-type code for pasted text.
params = [
[[None, [title, content], None, 2, None, None, None, None, None, None, 1]],
notebook_id,
[2, None, None, [1, None, None, None, None, None, None, None, None, None, [1]]],
]
RPC: ADD_SOURCE (izAoDd) - YouTube
Source: _source/add.py::SourceAddService.add_youtube_source()
# YouTube URL at position [7] in the source spec (different from regular URL).
params = [
[[None, None, None, None, None, None, None, [url], None, None, 1]], # 0
notebook_id, # 1
[2, None, None, [1, None, None, None, None, None, None, None, None, None, [1]]],
# 2: Shared wrapper
]
RPC: ADD_SOURCE (izAoDd) - Google Drive
Source: _source/add.py::SourceAddService.add_drive()
# Drive source structure - single-wrapped (not double!)
source_data = [
[file_id, mime_type, 1, title], # 0: File info
None, None, None, None, None, # 1-5: Padding
None, None, None, None, # 6-9: Padding
1, # 10: Trailing flag
]
params = [
[source_data], # 0: Single-wrapped (NOT [[source_data]])
notebook_id, # 1: Notebook ID
[2], # 2: Source type flag
[1, None, None, None, None, None, None, None, None, None, [1]], # 3: Config
]
Note: Drive add is intentionally still on the older [2],
[1, ..., [1]] tail pending a fresh live Drive capture. URL, YouTube, text,
CREATE_NOTEBOOK, and ADD_SOURCE_FILE use the shared nested wrapper from
_source/upload_payloads.py::build_template_block().
RPC: ADD_SOURCE_FILE (o4cbdc) - File Upload Registration
Source: _source/upload.py::SourceUploadPipeline.register_file_source(),
_source/upload_payloads.py::build_register_file_source_params(),
_source/upload_payloads.py::build_resumable_upload_start_request()
File uploads are a two-step flow. First, ADD_SOURCE_FILE registers the file
source and returns a SOURCE_ID; then the client starts a Scotty resumable
upload session and streams the bytes to the x-goog-upload-url returned by
that start request.
params = [
[[filename]], # 0: Filename wrapped twice
notebook_id, # 1: Notebook ID
[2, None, None, [1, None, None, None, None, None, None, None, None, None, [1]]],
# 2: Shared request-options wrapper
]
# Called with source_path:
await rpc_call(
RPCMethod.ADD_SOURCE_FILE,
params,
source_path=f"/notebook/{notebook_id}",
allow_null=False,
disable_internal_retries=True,
)
Registration is mutating, so the upload pipeline uses the same probe-then-create idempotency pattern as URL and Drive sources. Because filenames are not unique, the probe records source IDs before the create and only trusts a same-title source if it is new since that baseline.
# Start resumable upload after SOURCE_ID registration:
start_headers = {
"Accept": "*/*",
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
"Origin": base_url,
"Referer": f"{base_url}/",
"x-goog-authuser": authuser,
"x-goog-upload-command": "start",
"x-goog-upload-header-content-length": str(file_size),
"x-goog-upload-header-content-type": content_type,
"x-goog-upload-protocol": "resumable",
}
start_body = {
"PROJECT_ID": notebook_id,
"SOURCE_NAME": filename,
"SOURCE_ID": source_id,
}
# Stream bytes to response.headers["x-goog-upload-url"]:
upload_headers = {
"Accept": "*/*",
"Content-Type": "application/x-www-form-urlencoded;charset=utf-8",
"x-goog-authuser": authuser,
"Origin": base_url,
"Referer": f"{base_url}/",
"x-goog-upload-command": "upload, finalize",
"x-goog-upload-offset": "0",
}
RPC: DELETE_SOURCE (tGMBJ)
Source: _sources.py::delete()
IMPORTANT: notebook_id is passed via source_path, NOT in params!
params = [[[source_id]]] # Triple-nested!
# Called with:
await rpc_call(
RPCMethod.DELETE_SOURCE,
params,
source_path=f"/notebook/{notebook_id}", # <-- notebook_id here
)
RPC: UPDATE_SOURCE / Rename (b7Wfje)
Source: _sources.py::rename()
# Different structure: None at [0], source_id at [1], title triple-nested at [2]
params = [
None, # 0
[source_id], # 1: Single-nested source ID
[[[new_title]]], # 2: Triple-nested title
]
RPC: GET_SOURCE_GUIDE (tr032e)
Source: _sources.py::get_guide()
# Quadruple-nested source ID!
params = [[[[source_id]]]]
RPC: GET_SOURCE (hizoJc)
Source: _source/content.py::get_fulltext()
Purpose: Get raw text or clean HTML/markdown content of a source.
Params:
# Position 0: Single-nested source ID
# Position 1: Output type: [2] for plain text, [3] for cleaned HTML/markdown structure
# Position 2: Format selector matching position 1
params = [
[source_id], # 0
[2], # 1
[2], # 2
]
# Markdown/HTML source rendering uses the same selector in both slots:
params = [
[source_id],
[3],
[3],
]
Request format:
await rpc_call(
RPCMethod.GET_SOURCE,
params,
source_path=f"/notebook/{notebook_id}",
)
Source Labels
Source labels group a notebook's sources into AI-generated (or manually named)
topic buckets. A label is a standalone entity — a source carries no
back-reference; the label owns a list of source IDs, and membership is
many-to-many (a source can belong to multiple labels). Every label RPC's first
argument is the recurring request-options wrapper used by _settings.py:
OPTS = [2, None, None, [1, None, None, None, None, None, None, None, None, None, [1]]]
The Label Tuple (response shape)
Each label is a 4-tuple [name, sources, label_id, emoji]:
| Slot | Field | Notes |
|---|---|---|
[0] |
name |
str |
[1] |
sources |
[[source_id], ...] (each UUID wrapped in its own 1-element list); None for a new empty label |
[2] |
label_id |
server-assigned UUID |
[3] |
emoji |
"" when unset, else the emoji string |
Response envelopes differ by RPC: CREATE_LABEL returns
[None, [label, ...]] (label set at index [1]); LIST_LABELS returns
[[label, ...]] (label set at index [0]); UPDATE_LABEL and DELETE_LABEL
echo [] on success.
UI Selectors
Live-checked on 2026-06-15 after NotebookLM generated source labels for the probe notebook.
LABEL_UI_SELECTORS = {
# Before labels exist, the same button is labelled "Auto-label your sources
# by topic"; after labels exist, it becomes "Undo or re-label sources".
"auto_label_menu": [
"button[aria-label='Auto-label your sources by topic']",
"button[aria-label='Undo or re-label sources']",
],
"add_new_label": "button[role='menuitem']:has-text('Add new label')",
"reorganize": "button[role='menuitem']:has-text('Reorganize')",
"reorganize_all_sources": "button[role='menuitem']:has-text('All sources')",
"return_to_list_view": "button[role='menuitem']:has-text('Return to list view')",
"label_panel": "source-picker mat-expansion-panel",
"label_header": "source-picker mat-expansion-panel-header",
"label_name": "source-picker .label-name",
"label_checkbox": "source-picker input[aria-label='<label name>']",
"label_more": "source-picker button.label-more-button",
"label_rename": "button[role='menuitem']:has-text('Rename')",
"label_remove": "button[role='menuitem']:has-text('Remove')",
"label_add_emoji": "button[role='menuitem']:has-text('Add emoji')",
"label_rename_input": "source-picker input.label-rename-input",
"emoji_search": "input[aria-label='Search for an emoji, press Escape to dismiss.']",
"source_move_to": "button.more-menu-move-to-labels-button",
"move_to_label_option": ".cdk-overlay-container [role='menuitem']:has-text('<label name>')",
"move_to_checkbox": ".cdk-overlay-container input.mdc-checkbox__native-control",
}
Live label-flow notes:
Add new labelimmediately creates a manual label namedNew Label; there is no pre-create dialog.Renameswitches that label row into an inlineinput.label-rename-input; submitting with Enter commits the rename.Add emojiopens the inline emoji picker and exposes the emoji search input.Removedeleted the throwaway label immediately in the live UI probe.- Expanding a label panel reveals its member source rows; each source row keeps
the normal source menu plus a label-specific
Move tosubmenu. Move toshows every current label with checkboxes. The current label is checked; unchecked labels can be selected to change/add membership.
RPC: CREATE_LABEL (agX4Bc)
Source: _labels.py::generate(), _labels.py::create() (builders in _label/params.py)
A single multi-mode RPC; the mode is selected by which slot is populated. Slot
[4] drives AI auto-labeling (generate); slot [5] creates manual labels
(create).
# Auto-label / Reorganize -> All sources (slot [4] = []) - WIPES + regenerates with new ids
params = [OPTS, notebook_id, None, None, []]
# Reorganize -> Unlabeled sources (slot [4] = [0]) - preserves existing labels
params = [OPTS, notebook_id, None, None, [0]]
# Manual create (slot [5] = [[name, emoji]])
params = [OPTS, notebook_id, None, None, None, [["New Label", ""]]]
Response (all modes): the full post-op label set — [None, [label, ...]].
RPC: LIST_LABELS (I3xc3c)
Source: _labels.py::list()
params = [OPTS, notebook_id]
Response: [[label, ...]] — a single-element outer list wrapping the labels
(not [None, [label, ...]] like agX4Bc). Each label's slot [1] carries
its source UUIDs, so one list() call gives the complete source→label mapping.
RPC: UPDATE_LABEL (le8sX)
Source: _labels.py::update(), rename(), set_emoji(),
add_sources(), remove_sources() (builder: _label/params.py)
A unified label-update RPC covering rename, emoji, and source membership. Slot
[3] is a fieldmask [[name_emoji, sources_add, sources_remove]]; populate
only the group(s) you want to change.
# Rename (name_emoji = [name]; sources omitted)
params = [OPTS, notebook_id, label_id, [[[new_name]]]]
# Set emoji (name slot None, emoji set; sources omitted)
params = [OPTS, notebook_id, label_id, [[[None, emoji]]]]
# Add source(s) (name_emoji None, sources set) - APPENDS, does not replace
params = [OPTS, notebook_id, label_id, [[None, [[source_id]]]]]
# Remove source from this label only (sources_add None, sources_remove set)
params = [OPTS, notebook_id, label_id, [[None, None, [[source_id]]]]]
Note: the sources group appends (send only the IDs to add — existing
members survive) and labels may overlap (adding a source does not remove it
from any other label). Source removal is supported via the UPDATE_LABEL
fieldmask's sources_remove slot ([3][0][2]) — un-assigning the source from
this label only (it stays in the notebook and in any other label). The current
API loops one RPC per unique source id for add/remove membership changes; the
wire shape honors only the first id in each group.
Response: [] on success.
RPC: DELETE_LABEL (GyzE7e)
Source: _labels.py::delete()
Batch-capable — label IDs are passed as an array. Deleting a label does not delete its sources (they become unlabeled).
params = [OPTS, notebook_id, [label_id, ...]]
Response: [] on success.
Chat Panel
UI Selectors
CHAT_SELECTORS = {
"message_input": [
"textarea[placeholder='Ask a question or create something']",
"textarea[aria-label='Query box']",
"textarea[placeholder='Start typing...']",
],
"send_button": "button.submit-button[aria-label='Submit']",
"configure_button": "button[aria-label='Configure notebook']",
"chat_history": [
"chat-panel .chat-panel-content",
"[role='log']",
],
"message_bubble": [
".to-user-container", # AI messages
".from-user-container", # User messages
],
"save_to_note": "button[aria-label='Save message to a note']",
"copy_response": "button[aria-label='Copy model response to clipboard']",
"rate_good": "button[aria-label='Rate response as good']",
"rate_bad": "button[aria-label='Rate response as bad']",
}
CHAT_CONFIG = {
"modal": "configure-notebook-settings",
"goal_default": (
"configure-notebook-settings .prompt-section-toggles "
"button[aria-label='Default button']"
),
"goal_learning_guide": (
"configure-notebook-settings .prompt-section-toggles "
"button[aria-label='Learning Guide prompt button']"
),
"goal_custom": (
"configure-notebook-settings .prompt-section-toggles "
"button[aria-label='Custom button']"
),
"length_default": (
"configure-notebook-settings .style-section-toggles "
"button[aria-label='Default button']"
),
"length_shorter": "configure-notebook-settings button[aria-label='Concise style guide button']",
"length_longer": "configure-notebook-settings button[aria-label='Verbose style guide button']",
"save_button": "configure-notebook-settings button[aria-label*='Save settings']",
"close_button": "button[aria-label='Close chat settings']",
}
CHAT_OPTIONS = {
"menu_button": "button[aria-label='Chat options']",
"customize_notebook": "button[role='menuitem']:has-text('Customize notebook')",
"delete_history": "button[role='menuitem']:has-text('Delete chat history')",
}
NOTEBOOK_SHELL_SELECTORS = {
"share_notebook": "button[aria-label='Share notebook']",
"share_dialog": "mat-dialog-container[role='dialog']:has-text('Share')",
"share_copy_link": "button:has-text('Copy link')",
"share_more_copy_options": "button[aria-label='More copy options']",
"share_save": "button:has-text('Save')",
"settings_menu": "button[aria-label='Settings']",
"settings_output_language": "button[role='menuitem']:has-text('Output Language')",
"settings_theme": "button[role='menuitem'][aria-label='Theme']",
}
Query Endpoint (Streaming)
Chat queries use a separate streaming endpoint, not batchexecute:
POST /_/LabsTailwindUi/data/google.internal.labs.tailwind.orchestration.v1.LabsTailwindOrchestrationService/GenerateFreeFormStreamed
RPC: RENAME_NOTEBOOK (s0tc2d) - Rename Only
Source: _notebooks.py::rename()
# Just rename, no chat config
params = [
notebook_id, # 0
[[None, None, None, [None, new_title]]], # 1: Nested title at [[[3][1]]]
]
RPC: RENAME_NOTEBOOK (s0tc2d) - Configure Chat
Source: _chat/api.py::configure()
# Chat goal codes (ChatGoal enum)
CHAT_GOAL_DEFAULT = 1
CHAT_GOAL_CUSTOM = 2
CHAT_GOAL_LEARNING_GUIDE = 3
# Response length codes (ChatResponseLength enum)
CHAT_LENGTH_DEFAULT = 1
CHAT_LENGTH_LONGER = 4
CHAT_LENGTH_SHORTER = 5
# Build goal array
goal_array = [goal_value] # e.g., [1] for DEFAULT
# For CUSTOM: goal_array = [2, custom_prompt]
chat_settings = [goal_array, [response_length_value]]
params = [
notebook_id, # 0
[[None, None, None, None, None, None, None, chat_settings]], # 1: Settings at [[[7]]]
]
RPC: GET_LAST_CONVERSATION_ID (hPTbtc)
Source: _chat/api.py::get_conversation_id()
Returns the most recent conversation ID for a notebook. The server always returns
exactly one ID regardless of the limit param. Use GET_CONVERSATION_TURNS to
fetch the actual messages for the returned conversation.
params = [
[], # 0: Empty sources array
None, # 1
notebook_id, # 2
1, # 3: Limit (server ignores this; always returns one ID)
]
# Live web UI/CDP capture on 2026-06-15:
params = [
[2, None, None, [1, None, None, None, None, None, None, None, None, None, [1]]],
None,
notebook_id,
20,
]
Response: [[[conv_id]]] — single entry list containing the conversation ID.
RPC: GET_CONVERSATION_TURNS (khqZz)
Source: _chat/api.py::get_conversation_turns()
Returns the Q&A turns for a specific conversation. Turns are ordered newest-first.
params = [
[], # 0: Empty
None, # 1
None, # 2
conversation_id, # 3
limit, # 4: Max turns to return (e.g., 2 for latest Q&A pair)
]
# Live web UI/CDP capture on 2026-06-15:
params = [
[2, None, None, [1, None, None, None, None, None, None, None, None, None, [1]]],
None,
None,
conversation_id,
20,
]
Response turn structure:
turn[2] == 1: User question — text is atturn[3]turn[2] == 2: AI answer — text is atturn[4][0][0]
RPC: DELETE_CONVERSATION (J7Gthc)
Source: _chat/api.py::delete_conversation()
Deletes a conversation server-side. Mirrors the NotebookLM web UI's "Delete
history" button. After the call, the next ask() with no conversation_id
starts a brand-new conversation instead of extending the deleted one.
params = [
[], # 0: Empty / reserved
conversation_id, # 1: Conversation to delete
None, # 2
1, # 3: Always observed as 1; meaning unconfirmed
]
# source_path = f"/notebook/{notebook_id}" — notebook scope rides on the URL
Response: empty [] body inside the standard wrb.fr envelope. Success
is signaled by the absence of an error — there is no return payload.
RPC: SUGGEST_PROMPTS (otmP3b)
Source: _notebooks.py::NotebooksAPI.suggest_prompts()
Returns AI-suggested prompts for a notebook (the live
GeneratePromptSuggestions method) — a general notebook-prompt endpoint whose
mode selects the product surface (default 4 suggests chat questions). Each
suggestion pairs a short title with a ready-to-send multi-line instruction
string. Shape live-verified on the consumer/labs cohort (issue #1612) — the
backend serves it regardless of the web UI's experiment flag.
params = [
# 0: client context (capability envelope; same family as artifact RPCs)
[2, None, None, [1, None, None, None, None, None, None, None, None, None, [1]]],
notebook_id, # 1: Notebook to suggest prompts for
[[source_id], ...], # 2: Source-id wrappers (one [id] per source)
mode, # 3: REQUIRED int "mode/surface" enum (1..10;
# 0/omitted -> server INTERNAL). Default 4.
None, # 4: Reserved (always null)
query, # 5: Optional free-text steer (or null)
]
# source_path = f"/notebook/{notebook_id}"
Response: a single-element envelope wrapping the suggestion rows —
[[ [title, prompt], [title, prompt], ... ]]. Each row is decoded to a
PromptSuggestion(title, prompt). An empty / degenerate payload yields []
(suggestions are best-effort, so an absent payload does not raise).
Studio Panel - Artifact Generation
UI Selectors
STUDIO_SELECTORS = {
"artifact_button": ".create-artifact-button-container",
"customize_icon": [
".option-icon",
"button.edit-button[aria-label^='Customize']",
], # Click THIS for customization!
"add_note": "button:has-text('Add note')",
"artifact_list": ".artifact-library-container",
"artifact_row": ".artifact-item-button",
"artifact_menu": ".artifact-more-button",
}
ARTIFACT_MENU = {
"rename": "button:has-text('Rename')",
"share": "button:has-text('Share')",
"view_prompt_sources": "button:has-text('View prompt and sources')",
"delete": "button:has-text('Delete')",
}
ARTIFACT_VIEWER_SELECTORS = {
"open_artifact": ".artifact-item-button",
"expand": "button[aria-label='Expand']",
"more_options": "button[aria-label='More options']",
"view_sources": "button:has-text('View'):has-text('sources')",
"good_content": "button[aria-label='Good content rating']",
"bad_content": "button[aria-label='Bad content rating']",
}
NOTE_SELECTORS = {
"add_note": "button.add-note-button, button:has-text('Add note')",
"note_row": ".artifact-item-button:has-text('sticky_note_2')",
"note_row_by_title": ".artifact-item-button:has-text('<note title>')",
"open_note": ".artifact-item-button:has-text('<note title>') .artifact-stretched-button",
"note_menu": ".artifact-item-button:has-text('<note title>') .artifact-more-button",
"note_view_title": "input.title-input",
"note_view_close": "button[aria-label='Close note view']",
}
NOTE_MENU = {
"convert_to_source": "button[role='menuitem']:has-text('Convert to source')",
"convert_all_notes_to_source": "button[role='menuitem']:has-text('Convert all notes to source')",
"export_to_docs": "button[role='menuitem']:has-text('Export to Docs')",
"export_to_sheets": "button[role='menuitem']:has-text('Export to Sheets')",
"delete": "button[role='menuitem']:has-text('Delete')",
"confirm_delete": "button[aria-label='Confirm deletion']",
"cancel_delete": "button[aria-label='Cancel']",
}
GENERATION_TILE_SELECTORS = {
"tile": ".create-artifact-button-container",
"tile_by_label": ".create-artifact-button-container[aria-label='<label>']",
"customize_icon": ".create-artifact-button-container[aria-label='<label>'] .option-icon",
# Present for every live tile except Video Overview and Reports:
"customize_button": "button.edit-button[aria-label^='Customize']",
"customizer_dialog": "mat-dialog-container[role='dialog']",
"dialog_close": [
"button[aria-label='Close dialog']",
"button[aria-label='Close']",
],
"language_select": "mat-select[role='combobox']",
"language_option": "mat-option[role='option']",
"dialog_generate": "button:has-text('Generate')",
}
Live Studio tiles on 2026-06-15:
| Tile label | Customize selector | Live options |
|---|---|---|
| Audio Overview | .create-artifact-button-container[aria-label='Audio Overview'] .option-icon |
Format: Deep Dive, Brief, Critique, Debate; language; Length: Short, Default, Long; prompt textarea aria-label='What should the AI hosts focus on in this episode?' |
| Slide Deck | .create-artifact-button-container[aria-label='Slide Deck'] .option-icon |
Format: Detailed Deck, Presenter Slides; language; Length: Short, Default; prompt textarea aria-label='Describe the slide deck you want to create' |
| Video Overview | .create-artifact-button-container[aria-label='Video Overview'] .option-icon |
Format: Cinematic, Explainer, Brief; prompt textarea aria-label='How would you like the video to be customized?' |
| Mind Map | .create-artifact-button-container[aria-label='Mind Map'] .option-icon |
Prompt textarea aria-label='Text area for custom topic' |
| Reports | .create-artifact-button-container[aria-label='Reports'] .option-icon |
Root format picker: Create Your Own, Briefing Doc, Study Guide, Blog Post, plus dynamic suggested formats; each format can open a report prompt form |
| Flashcards | .create-artifact-button-container[aria-label='Flashcards'] .option-icon |
Number of Cards: Fewer, Standard (Default), More; Difficulty: Easy, Medium (Default), Hard; prompt textarea aria-label='Text area for custom topic' |
| Quiz | .create-artifact-button-container[aria-label='Quiz'] .option-icon |
Number of Questions: Fewer, Standard (Default), More; Difficulty: Easy, Medium (Default), Hard; prompt textarea aria-label='Text area for custom topic' |
| Infographic | .create-artifact-button-container[aria-label='Infographic'] .option-icon |
Orientation: Landscape, Portrait, Square; visual style: Auto-select, Kawaii, Clay, Sketch Note, Anime, Editorial, Instructional, Bento Grid, Bricks, Scientific, Professional; detail: Concise, Standard, Detailed BETA; prompt textarea aria-label='Describe the infographic you want to create' |
| Data Table | .create-artifact-button-container[aria-label='Data Table'] .option-icon |
Language; prompt textarea aria-label='Describe the data table you want to create' |
Reports-specific live selectors:
REPORT_DIALOG_SELECTORS = {
"create_your_own": "button[aria-label='Create Your Own']",
"briefing_doc": "button[aria-label='Briefing Doc']",
"study_guide": "button[aria-label='Study Guide']",
"blog_post": "button[aria-label='Blog Post']",
"suggested_format": "button.primary-action-button[aria-label]", # Dynamic titles
"customize_report": "button[aria-label='Customize Report']",
"back": "button[aria-label='Back']",
"prompt": "textarea[aria-label='Input to describe the kind of report to create']",
}
On 2026-06-15 the live suggested report titles for the probe notebook were
Product Evolution Report, Strategic Adoption Analysis,
Technical Terminology Reference, and Capability Application Overview.
These are notebook-content-dependent, so treat them as examples, not constants.
Notes live in the same Studio list as artifacts. Live note probe
(2026-06-15): clicking Add note created a New Note row with
.artifact-item-button and .artifact-more-button. Its menu exposed
Convert to source, Convert all notes to source, Export to Docs,
Export to Sheets, and Delete; delete opened a confirmation dialog with
button[aria-label='Confirm deletion']. The throwaway New Note from the
probe was deleted after collecting selectors.
Critical: Edit Icon vs Full Button
# ✅ Click edit icon for customization dialog
await page.locator(
".create-artifact-button-container[aria-label='Audio Overview'] .option-icon"
).click()
# Video Overview and Reports also use `.option-icon`, but do not expose it as
# `button.edit-button[aria-label^='Customize']` in the live DOM.
await page.locator(
".create-artifact-button-container[aria-label='Video Overview'] .option-icon"
).click()
# ❌ Clicking full button starts generation with defaults (skips customization!)
await page.locator(".create-artifact-button-container[aria-label='Audio Overview']").click()
RPC: CREATE_ARTIFACT (R7cb6c)
All artifact types use R7cb6c with different content type codes and nested configs.
Source: _artifacts.py (param builders: _artifact/payloads.py)
Live UI captures on 2026-06-15 for Data Table and interactive Mind Map showed
the web client sending the full client-options block below as param 0 (not
the older minimal [2] form). The Python payload builders now match this UI
shape for every CREATE_ARTIFACT generator and for RETRY_ARTIFACT.
create_artifact_options = [
2,
None,
None,
[1, None, None, None, None, None, None, None, None, None, [1]],
[[1, 4, 8, 2, 3, 6]], # artifact-type capability list
]
Kickoff response shape live-confirmed on 2026-06-15 for Data Table and interactive Mind Map:
result = [
[
artifact_id, # [0][0]: task/artifact id
title, # [0][1]
artifact_type, # [0][2]
None, # [0][3]
status_code, # [0][4]: 1=in_progress in both captures
# ... additional artifact metadata slots; first row len was 20
]
]
ArtifactGenerationService._parse_generation_result() reads result[0][0]
and result[0][4], which matches both live responses.
Audio Overview (Type 1)
Source: _artifacts.py::ArtifactsAPI (param builders: _artifact/payloads.py)
source_ids_triple = [[[sid]] for sid in source_ids] # [[[s1]], [[s2]], ...]
source_ids_double = [[sid] for sid in source_ids] # [[s1], [s2], ...]
params = [
create_artifact_options, # 0: Client options/capabilities
notebook_id, # 1
[
None, # [0]
None, # [1]
1, # [2]: ArtifactTypeCode.AUDIO
source_ids_triple, # [3]
None, # [4]
None, # [5]
[
None,
[
instructions, # Focus/instructions text
length_code, # 1=SHORT, 2=DEFAULT, 3=LONG
None,
source_ids_double,
language, # "en"
None,
format_code, # 1=DEEP_DIVE, 2=BRIEF, 3=CRITIQUE, 4=DEBATE
],
], # [6]
], # 2: Source config
]
Video Overview (Type 3)
Source: _artifacts.py::generate_video()
# Build the inner video config. Explainer and Brief expose visual styles;
# Cinematic hides the visual-style selector.
video_config = [
source_ids_double,
language, # "en"
instructions, # Focus/customization prompt
None,
format_code, # 1=EXPLAINER, 2=BRIEF, 3=CINEMATIC
style_code, # None=CUSTOM, 1=AUTO_SELECT, 2=CLASSIC, 3=WHITEBOARD, ...
]
if video_style == VideoStyle.CUSTOM and style_prompt:
video_config.append(style_prompt)
params = [
create_artifact_options,
notebook_id,
[
None, # [0]
None, # [1]
3, # [2]: ArtifactTypeCode.VIDEO
source_ids_triple, # [3]
None, # [4]
None, # [5]
None, # [6]
None, # [7]
[None, None, video_config], # [8]
],
]
Live Web UI capture on 2026-06-17 showed these visual-style radio values:
AUTO_SELECT=1, CUSTOM=0, CLASSIC=2, WHITEBOARD=3,
KAWAII=9, ANIME=7, WATERCOLOR=6, RETRO_PRINT=8,
HERITAGE=4, and PAPER_CRAFT=5. Because CUSTOM=0 is the protobuf
default, the Web UI's JSON array omits that field as null and appends the
custom visual-style prompt in the 7th slot:
[
source_ids_double,
"en",
"Focus prompt",
None,
2, # VideoFormat.BRIEF
None, # VideoStyle.CUSTOM omitted/defaulted
"Custom visual style",
]
Cinematic Video Overview (Type 3, format=3)
Source: _artifacts.py::generate_cinematic_video()
Cinematic videos use AI-generated documentary footage (Veo 3) instead of
slide-deck animations. They share the standard video RPC (Type 3) but omit
style_code and never accept style_prompt. Requires a Google AI Ultra
subscription.
params = [
create_artifact_options,
notebook_id,
[
None, # [0]
None, # [1]
3, # [2]: ArtifactTypeCode.VIDEO
source_ids_triple, # [3]
None, # [4]
None, # [5]
None, # [6]
None, # [7]
[
None,
None,
[
source_ids_double,
language, # "en"
instructions,
None,
3, # VideoFormat.CINEMATIC
],
], # [8]
],
]
Report (Type 2)
Source: _artifacts.py::generate_report()
params = [
create_artifact_options,
notebook_id,
[
None, # [0]
None, # [1]
2, # [2]: ArtifactTypeCode.REPORT
source_ids_triple, # [3]
None, # [4]
None, # [5]
None, # [6]
[
None,
[
title, # "Briefing Doc" / "Study Guide" / etc.
description, # Short description
None,
source_ids_double,
language, # "en"
prompt, # Detailed generation prompt
None,
True,
],
], # [7]
],
]
Quiz (Type 4, Variant 2)
Source: _artifacts.py::generate_quiz()
params = [
create_artifact_options,
notebook_id,
[
None, # [0]
None, # [1]
4, # [2]: ArtifactTypeCode.QUIZ_FLASHCARD
source_ids_triple, # [3]
None, # [4]
None, # [5]
None, # [6]
None, # [7]
None, # [8]
[
None,
[
2, # Variant: 2=quiz, 1=flashcards, 4=interactive mind map
None,
instructions,
None,
None,
None,
None,
[quantity_code, difficulty_code], # quantity: 1=FEWER, 2=STANDARD
], # difficulty: 1=EASY, 2=MEDIUM, 3=HARD
], # [9]
],
]
Flashcards (Type 4, Variant 1)
Source: _artifacts.py::generate_flashcards()
params = [
create_artifact_options,
notebook_id,
[
None, # [0]
None, # [1]
4, # [2]: ArtifactTypeCode.QUIZ_FLASHCARD
source_ids_triple, # [3]
None, # [4]
None, # [5]
None, # [6]
None, # [7]
None, # [8]
[
None,
[
1, # Variant: 1=flashcards (vs 2=quiz, 4=interactive mind map)
None,
instructions,
None,
None,
None,
[difficulty_code, quantity_code], # Note: reversed order from quiz!
],
], # [9]
],
]
Infographic (Type 7)
Source: _artifacts.py::generate_infographic()
# Orientation: 1=LANDSCAPE, 2=PORTRAIT, 3=SQUARE
# Detail: 1=CONCISE, 2=STANDARD, 3=DETAILED
# Style: InfographicStyle enum (1=AUTO_SELECT, 2=SKETCH_NOTE,
# 3=PROFESSIONAL, 4=BENTO_GRID, 5=EDITORIAL, ...).
# See rpc/types.py::InfographicStyle for the full list.
params = [
create_artifact_options,
notebook_id,
[
None, # [0]
None, # [1]
7, # [2]: ArtifactTypeCode.INFOGRAPHIC
source_ids_triple, # [3]
None, None, None, None, None, None, None, None, None, None, # [4-13]
[[instructions, language, None, orientation_code, detail_code, style_code]], # [14]
],
]
Note: Position [14] wraps the config in a single-element list ([[...]]),
not [None, [...]]. The 6th tuple element style_code was added with the
infographic style preset feature; pass None to let the backend auto-select.
Slide Deck (Type 8)
Source: _artifacts.py::generate_slide_deck()
# Format: 1=DETAILED_DECK, 2=PRESENTER_SLIDES
# Length: 1=DEFAULT, 2=SHORT
params = [
create_artifact_options,
notebook_id,
[
None, # [0]
None, # [1]
8, # [2]: ArtifactTypeCode.SLIDE_DECK
source_ids_triple, # [3]
None, None, None, None, None, None, None, None, None, None, None, None, # [4-15]
[[instructions, language, format_code, length_code]], # [16]
],
]
Data Table (Type 9)
Source: _artifacts.py::generate_data_table()
params = [
create_artifact_options,
notebook_id,
[
None, # [0]
None, # [1]
9, # [2]: ArtifactTypeCode.DATA_TABLE
source_ids_triple, # [3]
None, None, None, None, None, None, None, None, None, None, None, None, None, None, # [4-17]
[None, [instructions, language]], # [18]
],
]
Mind Map (Type 5) - Uses GENERATE_MIND_MAP (yyryJe)
Source: _artifacts.py::generate_mind_map()
Note: Mind map uses a different RPC method than other artifacts.
# RPC: GENERATE_MIND_MAP (yyryJe), NOT CREATE_ARTIFACT
# Python signature:
# generate_mind_map(notebook_id, source_ids=None, language="en", instructions=None)
params = [
source_ids_nested, # 0: [[[sid]] for sid in source_ids]
None, # 1
None, # 2
None, # 3
None, # 4
[
"interactive_mindmap", # 5[0]: command name
[["[CONTEXT]", instructions or ""]], # 5[1]: instructions (added in v0.4.0)
language, # 5[2]: language code, e.g. "en" (added in v0.4.0)
],
None, # 6
[2, None, [1]], # 7: Fixed config
]
Interactive Mind Map (Type 4 / variant 4) - Uses CREATE_ARTIFACT (R7cb6c)
Source: _artifact/payloads.py::build_interactive_mind_map_artifact_params(),
_mind_maps_api.py::MindMapsAPI.generate()
NotebookLM's web app now generates an interactive mind map — a studio
artifact in the type-4 family with variant 4 (distinct from the note-backed
JSON mind map above, which the library surfaces with the synthetic type code 5).
Unlike the synchronous note-backed kind, this is created asynchronously via
CREATE_ARTIFACT and polled to completion (issue #1256).
# RPC: CREATE_ARTIFACT (R7cb6c) — interactive mind map
params = [
create_artifact_options,
notebook_id,
[
None, None,
4, # 2: artifact type (type-4 family)
[[[sid]] for sid in source_ids], # 3: nested source ids
None, None, None, None, None,
[None, [4]], # 9: [_, [variant]] → variant 4 = interactive mind map
],
]
# With a custom prompt: variant at [9][1][0], free-text prompt at [9][1][2] —
# the same options-block layout quiz/flashcards use. The server honors it for
# variant 4 (verified live: it steers the generated node tree), and the prompt
# reads back from the LIST_ARTIFACTS [9][1][2] slot that
# ``ArtifactRow.generation_prompt`` decodes.
params[2][9] = [None, [4, None, "focus only on the three astronauts"]]
Reading the tree: the interactive map exposes its {"name", "children"}
node tree through GET_INTERACTIVE_HTML (v9rmvd) — the same RPC used for
quiz/flashcard HTML, but the JSON tree lives at [0][9][3] (the rendered
HTML body is at [0][9][0]). client.mind_maps.get_tree() and
download_mind_map both read that position; client.mind_maps unifies the two
kinds behind a single MindMapKind discriminator.
RPC: LIST_ARTIFACTS (gArtLc)
Source: _artifacts.py::list(), _artifacts.py::poll_status()
params = [
[2],
notebook_id,
'NOT artifact.status = "ARTIFACT_STATUS_SUGGESTED"', # Filter string
]
# Live web UI/CDP capture on 2026-06-15 includes projection/type filters
# inside the request-options wrapper:
params = [
[
2,
None,
None,
[1, None, None, None, None, None, None, None, None, None, [1]],
[[1, 4, 8, 2, 3, 6]],
],
notebook_id,
'NOT artifact.status = "ARTIFACT_STATUS_SUGGESTED"',
]
# Response contains artifacts array with status:
# status = 1 → Processing
# status = 2 → Pending
# status = 3 → Completed
# status = 4 → Failed
Python API Note: artifacts.list() also fetches mind maps from GET_NOTES_AND_MIND_MAPS and includes them as Artifact objects (type=5). This provides a unified list of all AI-generated content. Mind maps with status=2 (deleted) are filtered out.
Notes
RPC: CREATE_NOTE (CYK0Xb)
Source: _notes.py::create()
Note: Google ignores title/content in CREATE_NOTE. Must call UPDATE_NOTE after to set actual content.
# Creates note with fixed placeholder values
params = [
notebook_id, # 0
"", # 1: Empty string (ignored)
[1], # 2: Fixed flag
None, # 3
"New Note", # 4: Placeholder title (ignored)
]
# Then call UPDATE_NOTE to set real title/content
RPC: CREATE_NOTE (saved-from-chat variant) (CYK0Xb)
Source: _chat/notes.py::save_chat_answer_as_note() (canonical owner) —
exposed publicly as ChatAPI.save_answer_as_note(...).
Note: This is the same RPC method ID as plain CREATE_NOTE above, but uses a 7-element params array (vs the 5-element blank-note form) and mode flag [2] to tell the server the note carries a saved chat answer. The server stores per-citation source-passage metadata so [N] markers in the answer render as hover-anchored links in the NotebookLM web UI. No follow-up UPDATE_NOTE is needed — this is a single round-trip.
Reverse-engineered from a captured web-UI "Save to note" request (issue #660). Pinned by fixture and golden unit test at tests/unit/fixtures/save_chat_as_note_create_note_request.json and tests/unit/test_save_chat_as_note_encoder.py::test_golden_single_citation.
params = [
notebook_id, # [0]
answer_with_markers, # [1] str — full answer text INCLUDING [N] markers
[2], # [2] mode flag — [2] = saved-from-chat (vs [1] = blank-note)
source_passages, # [3] list — one descriptor per UNIQUE cited chunk_id
title, # [4] str — requested title; server may auto-generate a smart one
rich_content, # [5] list — cleaned answer + per-marker anchors (see below)
[2], # [6] trailer flag
]
source_passages (slot [3]) — one entry per unique cited chunk:
[
None, None, None,
[[None, source_start, source_end]], # passage span in source document
[passage_text_wrapper], # cited text wrapped with offsets + render flags
[[[passage_id], source_id]], # passage_id + source_id pair
[chunk_id], # standalone chunk_id
]
rich_content (slot [5]) — five sub-slots:
[
[
cleaned_answer_passage_group, # answer text WITHOUT [N] markers, with offsets
[ # per-marker anchors
[[chunk_id], [None, 0, position_of_marker_in_clean_text]],
# ...one entry per [N] in the answer
],
],
None, # always-null slot
None, # always-null slot
[ # source_passages keyed by chunk_id
[[chunk_id], <same descriptor shape as one entry of slot [3]>],
],
1, # trailer flag
]
Response shape (6 elements — same shape as a stored note row):
[
note_id, # [0] server-assigned UUID
answer_with_markers, # [1] echoed
[2, user_id, [ts_sec, ts_nanos]], # [2] metadata: type=2 (saved-from-chat)
source_passages, # [3] echoed
server_title, # [4] may differ from request (smart title)
rich_content, # [5] echoed
]
Encoding quirks:
- Rendering-flag arrays use the integer
0, not the booleanfalse—json.dumps(False)emitsfalse, which the server normalizes but the wire-channel match is strict. The encoder uses integer0to stay byte-exact with the captured request. - The server appears to apply a "smart title" pass for
[2]-mode notes — the captured response title differed from the captured request title (the request sent"New Saved Note"; the response stored"Le Verger de la Connaissance : Le Cas de la Pomme").ChatAPI.save_answer_as_note()surfaces the server-stored title in the returnedNote.
Known gaps:
- The
passage_idUUID at slot[3][0][5][0][0]does NOT appear in the streaming chat response shape we currently parse._build_source_passage_descriptorfalls back tochunk_idas a placeholder whenChatReference.passage_idis unset (which is always, in production today). Empirically the server accepts this and the web UI still renders hover anchors. If a future capture reveals where this UUID comes from, populateChatReference.passage_idin_chat_wire.py::parse_single_citation()and the encoder will use it automatically. - Multi-citation segmentation uses a cumulative-span heuristic (each
[N]anchorsclean_text[0..position]rather than a per-segment span). This matches the captured single-citation payload exactly but is unverified against multi-citation captures. See issue #660 PR description.
RPC: UPDATE_NOTE (cYAfTb)
Source: _notes.py::update()
params = [
notebook_id, # 0
note_id, # 1
[[[content, title, [], 0]]], # 2: Triple-nested [content, title, [], 0]
]
RPC: DELETE_NOTE (AH0mwd)
Source: _notes.py::delete()
Important: This is a soft delete - it clears note content but does NOT remove the note from the list. The note remains with None content and a status flag of 2.
params = [
notebook_id, # 0
None, # 1
[note_id], # 2: Single-nested note ID
]
# BEFORE delete:
# ['note_id', ['note_id', 'content', [metadata], None, 'title']]
# AFTER delete:
# ['note_id', None, 2] # Status 2 = deleted/cleared
Note: Same behavior applies to mind maps via delete_mind_map(). The Python API filters out items with status=2 in list() and list_mind_maps() to match UI behavior.
RPC: GET_NOTES_AND_MIND_MAPS (cFji9)
Source: _notes.py::_get_all_notes_and_mind_maps()
params = [notebook_id]
# Live web UI/CDP capture on 2026-06-15:
params = [
notebook_id,
None,
None, # Refresh calls may send a timestamp here, e.g. [seconds, nanos]
[2, None, None, [1, None, None, None, None, None, None, None, None, None, [1]]],
]
Note/Mind Map Data Structures
Notes and mind maps share the same storage system and are distinguished by content format.
Active Note Structure
[
"note_id", # Position 0: Note ID
[
"note_id", # [1][0]: ID (duplicate)
"content", # [1][1]: Note content text
[ # [1][2]: Metadata
1, # Type flag
"user_id", # User ID
[ts, ns] # [timestamp_seconds, nanoseconds]
],
None, # [1][3]: Unknown
"title" # [1][4]: Note title
]
]
Active Mind Map Structure
[
"mind_map_id", # Position 0: Mind map ID
[
"mind_map_id", # [1][0]: ID (duplicate)
'{"name": "Root", "children": [...]}', # [1][1]: JSON with children/nodes
[metadata], # [1][2]: Same as notes
None, # [1][3]: Unknown
"Mind Map Title" # [1][4]: Title
]
]
Deleted Item Structure (Status = 2)
["id", None, 2] # Content cleared, status=2 indicates soft-deleted
The Python API:
notes.list()- Returns only active notes (excludes mind maps and status=2)notes.list_mind_maps()- Returns only active mind maps (excludes status=2)artifacts.list()- Includes mind maps as Artifact objects (excludes status=2)
Source ID Nesting Patterns
CRITICAL: Source IDs require different nesting levels depending on the method.
| Pattern | Structure | Used By |
|---|---|---|
| Single | [source_id] |
UPDATE_SOURCE position [1] |
| Double | [[source_id]] |
Artifact source_ids_double |
| Triple | [[[source_id]]] |
DELETE_SOURCE, Artifact source_ids_triple |
| Quadruple | [[[[source_id]]]] |
GET_SOURCE_GUIDE |
| Array of Double | [[s1], [s2], ...] |
Artifact generation |
| Array of Triple | [[[s1]], [[s2]], ...] |
Artifact generation |
Building nesting in Python:
source_ids = ["source_1", "source_2", "source_3"]
# Single: [source_id]
single = [source_ids[0]]
# Double: [[source_id]]
double = [[source_ids[0]]]
# Triple: [[[source_id]]]
triple = [[[source_ids[0]]]]
# Array of Double for artifacts
source_ids_double = [[sid] for sid in source_ids]
# Result: [["source_1"], ["source_2"], ["source_3"]]
# Array of Triple for artifacts
source_ids_triple = [[[sid]] for sid in source_ids]
# Result: [[["source_1"]], [["source_2"]], [["source_3"]]]
Notebook Summary & Sharing
RPC: SUMMARIZE (VfAZjd)
Source: _notebooks.py::get_summary(), _notebooks.py::get_description()
Gets AI-generated summary and suggested topics for a notebook.
params = [
notebook_id, # 0: Notebook ID
[2], # 1: Fixed flag
]
# Live web UI/CDP capture on 2026-06-15:
params = [
notebook_id,
[2, None, None, [1, None, None, None, None, None, None, None, None, None, [1]]],
]
# Called with source_path:
await rpc_call(
RPCMethod.SUMMARIZE,
params,
source_path=f"/notebook/{notebook_id}",
)
# Response structure:
# [
# [ # [0]: Outer container
# [summary_text], # [0][0]: Summary wrapped in list; text at [0][0][0]
# [[ # [0][1][0]: Suggested topics array
# [question, prompt], # Each topic has question and prompt
# ...
# ]],
# null, null, null,
# [[question, score], ...], # [0][5]: Topics with relevance scores
# ]
# ]
RPC: GET_SHARE_STATUS (JFMDGd)
Source: _sharing.py::get_status()
Get the current share settings for a notebook, including users with access and public status.
params = [
notebook_id, # 0: Notebook ID
[2], # 1: Fixed flag
]
# Live web UI/CDP capture on 2026-06-15:
params = [
notebook_id,
[2, None, None, [1, None, None, None, None, None, None, None, None, None, [1]]],
]
# Called with source_path:
await rpc_call(
RPCMethod.GET_SHARE_STATUS,
params,
source_path=f"/notebook/{notebook_id}",
)
# Response structure:
# [
# [ # [0]: List of users with access
# [
# "email@example.com", # [0]: email
# 1, # [1]: permission (1=owner, 2=editor, 3=viewer)
# [], # [2]: flags (empty)
# [
# "Display Name", # [3][0]: display name
# "https://..." # [3][1]: avatar URL
# ]
# ],
# # ... more users
# ],
# [true], # [1]: is_public - [true] or [false]
# 1000 # [2]: unknown constant (ignore)
# ]
RPC: SHARE_NOTEBOOK (QDyure)
Source: _sharing.py::set_public(), _sharing.py::add_user(), _sharing.py::remove_user()
Multi-purpose RPC for managing notebook sharing: toggle public access, add/update users, or remove users.
Toggle public/restricted access:
# access_value: 0=restricted, 1=anyone with link
params = [
[
[
notebook_id,
None, # no user changes
[access_value], # [0]=restricted, [1]=public
[access_value, ""] # [flag, welcome_message]
]
],
1, # action type
None,
[2] # fixed flag
]
# Response: [] (empty on success)
Add/update user:
# permission: 2=editor, 3=viewer, 4=remove
# notify_flag: 0=no email, 1=send notification
# message_flag: 0=has message, 1=no message
params = [
[
[
notebook_id,
[[email, None, permission]], # user to add/update
None, # None = no public access change
[message_flag, welcome_message]
]
],
notify_flag, # 0 or 1
None,
[2]
]
# Response: [] (empty on success)
Remove user:
params = [
[
[
notebook_id,
[[email, None, 4]], # 4 = remove permission
None,
[0, ""]
]
],
0, # no notification
None,
[2]
]
RPC: SET_VIEW_LEVEL (via RENAME_NOTEBOOK s0tc2d)
Source: _sharing.py::set_view_level()
Set what viewers can access (full notebook vs chat only).
Note: This uses the same RPC ID as RENAME_NOTEBOOK (s0tc2d) but with different parameter structure.
# view_level: 0=full notebook, 1=chat only
params = [
notebook_id, # 0: Notebook ID
[
[
None, None, None, None, # indices 0-3
None, None, None, None, # indices 4-7
[[view_level]], # index 8: [[0]] or [[1]]
]
],
]
# Response: Full notebook data (same as rename response)
Notebook Sharing Overview
Notebook sharing and artifact deep-link sharing are separate toggles:
SHARE_NOTEBOOK governs who can open the notebook at all, while
SHARE_ARTIFACT is the legacy share-link path used to build or toggle a
notebook URL with an optional ?artifactId= target.
Notebooks have three sharing dimensions:
-
Notebook visibility (SHARE_NOTEBOOK - QDyure):
[0]= Restricted (only explicitly shared users)[1]= Anyone with the link
-
View Level (RENAME_NOTEBOOK - s0tc2d):
[[0]]= Full notebook (chat + sources + notes)[[1]]= Chat only (viewers can only use chat)
-
User Permissions (SHARE_NOTEBOOK - QDyure):
1= Owner (read-only, cannot be assigned)2= Editor (can edit notebook)3= Viewer (read-only access)4= Remove (internal: remove user from share list)
Python API:
# Use client.sharing for all sharing operations
status = await client.sharing.get_status(notebook_id)
await client.sharing.set_public(notebook_id, True)
await client.sharing.set_view_level(notebook_id, ShareViewLevel.CHAT_ONLY)
await client.sharing.add_user(notebook_id, "user@example.com", SharePermission.VIEWER)
Share URLs:
- Notebook:
https://notebooklm.google.com/notebook/{notebook_id} - Artifact deep-link:
https://notebooklm.google.com/notebook/{notebook_id}?artifactId={artifact_id}
The ?artifactId=xxx parameter creates a deep link that opens the notebook and
navigates to that specific artifact. It does not make the artifact an
independent public resource outside the notebook. Mind Maps cannot be shared
(no public URLs).
Source Refresh Operations
RPC: REFRESH_SOURCE (FLmJqe)
Source: _sources.py::refresh()
Refresh a source to get updated content (for URL/Drive sources).
params = [
None, # 0
[source_id], # 1: Single-nested source ID
[2], # 2: Fixed flag
]
# Called with source_path:
await rpc_call(
RPCMethod.REFRESH_SOURCE,
params,
source_path=f"/notebook/{notebook_id}",
)
RPC: CHECK_SOURCE_FRESHNESS (yR9Yof)
Source: _sources.py::check_freshness()
Check if a source needs to be refreshed.
params = [
None, # 0
[source_id], # 1: Single-nested source ID
[2], # 2: Fixed flag
]
# Called with source_path:
await rpc_call(
RPCMethod.CHECK_SOURCE_FRESHNESS,
params,
source_path=f"/notebook/{notebook_id}",
)
# Response varies by source type:
# URL sources: [] (empty array) = fresh
# Drive sources: [[null, true, [source_id]]] = fresh
# [[null, false, [source_id]]] = stale
# Legacy: True = fresh, False = stale
Research Operations
Research allows searching the web or Google Drive for sources to add to notebooks.
Source Type Codes
| Code | Source |
|---|---|
| 1 | Web |
| 2 | Google Drive |
RPC: START_FAST_RESEARCH (Ljjv0c)
Source: _research.py::start() with mode="fast"
Start a fast research session.
# source_type: 1=Web, 2=Drive
params = [
[query, source_type], # 0: Query and source type
None, # 1
1, # 2: Fixed value
notebook_id, # 3: Notebook ID
]
# Called with source_path:
await rpc_call(
RPCMethod.START_FAST_RESEARCH,
params,
source_path=f"/notebook/{notebook_id}",
)
# Response: [task_id, report_id, ...]
RPC: START_DEEP_RESEARCH (QA9ei)
Source: _research.py::start() with mode="deep"
Start a deep research session (web only, more thorough).
# Deep research only supports Web (source_type=1)
params = [
None, # 0
[1], # 1: Fixed flag
[query, source_type], # 2: Query and source type
5, # 3: Fixed value
notebook_id, # 4: Notebook ID
]
# Called with source_path:
await rpc_call(
RPCMethod.START_DEEP_RESEARCH,
params,
source_path=f"/notebook/{notebook_id}",
)
# Response: [task_id, report_id, ...]
Deep research is not complete after QA9ei alone. In the observed browser/client
flow, the returned report_id later becomes important during polling and import:
QA9eistarts the deep research job and returns[task_id, report_id, ...]e3bVqcpolls the notebook for all research tasks and exposes the report contentLBwxtbimports the report entry plus selected web sources using the later polled deep-research task ID, which commonly matches the earlierreport_id
RPC: POLL_RESEARCH (e3bVqc)
Source: _research.py::poll()
Poll for research results.
params = [
None, # 0
None, # 1
notebook_id, # 2: Notebook ID
]
# Called with source_path:
await rpc_call(
RPCMethod.POLL_RESEARCH,
params,
source_path=f"/notebook/{notebook_id}",
)
# Response structure:
# [
# [task_id, [
# ...,
# query_info, # [1]: [query_text, ...]
# ...,
# sources_and_summary, # [3]: [[sources], summary_text]
# status_code, # [4]: 2=completed, 6=completed (deep), other=in_progress
# ]],
# ...
# ]
#
# sources_and_summary[0] can contain a mix of:
#
# Fast research web source:
# [url, title, desc, type, ...]
#
# Deep research report source (current shape):
# [None, [title, report_markdown], None, type, ...]
#
# Deep research report source (legacy shape):
# [None, title, None, type, None, None, [chunk1, chunk2, ...]]
#
# Notes:
# - The RPC returns all research tasks for the notebook, not just the latest one.
# - The client exposes all parsed tasks via an additive `tasks` field and keeps the
# top-level return value backward-compatible as the latest task.
# - For deep research, sources parsed from poll() carry `research_task_id`, which is
# later used by IMPORT_RESEARCH.
RPC: IMPORT_RESEARCH (LBwxtb)
Source: _research.py::import_sources()
Import selected research sources into the notebook.
# Build source array from selected sources
# Deep research imports prepend a special report entry before regular web sources.
source_array = []
# Deep research report entry:
source_array.append([
None, # 0
[title, markdown], # 1: Report title and full markdown body
None, # 2
3, # 3: Special report marker
None, # 4
None, # 5
None, # 6
None, # 7
None, # 8
None, # 9
3, # 10: Special report marker
])
# Standard web source entry:
source_array.append([
None, # 0
None, # 1
[url, title], # 2: URL and title
None, # 3
None, # 4
None, # 5
None, # 6
None, # 7
None, # 8
None, # 9
2, # 10: Standard web-source marker
])
params = [
None, # 0
[1], # 1: Fixed flag
task_id, # 2: Research task ID (for deep research, use the polled task ID)
notebook_id, # 3: Notebook ID
source_array, # 4: Array of sources to import
]
# Called with source_path:
await rpc_call(
RPCMethod.IMPORT_RESEARCH,
params,
source_path=f"/notebook/{notebook_id}",
)
# Response: Imported notebook sources with IDs
#
# Notes:
# - Deep research report preservation depends on importing the special report entry,
# not just the URL sources.
# - The browser/client flow uses the later polled deep-research task ID here rather
# than blindly reusing the original task ID returned by START_DEEP_RESEARCH.
RPC: CANCEL_RESEARCH (Zbrupe)
Source: _research.py::ResearchAPI.cancel()
Cancel an in-flight research (DiscoverSources) run. An IN_PROGRESS run
transitions to a terminal FAILED state shortly after this call; cancelling an
already-terminal run is a silent no-op.
params = [
None, # 0: optional client context — omitted (matches start/poll)
None, # 1
run_id, # 2: the poll-level run id (== ResearchTask.task_id from poll())
]
await rpc_call(
RPCMethod.CANCEL_RESEARCH,
params,
source_path=f"/notebook/{notebook_id}",
)
# Response: [] unconditionally.
#
# Notes (all LIVE-VERIFIED end-to-end against a scratch notebook):
# - Fire-and-forget. The server returns an empty payload and does NOT validate
# the run id (a garbage all-zeros id also returns []), so the response carries
# no success signal. ``cancel()`` returns None and never raises on an unknown
# id. Confirm by polling afterward — a cancelled IN_PROGRESS run reads FAILED
# within a few seconds; re-cancelling an already-terminal run is a silent no-op.
# - run_id is the poll-level id. For DEEP research that is the report_id from
# START_DEEP_RESEARCH: deep's task_id is a sessionId that POLL_RESEARCH reports
# as NOT_FOUND, and cancelling with it is a SILENT NO-OP (run keeps running) —
# only the report_id stops a deep run. For FAST research it is the task_id
# (fast returns no report_id). poll().task_id is the safe value for both modes.
# - notebook_id (source-path) is ROUTING ONLY, not a scoping/authorization
# boundary: a valid run_id is cancelled even when source-path names a different
# / non-existent / empty notebook. The run id alone identifies the run server-side.
User Settings
Global user settings that affect all notebooks in an account.
RPC: GET_USER_SETTINGS (ZwVcOc)
Source: _settings.py::get_output_language()
Get user settings including the current output language.
params = [
None, # 0
[1, None, None, None, None, None, None, None, None, None, [1]], # 1: Fixed config
]
# Live web UI/CDP capture on 2026-06-15 from notebook context:
params = [
[2, None, None, [1, None, None, None, None, None, None, None, None, None, [1]]],
]
# Called with root source_path:
await rpc_call(
RPCMethod.GET_USER_SETTINGS,
params,
source_path="/", # Global setting uses root path
)
# Response structure:
# [[
# null,
# [6, 500, 300, 500000], # [0][1]: Limits/quotas
# [true, null, null, true, ["ja"]], # [0][2]: Settings (language at [4][0])
# [[1]], # [0][3]: Unknown
# [true, 1, 3, 2] # [0][4]: Feature flags
# ]]
#
# Language code at: result[0][2][4][0]
# Notebook limit at: result[0][1][1]
# Source limit at: result[0][1][2]
RPC: SET_USER_SETTINGS (hT54vc)
Source: _settings.py::set_output_language()
Set user settings (currently used for output language).
Important: This is a GLOBAL setting that affects all notebooks in the account.
# Language code goes in a triple-nested structure
params = [
[[None, [[None, None, None, None, [language]]]]], # 0: Nested language config
]
# Called with root source_path:
await rpc_call(
RPCMethod.SET_USER_SETTINGS,
params,
source_path="/", # Global setting uses root path
)
# Response structure:
# [
# null,
# [6, 500, 300, 500000], # [1]: Limits
# [true, null, null, true, ["ja"]], # [2]: Updated settings (language at [4][0])
# ...
# ]
#
# Language code at: result[2][4][0]
Supported Languages:
Common language codes include:
en(English),ja(日本語),zh_Hans(中文简体),zh_Hant(中文繁體)ko(한국어),es(Español),fr(Français),de(Deutsch),pt_BR(Português)- See
cli/language_cmd.py::SUPPORTED_LANGUAGESfor the full list of 80+ languages
Artifact Management
RPC: RENAME_ARTIFACT (rc3d8d)
Source: _artifacts.py::rename()
Rename an artifact.
params = [
[artifact_id, new_title], # 0: Artifact ID and new title
[["title"]], # 1: Field mask (update title)
]
# Called with source_path:
await rpc_call(
RPCMethod.RENAME_ARTIFACT,
params,
source_path=f"/notebook/{notebook_id}",
)
RPC: EXPORT_ARTIFACT (Krh3pd)
Source: _artifacts.py::export_report(), _artifacts.py::export_data_table(), _artifacts.py::export()
Export an artifact to Google Docs or Sheets.
# Export types:
# 1 = Google Docs
# 2 = Google Sheets
params = [
None, # 0
artifact_id, # 1: Artifact ID
content, # 2: Content to export (optional, can be None)
title, # 3: Title for exported document
export_type, # 4: 1=Docs, 2=Sheets
]
# Called with source_path:
await rpc_call(
RPCMethod.EXPORT_ARTIFACT,
params,
source_path=f"/notebook/{notebook_id}",
)
# Response: Export result with document URL
RPC: REVISE_SLIDE (KmcKPe)
Source: _artifacts.py::revise_slide(),
_artifact/generation.py::ArtifactGenerationService.revise_slide(),
_artifact/payloads.py::build_revise_slide_params()
Revise one slide in an existing completed slide deck. slide_index is
zero-based and must be non-negative.
params = [
[2], # 0: Fixed flag
artifact_id, # 1: Slide deck artifact ID
[[[slide_index, prompt]]], # 2: Revision request
]
# Called with source_path:
await rpc_call(
RPCMethod.REVISE_SLIDE,
params,
source_path=f"/notebook/{notebook_id}",
allow_null=True,
)
Contract (ADR-0019 "async kickoff"): an accepted revision returns a standard
GenerationStatus parsed from the RPC response; a synchronous server refusal
propagates as RateLimitError / RPCError; a null result raises
ArtifactFeatureUnavailableError.
RPC: RETRY_ARTIFACT (Rytqqe)
Source: _artifacts.py::retry_failed()
Retry a failed Studio artifact in place — the equivalent of the NotebookLM web
UI "Retry" button. The failed artifact is not deleted first; the same
artifact_id is preserved and the artifact moves from failed back to
in_progress, so existing poll_status() / wait_for_completion() flows keep
working against it. Captured/validated across video, audio, and infographic
artifacts (issue #1319).
params = [
retry_options, # 0: fixed client capability blob (see below)
artifact_id, # 1: ID of the failed artifact to retry
]
# retry_options is a type-agnostic literal, sent verbatim regardless of
# artifact type. The trailing [[1, 4, 8, 2, 3, 6]] is a static
# artifact-type-code capability list, not artifact-specific.
retry_options = [
2,
None,
None,
[1, None, None, None, None, None, None, None, None, None, [1]],
[[1, 4, 8, 2, 3, 6]],
]
# Called with source_path:
await rpc_call(
RPCMethod.RETRY_ARTIFACT,
params,
source_path=f"/notebook/{notebook_id}",
allow_null=True,
)
Response: payload index 0 is a standard artifact row (positionally
identical to a LIST_ARTIFACTS row): row[0] is the same artifact_id
(returned as the task id) and row[4] == 1 (PROCESSING → in_progress).
Contract (ADR-0019 "async kickoff"): an accepted retry returns
GenerationStatus(status="in_progress"); a synchronous server refusal
(USER_DISPLAYABLE_ERROR — rate limit, quota, or non-retryable artifact)
raises the underlying RateLimitError / RPCError; a null / missing-id
result raises ArtifactFeatureUnavailableError. A retry may still fail again
provider-side — observed by polling as a later terminal failed status — so
callers decide whether to re-invoke.
RPC: SHARE_ARTIFACT (RGP97b)
Source: _sharing_manager.py::ShareManager.share() (legacy share-link toggle)
Toggle the legacy share-link state for a notebook URL, optionally with an
artifact deep-link target. Distinct from SHARE_NOTEBOOK (QDyure), which
governs notebook visibility and user permissions.
Note: Mind Maps are NOT shareable (they don't have public URLs).
# share_options: [1] for public, [0] for private
# Payload shape is conditional on artifact_id:
# - Without artifact_id: 2-tuple [share_options, notebook_id]
# - With artifact_id (truthy): 3-tuple [share_options, notebook_id, artifact_id]
# Sending a third positional element when artifact_id is None/empty changes the
# wire payload, so callers MUST omit it rather than pass null.
share_options = [1] if public else [0]
if artifact_id:
params = [share_options, notebook_id, artifact_id]
else:
params = [share_options, notebook_id]
# Called with source_path:
await rpc_call(
RPCMethod.SHARE_ARTIFACT,
params,
source_path=f"/notebook/{notebook_id}",
)
# Share URL format:
# - Notebook: https://notebooklm.google.com/notebook/{notebook_id}
# - Artifact deep-link: https://notebooklm.google.com/notebook/{notebook_id}?artifactId={artifact_id}
Important: The ?artifactId=xxx URL is a deep link - it opens the shared notebook and navigates to that artifact. The artifact itself isn't independently shared.
RPC: GET_INTERACTIVE_HTML (v9rmvd)
Source: _artifact/downloads.py::_get_artifact_content() (quiz/flashcard HTML), _artifact/downloads.py::_get_interactive_mind_map_tree() (interactive mind-map tree)
Fetch the interactive payload for a studio artifact. Used both for quiz/flashcard
HTML and for the interactive mind-map JSON node tree (issue #1256) — the same
RPC, but the two kinds read different cells of index 9.
params = [artifact_id] # Just the artifact ID
# Called with source_path:
await rpc_call(
RPCMethod.GET_INTERACTIVE_HTML,
params,
source_path=f"/notebook/{notebook_id}",
)
# Response structure:
# [[
# ..., # indices 0-8: metadata
# [ # index 9: interactive content array
# html_content, # [9][0]: rendered HTML body (quiz / flashcard)
# ...,
# ...,
# tree_json, # [9][3]: interactive mind-map {"name","children"} tree (JSON string)
# ],
# ...
# ]]
#
# Quiz/flashcard download reads [0][9][0] (HTML → JSON/Markdown/HTML).
# Interactive mind-map download reads [0][9][3] (the JSON node tree).
RPC: GET_SUGGESTED_REPORTS (ciyUvf)
Source: _artifacts.py::suggest_reports()
Get AI-suggested report formats based on notebook content.
params = [
[2], # 0: Fixed flag (same pattern as LIST_ARTIFACTS)
notebook_id, # 1: Notebook ID
]
# Called with source_path:
await rpc_call(
RPCMethod.GET_SUGGESTED_REPORTS,
params,
source_path=f"/notebook/{notebook_id}",
)
# Response structure:
# [[
# [title, description, None, None, prompt, audience_level],
# ...
# ]]
#
# Example response item:
# ["Research Paper", "An academic paper analyzing...", None, None,
# "Write a research paper for an academic audience...", 2]
#
# audience_level: 1=Beginner, 2=Intermediate, 3=Advanced
Note: This is the dedicated RPC method for getting suggested report formats. Previously ACT_ON_SOURCES with "suggested_report_formats" command was attempted but it doesn't work correctly.
Operation Timing Categories
Quick Operations
Most operations complete nearly instantly:
- Notebook operations: list, create, rename, delete
- Source metadata: list, rename, delete
- Note operations: create, update, delete
- Chat configuration
- Artifact listing
Processing Operations
These require backend processing - wait for completion:
- Add source (URL): Network fetch + text extraction
- Add source (file): Upload + parsing
- Add source (YouTube): Transcript extraction
- Mind Map generation: Usually faster than other generation types
Generation Operations
AI-generated content takes significant time:
- Audio Overview: Several minutes
- Video Overview: Several minutes (longer than audio)
- Reports/Study Guides: 1-2 minutes
- Quiz/Flashcards: 1-2 minutes
- Infographic/Slide Deck/Data Table: 1-2 minutes
Long-Running Operations
Some operations can run much longer:
- Deep Research: Can take many minutes depending on query complexity
Implementation Note
When automating, poll for completion rather than using fixed timeouts. Check artifact status or source processing state periodically.
Legacy/Unused RPC Methods
These RPC method IDs exist in rpc/types.py but are either legacy (superseded by other methods) or not currently used in the implementation. Documented here for completeness.
| RPC ID | Method | Status | Notes |
|---|
Note:
GET_SOURCE(hizoJc) was previously listed here as "Broken" but is now active — used by_source/content.py::get_fulltext(). See RPC Method Status and the detailed section above.
Why keep these? These IDs are preserved in the codebase in case:
- Google re-enables or changes their functionality
- Future reverse-engineering reveals their purpose
- They become useful for specific edge cases
Note: The unified CREATE_ARTIFACT (R7cb6c) method handles all artifact generation (audio, video, reports, quizzes, etc.).