A technical narrative of how CosySim learned to talk to NotebookLM, Gemini, AI Studio, GitHub Copilot, and Google Workspace — without a single official API.
Version: v2.0 [2026-03-23] Author: Knack + Claude Code
CosySim is a local-first AI simulation framework. It needed to connect to frontier AI models — but not through official SDKs. The goal was to build a unified pipeline where local agents could query NotebookLM for grounded research, Gemini for generation, AI Studio for embeddings, GitHub Copilot for 38 frontier models, and LMStudio for local inference — all through a single interface.
None of these services had public APIs for what we needed. NotebookLM has no API at all. Gemini's internal protocol is undocumented. GitHub Copilot's model access isn't meant for programmatic use. So we reverse-engineered everything.
This is the story of how.
It started with Chrome DevTools. Open NotebookLM in the browser, open the Network tab, use the app, export a HAR file. Inside that HAR file: every HTTP request the browser made.
The pattern was immediately obvious. Every operation — creating a notebook, adding a source, asking a question — went through a single endpoint:
POST https://notebooklm.google.com/_/LabsTailwindUi/data/batchexecute
The request body was URL-encoded with a parameter called f.req containing a JSON array:
[[["wXbhsf", "[null,1,null,[2]]", null, "generic"]]]That 6-7 character string (wXbhsf) is an rpcid — Google's internal operation identifier. Different rpcid = different operation. The second element is the payload, JSON-encoded as a string. The response comes back in a custom format:
)]}'
[["wrb.fr","wXbhsf","[...response_json...]",null,null,null,"generic"]]
Strip the )]}'\n JSONP safety prefix, parse the outer array, find the wrb.fr items, extract the JSON string at position [2], parse that. Five layers of decoding for every response.
We had our first breakthrough: batchexecute is Google's universal internal RPC framework. The same protocol powers Docs, Sheets, Drive, Gemini, NotebookLM, and Apps Script. Different service, different rpcids, same wire format.
The browser sends a wall of cookies with every request. Through trial and error, we identified which ones matter:
- SID, SSID, HSID, APISID — core Google session identifiers
- SAPISID, __Secure-3PAPISID — used to compute the SAPISIDHASH anti-abuse header
- __Secure-3PSID, __Secure-3PSIDTS — same-site secure session variants
The SAPISIDHASH computation was the first real reverse-engineering win:
timestamp = str(int(time.time()))
raw = f"{timestamp} {SAPISID_cookie} https://notebooklm.google.com"
hash_value = hashlib.sha1(raw.encode()).hexdigest()
header = f"SAPISIDHASH {timestamp}_{hash_value}"But then a critical discovery: We were adding SAPISIDHASH to batchexecute calls and getting HTTP 400 errors. After comparing our requests to actual browser HAR traffic side-by-side, we realized: NotebookLM batchexecute does NOT use SAPISIDHASH. It authenticates purely via cookies + the at CSRF token in the POST body. Adding the extra header broke it. (Other Google services like Colab and Sheets DO use SAPISIDHASH — it's per-service.)
Every batchexecute call requires a bl (build label) parameter in the URL query string:
?bl=boq_labs-tailwind-frontend_20260226.08_p0
This is the frontend deploy version. Google pushes new frontend builds roughly weekly. When the build label changes, all requests with the old label silently return null. No error, no 400, no 401 — just empty responses. This was maddening to debug the first time it happened. Everything was "working" but returning nothing.
We also need f.sid (server session ID) and at (anti-CSRF token), both extracted from a JavaScript object called WIZ_global_data embedded in the page HTML. Google obfuscates the key names — we found the session ID under keys like IxjpMA or FdrFJe, and the CSRF token under SNlM0e. These change with each page load.
Over several sessions, we analyzed 8+ HAR files and mapped rpcids to operations:
| RPC ID | Operation | How We Figured It Out |
|---|---|---|
wXbhsf |
List sources | Fired when opening a notebook |
rLM1Ne |
Load notebook | Fired on notebook navigation |
ub2Bae |
List notebooks | Fired on homepage load |
CYK0Xb |
Create note (Q&A) | Fired when asking a question |
VfAZjd |
AI summary | Fired when requesting a study guide |
izAoDd |
Add source | Fired when adding a URL |
tGMBJ |
Delete source | Fired when removing a source |
ozz5Z |
Feature flags | Fired on app initialization |
sqTeoe |
Audio overview types | Fired in audio panel |
We eventually cataloged 49 NotebookLM rpcids and 36 Gemini rpcids.
On 2026-02-28, a fresh manual HAR capture (manual_testing.har) revealed a catastrophic error in our v2.x RPC mappings. We had been using s0tc2d as the chat message rpcid. It's not. It's RENAME_NOTEBOOK.
WRONG (v2.x):
s0tc2d = RPC_CHAT_MESSAGE ← WRONG
sqTeoe = "list all notebooks" ← WRONG
cFji9 = "conversation history" ← WRONG
CORRECT (v3.0+):
s0tc2d = RPC_RENAME_NOTEBOOK ← Actually renames
sqTeoe = RPC_LIST_AUDIO_TYPES ← Audio overview options
cFji9 = RPC_MIND_MAP ← Mind map / sync notes
Sixteen rpcids were wrong. We had been sending rename requests thinking we were chatting.
The biggest discovery from this correction: real NLM chat doesn't use batchexecute at all. It uses a gRPC-web streaming endpoint:
POST https://notebooklm.google.com/_/LabsTailwindUi/data/
google.internal.labs.tailwind.orchestration.v1.
LabsTailwindOrchestrationService/GenerateFreeFormStreamed
The payload is a 9-element array:
[source_ids, question, history, [2, None, [1], [1]], thread_id, None, None, notebook_id, 1]The response streams progressively — full text at each chunk, not deltas. This was a completely different protocol from batchexecute.
After the rpcid corrections, we had a working batchexecute client for source management (add, delete, list) but chat required the gRPC streaming endpoint. The problem: calling that endpoint server-side required headers that only the browser adds automatically (x-browser-validation, specific cookie scoping).
Solution: inject JavaScript directly into the browser via Chrome DevTools Protocol.
This was the proof-of-concept. Connect to Chrome on port 9223 via WebSocket, inject a fetch() call, let the browser handle auth:
# 1. Connect to Chrome
tabs = requests.get("http://localhost:9223/json").json()
nlm_tab = next(t for t in tabs if "notebooklm.google.com" in t["url"])
# 2. Connect via WebSocket
ws = websocket.create_connection(nlm_tab["webSocketDebuggerUrl"])
# 3. Extract session tokens from page
ws.send(json.dumps({
"id": 1, "method": "Runtime.evaluate",
"params": {"expression": "JSON.stringify(window.WIZ_global_data)"}
}))
# Parse out bl, f_sid, at from the response
# 4. Inject fetch() — browser adds all auth automatically
js = f"""
fetch('https://notebooklm.google.com/_/LabsTailwindUi/data/batchexecute?...', {{
method: 'POST',
credentials: 'include', // ← Browser adds cookies automatically
body: 'f.req=...'
}}).then(r => r.text())
"""
ws.send(json.dumps({
"id": 2, "method": "Runtime.evaluate",
"params": {"expression": js, "awaitPromise": True}
}))This worked. The browser handles cookies, CORS, and all Google's anti-automation headers. We just needed to inject the right fetch call.
The streaming response from GenerateFreeFormStreamed comes back in wrb.fr chunks. Each chunk has different structure — some contain text, some contain status codes, some contain metadata. This script attached to a running NLM tab via CDP, injected a chat request, and printed every chunk with its structure annotated. This is how we learned:
- Text lives in nested arrays at varying depths
- Status/error info is at position [5] in the wrb.fr item
- Gemini "thinking" traces appear as bold headers (
**Analyzing...**) before the real answer - The final answer must be extracted by skipping thinking traces
After the v2.x → v3.1 crisis, it was clear that manual HAR analysis wouldn't scale. Google rotates rpcids with every frontend deploy (~weekly). We needed automated discovery.
ARGUS (Automated Reconnaissance & Google Universal Surveyor) was built as a living API intelligence platform with multiple signal sources:
1. Network Traffic Capture (CDP)
Network.enableon Chrome tabs to intercept all HTTP/HTTPS- Real-time rpcid extraction from batchexecute URLs and payloads
- Compare against known baselines — new rpcid = discovery event
2. Heap Snapshot Diffing
- Take V8 heap snapshot BEFORE an action
- Perform the action (click button, navigate)
- Take heap snapshot AFTER
- Diff the string tables — new strings matching rpcid pattern = discovered
- Can find rpcids triggered internally but never sent over the network
3. Playwright UI Crawlers
- Automated crawlers for NotebookLM (14 flows), Gemini (10 flows), AI Studio (15 flows)
- Drive every UI feature to trigger all endpoints
- Each flow: screenshot → action → capture traffic → diff heaps
4. HAR File Mining
- Batch processor for imported HAR captures
- Deduplicates by MD5 content hash
- Extracts all batchexecute + gRPC-web calls
- Saves first-seen payload examples per rpcid
5. Bundle Analysis
- Scan JS bundles for embedded proto field name→number mappings
- Pattern:
fieldNumber: N, name: 'field_name' - Feeds into proto reconstruction
For each target (NLM, Gemini, AI Studio):
1. Start network monitor (CDP)
2. Launch Playwright crawler
3. For each UI flow:
a. Heap snapshot BEFORE
b. Perform action
c. Drain captured traffic
d. Heap snapshot AFTER
e. Diff heaps → new rpcids
f. Decode traffic (batchexecute + gRPC-web)
g. Register discoveries in endpoint registry
h. Store in Nexus knowledge base
4. Probe feature flags (ozz5Z rpcid, IDs 300-1500)
5. Rebuild .proto files from accumulated field data
6. Generate diff report (pre-scan vs post-scan)
When Google's Gemini v2 migration broke the chat rpcids, we built a progression of increasingly sophisticated capture scripts:
argus_chat_capture.py — The quick-and-dirty version:
- Spawn Chrome with injected cookies
- Navigate to a notebook
- Inject a question into the textarea (Angular-safe: use native property setter + event dispatch)
- Capture 20 seconds of traffic
- Extract new rpcid + payload format
argus_live_chat.py — More robust, with Angular bypass:
// Angular's change detection doesn't see programmatic value changes.
// Must use the native setter to trigger Angular's model binding:
Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype, 'value'
).set.call(textarea, question);
textarea.dispatchEvent(new InputEvent('input', {bubbles: true, inputType: 'insertText'}));argus_chat_probe.py — Production-grade with CLI args, structured JSON output, and response body capture. Designed for integration into the scheduler.
ARGUS doesn't just capture — it distills discoveries into knowledge:
- Build markdown document from endpoint registry (all rpcids, methods, coverage stats)
- Upload to a persistent NotebookLM notebook as a source
- Ask 40+ targeted questions about the API surface
- Store all Q&A pairs in Nexus knowledge base under
category=argus
This creates a searchable knowledge layer that agents can query: "What rpcid handles source deletion?" → instant answer from Nexus.
Google merged NotebookLM + Sheets + Drive + AI Studio + Gemini + Docs into a unified "Gemini v2 Workspace" surface. This caused:
- 25 of 49 NLM rpcids stopped working (rotated or removed)
- Payload format changed for surviving rpcids
- f.sid became session-scoped — headless browser profiles couldn't access notebook content
- Source IDs became session-scoped — IDs from one session were invalid in another
From HAR entry #68 of notebooklm_knack112358-questions-asked.har:
- Chat requests have NO rpcid in the URL — they go directly to the gRPC streaming endpoint
- Error
[16]means source IDs are invalid/stale for the current session - The
GenerateFreeFormStreamedendpoint still works — only batchexecute rpcids rotated
We built a unified auth recovery system (cdp_auth_recovery.py) that:
- DETECT — Check if Chrome is running on port 9222
- INJECT — Open a disposable tab, inject saved cookies via
Network.setCookie - NAVIGATE — Go to NotebookLM, verify login (title check, no signin redirect)
- EXTRACT — Pull session tokens from
WIZ_global_dataviaRuntime.evaluate - HARVEST — Fresh cookies from the tab session
- SAVE — Cookies to
nlm_cookies.json, tokens tonlm_meta.json - SYNC — Push to GoogleAccountPool for ARGUS/crawler reuse
- VALIDATE — Test each API key against Gemini embedding endpoint
- HARVEST KEYS — If dead, intercept AI Studio network traffic for fresh keys
- UPDATE — Write new keys to config
The key insight: use a disposable Chrome tab (opened fresh, closed after) so the user's actual browsing session is never disrupted.
When API keys die (Google rotates them), the recovery system:
- Navigates to
https://aistudio.google.com/app/apikeyvia CDP - Monitors network traffic for responses from
alkalimakersuite-pa.clients6.google.com - Regex extracts keys:
AIza[a-zA-Z0-9_\-]{35} - Tests each against the Gemini embedding endpoint
- Keeps only working keys
By this point we had access to:
- NotebookLM via batchexecute + gRPC streaming (cookies + CSRF)
- Gemini via batchexecute (cookies + SAPISIDHASH)
- AI Studio via gRPC-web (API key + SAPISIDHASH)
- GitHub Copilot via REST API (GitHub Bearer token, refreshed hourly)
- LMStudio via REST v1 API (optional Bearer token)
- Google Colab via gRPC (SAPISIDHASH)
Five different protocols, five different auth mechanisms. Agents shouldn't need to care about any of this.
One script to query any model:
ask.py "What is X?" --model claude-opus-4.6 # → GitHub Copilot (38 models)
ask.py "What is X?" --nlm # → NotebookLM (Gemini, grounded)
ask.py "What is X?" --local # → LMStudio (local inference)
ask.py --models --vendor anthropic # → List available modelsRoutes to the right backend, handles auth, returns a clean answer. Model aliases: opus, sonnet, haiku, gpt5, gpt, codex, gemini, flash, grok.
For tools that speak OpenAI protocol (Cursor, Continue, aider, etc.):
GET http://localhost:5800/v1/models → List all models
POST http://localhost:5800/v1/chat/completions → Chat (streaming & non-streaming)
Takes OpenAI format in, routes to the right backend, returns OpenAI format out. Supports SSE streaming. Any tool that can talk to OpenAI can now talk to Claude, GPT-5, Gemini, Grok, or local models.
The simplest and most reliable NLM integration. Attaches to a running Chrome tab with NotebookLM open, extracts session tokens, injects a fetch() call, and returns the answer:
python scripts/nlm_ask.py "What are the key findings?"The browser handles all auth. We just inject the question and read the response. Handles Gemini thinking traces (skips **Bold headers**), extracts the final answer, 90-second timeout for complex queries.
Two backends for NotebookLM, automatically routed:
- batchexecute (direct HTTP) — fast, good for source management, breaks when rpcids rotate
- Node.js MCP Bridge (Patchright) — slow but stable, handles real chat via browser automation
The hybrid router (nlm_hybrid.py) tries batchexecute first, falls back to Node bridge if it fails. Chat always goes to the Node bridge (batchexecute chat doesn't work).
The CDP browser injection approach (Chapter 3) works, but it requires Chrome to be running for every API call. For a production system that runs 24/7, you don't want to depend on a browser being open. The goal: make server-side HTTP calls that Google's servers can't distinguish from a real Chrome browser.
Through months of HAR analysis and trial-and-error, we identified exactly which headers Google validates and which it ignores. The complete header set for a Chrome-free batchexecute client:
headers = {
# ── Standard HTTP ────────────────────────────────────────
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
"User-Agent": ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/145.0.0.0 Safari/537.36"),
"Referer": "https://notebooklm.google.com/",
"Origin": "https://notebooklm.google.com",
# ── CORS compliance (required — request rejected without these) ──
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-origin",
# ── Chrome identity (faked — server validates these) ─────
"sec-ch-ua": '"Not:A-Brand";v="99", "Google Chrome";v="145", "Chromium";v="145"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"Windows"',
"x-browser-channel": "stable",
"x-browser-year": "2026",
# ── Anti-XSRF (required) ─────────────────────────────────
"X-Same-Domain": "1",
# ── Privacy (optional but looks more real) ───────────────
"DNT": "1",
# ── Auth (from cookies, NOT SAPISIDHASH) ─────────────────
"Cookie": "SID=...; SSID=...; APISID=...; __Secure-3PSID=...; ...",
}SAPISIDHASH — the biggest gotcha. Other Google APIs (Maps, Docs, Colab, Sheets, Drive) require an Authorization: SAPISIDHASH <timestamp>_<sha1> header. We initially added it to NLM calls because every other Google API uses it. It causes HTTP 400, error code 3. NotebookLM batchexecute authenticates ONLY via Cookie + the at CSRF token in the POST body. This took days to figure out — the error response is opaque, and every instinct said "add more auth headers."
The HAR comparison that cracked it: we recorded a real Chrome session, exported the HAR, and diff'd our request headers against the browser's. The browser never sends Authorization to the batchexecute endpoint. That was the moment.
Empty at token — causes 403. If the CSRF token isn't available yet (first page load), you must omit the at parameter entirely from the POST body. Sending at= (empty string) triggers a 403 Forbidden. This is different from most CSRF implementations that accept empty tokens.
The Chrome MCP capture (2026-03-23) revealed headers that only a real browser generates:
x-browser-validation: OsQr7VAWzRcWhg0pyQAkUi0ayRw= ← cryptographic, changes per request
x-browser-copyright: Copyright 2026 Google LLC ← static but Chrome-binary-embedded
x-client-data: CIe2yQEIpbbJAQipncoBCM7eygEI... ← Chrome variation/experiment flags
x-goog-ext-353267353-jspb: [null,null,null,282611] ← Google internal extension data
We fake x-browser-channel and x-browser-year — these are static strings that don't change per-request, and Google accepts our fakes. The x-browser-validation is cryptographic and per-request — we don't send it, and Google still accepts the request. This means it's likely used for telemetry/analytics, not auth enforcement.
Bottom line: The Chrome-free client works for all batchexecute operations. The only thing it can't do is GenerateFreeFormStreamed (gRPC chat) which requires the x-browser-validation header — for that, we use the Chrome MCP approach or the Node.js bridge.
The POST body is URL-encoded with two parameters:
f.req=<url_encoded_triple_nested_json>&at=<csrf_token>
The f.req structure (this took MANY attempts to get right):
# CORRECT (confirmed from HAR — triple-nested)
f_req = [[[rpc_id, args_json, None, "generic"]]] # Three levels of nesting
# WRONG (causes HTTP 400)
f_req = [[rpc_id, args_json, None, "generic"]] # Two levels — REJECTED
f_req = [rpc_id, args_json, None, "generic"] # One level — REJECTEDThe third element (None) and fourth element ("generic") are required padding. We don't know what they mean, but omitting them causes rejection. Every HAR capture shows them.
For multi-RPC batching (up to ~10 calls per request):
f_req = [[[rpc_id_1, args_1, None, "generic"],
[rpc_id_2, args_2, None, "generic"],
[rpc_id_3, args_3, None, "generic"]]]https://notebooklm.google.com/_/LabsTailwindUi/data/batchexecute
?rpcids=VfAZjd;CYK0Xb # semicolon-separated rpcid list
&source-path=/notebook/<uuid> # notebook context (critical!)
&bl=boq_labs-tailwind-frontend_20260319.10_p0 # build label
&f.sid=-6520081273601444256 # server session ID
&hl=en # language
&_reqid=357556 # request counter
&rt=c # response type: chunked wrb.fr
source-path is critical. Without the correct notebook UUID here, source-scoped RPCs (LIST_SOURCES, CREATE_NOTE, SAVE_NOTE, etc.) silently return null. For account-level RPCs (LIST_NOTEBOOKS, USER_QUOTA), use source-path=/.
This is the 9-element array for real conversational chat, confirmed from HAR entry #68 (March 2026 deployment):
inner = [
# [0] Source context — every source UUID triple-nested
[[[source_id_1]], [[source_id_2]], [[source_id_3]], ...],
# [1] The question text
"What are the key findings?",
# [2] Conversation history (previous turns)
# Format: [[prev_answer, null, 2], [prev_question, null, 1], ...]
# Empty list [] for first message
[[previous_answer_text, None, 2],
[previous_question_text, None, 1]],
# [3] Response config
[2, None, [1], [1]], # tier=2 (Pro), include citations, include thinking
# [4] Thread UUID (for conversation continuity)
"thread-uuid-here", # or None for new conversation
# [5] Reserved (always None)
None,
# [6] Reserved (always None)
None,
# [7] Notebook UUID
"c3165bf5-b1a1-40e8-8f1f-2008234987b3",
# [8] Request type flag (always 1)
1,
]
# Wrapped as: f.req=[null, json.dumps(inner)]
# Plus: &at={csrf_token} in POST bodyEvery batchexecute response follows this structure:
Layer 1: XSSI prefix )]}' ← strip this first
Layer 2: Size hint 1053 ← byte count for next block
Layer 3: wrb.fr frame [["wrb.fr", "rpcid", "<inner_json>", null, null, null, "generic"]]
Layer 4: Inner JSON [[answer_id, "markdown with [citation_uuid] markers"]]
Layer 5: Citation UUIDs Extract via regex: [a-f0-9]{8}-[a-f0-9]{4}-...-[a-f0-9]{12}
For streaming responses (GenerateFreeFormStreamed), each chunk contains the FULL answer text so far — not deltas. Use the last chunk's text as the complete answer.
Different source types use different positions in the source object array — getting this wrong causes silent failures (empty source created, no error):
# Regular URL → position [2] as a string
source_obj = [None, None, "https://example.com", None, None, None, None, None, None, None, 1]
# ↑ position 2
# YouTube URL → position [7] as a LIST (not a string!)
source_obj = [None, None, None, None, None, None, None, ["https://youtube.com/watch?v=xyz"], None, None, 1]
# ↑ position 7, wrapped in list
# Text source → position [1] as [title, content], position [3] = 3
source_obj = [None, ["Title", "Content..."], None, 3, None, None, None, None, None, None, 1]
# ↑ position 1 ↑ format type 3 = textUsing position 2 for YouTube or position 7 for regular URLs creates an empty source with no error message. This took considerable debugging to discover.
On 2026-03-23, we discovered that Chrome DevTools has an official MCP server (chrome-devtools-mcp). This replaces the entire CDP WebSocket nightmare with clean, standard MCP tool calls.
# Async websockets library (NOT the sync websocket library — CORS blocks it)
async with websockets.connect(tab['webSocketDebuggerUrl'], max_size=50*1024*1024) as ws:
await ws.send(json.dumps({"id": 1, "method": "Network.enable", "params": {}}))
while True:
r = json.loads(await asyncio.wait_for(ws.recv(), 30))
if r.get("id") == 1: break
# ... fight with CORS, async, timeouts, port mismatches ...Problems: CORS 403 errors with sync library, port confusion (9222 vs 9223), async-only, no response body access, custom event loop needed.
{
"mcpServers": {
"chrome": {
"command": "npx",
"args": ["chrome-devtools-mcp@latest", "--browserUrl", "http://127.0.0.1:9223", "--no-usage-statistics"]
}
}
}Then just call MCP tools:
take_snapshot → Full accessibility tree with element UIDs
fill(uid, value) → Type into any input (Angular-safe)
click(uid) → Click any button
evaluate_script(fn) → Run JS in page context
list_network_requests → All network traffic with filtering
get_network_request → Full request/response with headers, cookies, body
take_screenshot → Visual capture to disk
navigate_page → Go to URL
press_key → Keyboard input
In one get_network_request call on the NLM chat endpoint, we extracted:
- Full request headers — including
x-browser-validation(cryptographic, per-request),x-client-data(Chrome experiment flags), andx-goog-ext-353267353-jspb(Google internal extension data) - Complete cookie string — every Google auth cookie, fresh, with exact domain/path/expiry
- The exact f.req payload — all 39 source UUIDs, the question, full conversation history
- Response headers — including fresh
Set-Cookiewith rotated SIDCC tokens - 794KB response body — saved to disk, the complete gRPC streaming response
All without a single line of WebSocket code.
| Aspect | Chrome-Free Client | Chrome MCP |
|---|---|---|
| Requires Chrome | No (only for auth refresh) | Yes (must be running) |
| Auth source | Cookies from disk (nlm_cookies.json) |
Live browser session |
| Speed | Fast (~200ms per call) | Medium (~500ms, MCP overhead) |
| batchexecute | Full support | Full support |
| GenerateFreeFormStreamed | Works but missing x-browser-validation |
Full support (browser sends it) |
| Auth refresh | CDP recovery or HAR import | Automatic (browser manages) |
| Best for | Production, scheduled tasks, batch ops | ARGUS discovery, live debugging, auth capture |
| Setup | Zero — just needs cookies on disk | npx chrome-devtools-mcp + Chrome |
The production architecture uses BOTH:
- Chrome-free client for all batchexecute operations (fast, no browser dependency)
- Chrome MCP or Node.js bridge for gRPC chat (needs browser headers)
- CDP auth recovery refreshes cookies periodically (every 15 minutes via scheduler)
Through ARGUS discovery, we found that Google uses the exact same batchexecute protocol across multiple services. We call this the WIZ pattern (after the WIZ_global_data JavaScript object that contains session tokens):
| Service | Endpoint | Auth | Status |
|---|---|---|---|
| NotebookLM | notebooklm.google.com/_/LabsTailwindUi/data/batchexecute |
Cookie + at CSRF |
Production |
| Gemini | gemini.google.com/_/BardChatUi/data/batchexecute |
Cookie + at CSRF |
Production |
| Opal | opal.google.com/_/Opal/data/batchexecute |
Cookie + at CSRF |
Experimental |
| Colab | colab.clients6.google.com/... |
Cookie + SAPISIDHASH | Production |
| Sheets/Drive | clients6.google.com/... |
Cookie + SAPISIDHASH | Production |
The key difference: NLM, Gemini, and Opal do NOT use SAPISIDHASH. Colab, Sheets, and Drive DO. This is a per-service decision by Google's auth team, not a protocol-level difference.
Discovered 5 new rpcids from gemini.google.com-NEWEST.har:
| rpcid | Operation | Notes |
|---|---|---|
HcT8bb |
List Storybook Gems | Creative workspace |
XqA3Ic |
Get Storybook Detail | Individual gem content |
ZKcapf |
List Saved Info | Bookmarks/saved items |
jGArJ |
List My Content | /mystuff page |
sJBwce |
Get Subscription Tiers | Pro/Ultra plan details |
Gemini's gRPC streaming uses BardFrontendService/StreamGenerate instead of NLM's LabsTailwindOrchestrationService/GenerateFreeFormStreamed.
A new experimental creative workspace at opal.google.com. Uses the same WIZ batchexecute pattern. Shares credentials with NLM via data/nlm_meta.json — once you authenticate with NLM, Opal works automatically.
ARGUS isn't a single script — it's a multi-layer intelligence platform:
Orchestrator (scripts/argus/orchestrator.py)
├── NetworkMonitor — CDP-based traffic capture (all tabs)
├── NLMCrawler — 13 UI flows for NotebookLM
├── GeminiCrawler — 10 UI flows for Gemini
├── AIStudioCrawler — 15 UI flows for AI Studio
├── BatchExecuteDecoder — f.req/wrb.fr parsing
├── GrpcWebDecoder — Binary proto + JSON frame parsing
├── HeapDiffer — V8 heap snapshot string table diffing
├── FeatureFlagProber — ID range scanning (300-1500)
├── ProtoReconstructor — Build .proto files from wire data
├── EndpointRegistry — Versioned discovery storage
├── ApiDocGenerator — Auto-generate API reference docs
└── NexusSink — Store discoveries in Nexus knowledge base
The registry (data/argus/registry.json) is a versioned, diffable store of all discovered API endpoints:
{
"schema": "2.0",
"nlm_rpcids": {"AUrzMb": {"name": "Analytics", "seen": 3, "last": "2026-03-23"}},
"gemini_rpcids": {...},
"aistudio_methods": {...},
"nlm_grpc_methods": {...},
"heap_discovered": {...},
"unknown_endpoints": {...},
"runs": [{"ts": "2026-03-23", "new_rpcids": ["AUrzMb"], "duration_s": 529.7}]
}The baseline is YAML-driven (config/nlm_rpcids.yaml), not hardcoded. When Google rotates rpcids, update the YAML and the entire SDK, transport layer, and ARGUS baseline update automatically.
Each crawler systematically exercises every UI feature to trigger all possible API calls:
NLM Crawler (13 flows):
- List notebooks →
ub2Bae - Open notebook →
rLM1Ne,wXbhsf,e3bVqc,gArtLc,hPTbtc,sqTeoe - Send chat →
GenerateFreeFormStreamed - Get history →
GzgSEd - Generate study guide →
xqEXEf - Generate FAQ →
xqEXEf(variant payload) - Generate briefing →
xqEXEf(variant payload) - Audio overview →
sqTeoe - Notebook analysis →
VfAZjd - Add text source →
izAoDd - List sources →
wXbhsf - Feature flags →
ozz5Z - Create/delete notebook →
CCqFvf,WWINqb
Chrome DevTools has a Protocol Monitor (Settings > Experiments > Protocol Monitor) that captures all CDP messages. The Save button exports them as JSON. We built an importer (scripts/argus/importers/protocol_monitor.py) that:
- Parses the exported JSON (array of CDP messages)
- Extracts
Network.requestWillBeSentevents - Filters for
LabsTailwind/batchexecuteURLs - Decodes
f.reqpayloads (URL-encoded → JSON → rpcid + args) - Extracts cookies, session tokens, gRPC method names
- Merges discoveries into the ARGUS endpoint registry
The HAR watchfolder (scripts/har_watchfolder.py) auto-detects .json files alongside .har files and routes them through this importer.
Google session cookies expire. API keys rotate. Build labels change weekly. The system needs fresh credentials continuously.
har_watchfolder.py is the background daemon that closes this loop:
Drop HAR file into data/hars/
↓
Watchfolder detects new file (polls every 30s)
↓
Extracts Google session cookies
↓
Updates GoogleAccountPool
↓
Probes NLM + Colab to verify auth works
↓
Moves HAR to imported/ (or failed/)
↓
Logs event to Nexus for audit trail
Commands:
python scripts/har_watchfolder.py watch # Start polling daemon
python scripts/har_watchfolder.py import file.har # Import single file
python scripts/har_watchfolder.py health # Probe all accounts
python scripts/har_watchfolder.py status # Show cookie agesCombined with the CDP auth recovery (runs every 15 minutes via scheduler), the system maintains fresh credentials automatically. Drop a HAR file, and within 30 seconds every service has fresh auth.
-
batchexecute is Google's universal internal RPC framework — same wire format across Docs, Sheets, Drive, Gemini, NotebookLM, Apps Script, Opal. Learn it once, access everything. We call this the WIZ pattern.
-
SAPISIDHASH is per-service — NotebookLM, Gemini, and Opal don't use it (cookies + CSRF only). Colab, Sheets, and Drive DO use it. Adding it where it's not expected causes 400 errors. This per-service auth decision took days to discover.
-
Build labels expire silently — no error, just empty responses. This is the #1 cause of "everything was working yesterday" failures. No HTTP error code, no 401 — just null data in a 200 response.
-
Real chat uses gRPC streaming, not batchexecute. The
GenerateFreeFormStreamedendpoint returns progressive full text, not deltas. The rpcid for chat rotated fromtJHFsftoBgzyjcduring the Gemini v2 migration (confirmed 2026-03-23). -
Source IDs and session IDs are scoped — after the Gemini v2 migration, IDs from one session don't work in another. You need fresh session context.
-
Angular textarea injection requires the native property setter —
element.value = xdoesn't trigger Angular's change detection. Must useObject.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set.call(element, x)followed byInputEventdispatch. -
f.req MUST be triple-nested —
[[[rpc, args, null, "generic"]]]with three levels. Two levels causes 400. This took many failed attempts to get right. -
Empty
at=causes 403 — unlike most CSRF implementations, NLM rejects blank tokens. Omit the parameter entirely if you don't have it. -
Chrome's
x-browser-*headers CAN be faked —x-browser-channel: stableandx-browser-year: 2026are static strings that Google accepts from server-side HTTP calls. Thex-browser-validationheader is cryptographic and per-request, but Google doesn't enforce it for batchexecute. It IS checked for some gRPC endpoints. -
Source object positions vary by type and are undocumented — regular URLs go at position [2], YouTube at position [7] as a list, text at position [1] as [title, content]. Wrong position = silent empty source, no error.
-
Two-method architecture is optimal — Chrome-free HTTP client for fast production batchexecute calls (no browser needed), Chrome MCP for gRPC chat and ARGUS discovery (needs browser). CDP auth recovery refreshes cookies for both.
-
Chrome DevTools MCP server (
chrome-devtools-mcp) replaces all raw CDP WebSocket code. Oneget_network_requestcall extracts headers, cookies, full request/response body — everything we spent hours fighting CDP for. -
Dual-backend redundancy is essential — when batchexecute rpcids rotate, the Node.js browser bridge keeps working. When the bridge is slow, batchexecute handles source management.
-
Heap snapshot diffing finds APIs that network capture misses — some rpcids are triggered internally but never sent over the wire. V8 heap analysis catches them.
-
Knowledge distillation compounds — feeding ARGUS discoveries into NotebookLM, then extracting Q&A pairs into Nexus, creates a searchable knowledge layer that gets richer with every scan.
-
Local agents need APIs — the system is designed so local LMStudio models (Qwen, Gemma, etc.) can call the NotebookLM SDK, ARGUS, and all integrations via the Flask proxy and MCP skills. The system builds itself from the inside.
-
Google deploys weekly — build labels, rpcids, and sometimes payload formats change. Automated discovery isn't optional, it's mandatory for production stability.
-
API keys rotate unpredictably — the CDP recovery system that harvests fresh keys from AI Studio is the only reliable way to maintain access.
-
HAR files are gold — a single HAR capture contains everything: cookies, session tokens, build labels, rpcid→payload mappings, response formats. The watchfolder automation makes this a one-drop operation.
-
Protocol Monitor JSON exports are the new HAR — drop a
.jsonexport from Chrome DevTools Protocol Monitor intodata/hars/and the watchfolder auto-imports rpcids + payloads + cookies into the ARGUS registry.
| Script | Purpose | Key Technique |
|---|---|---|
nlm_debug_chunks.py |
Debug NLM streaming response structure | CDP injection, wrb.fr parsing |
cdp_live_probe.py |
Proof-of-concept: browser-side fetch works | CDP Runtime.evaluate, session extraction |
argus_live_chat.py |
Quick chat traffic capture | Chrome spawn, cookie injection, Angular bypass |
argus_chat_capture.py |
Detailed capture after rpcid rotation | Two-stage cookie injection, DOM detection |
argus_chat_probe.py |
Production chat probe with CLI + JSON output | CDPSession class, response body retrieval |
argus_grpc_discovery.py |
CDP-based gRPC method discovery | Tab navigation, button clicking, traffic capture |
argus_deep_crawl.py |
Systematic UI crawl + direct RPC verification | All buttons + direct fetch tests |
har_payload_analyzer.py |
Deep HAR mining for rpcids and payloads | Multi-endpoint extraction, override detection |
analyze_gemini_deep.py |
Gemini rpcid and service path analysis | batchexecute decode, gRPC path extraction |
analyze_gemini_deep2.py |
Gemini model list and thinking signatures | Response structure analysis |
har_watchfolder.py |
Background auto-import for HAR + Protocol Monitor JSON | Polling, health probes, Nexus audit |
nlm_ask.py |
Simple NLM query via CDP browser fetch | Tab attach, fetch injection, thinking skip |
ask.py |
Unified CLI for all frontier models | Multi-backend routing (Copilot/NLM/LMStudio) |
model_proxy.py |
OpenAI-compatible API server | Protocol translation, SSE streaming |
oracle.py |
System diagnostics and observability | Error aggregation, health grid, trace waterfall |
ARGUS Platform (scripts/argus/):
| Module | Purpose |
|---|---|
orchestrator.py |
Master controller — runs all phases sequentially |
crawlers/nlm_crawler.py |
13 NLM UI flows (list/open/chat/generate/source/flags) |
crawlers/gemini_crawler.py |
10 Gemini UI flows |
crawlers/aistudio_crawler.py |
15 AI Studio UI flows |
decoders/batchexecute.py |
f.req/wrb.fr parsing and payload extraction |
decoders/grpc_web.py |
Binary proto + JSON gRPC-web frame parsing |
decoders/heap_diffing.py |
V8 heap snapshot string table diffing |
discovery/endpoint_registry.py |
Versioned discovery storage with baseline diffs |
discovery/feature_flag_probe.py |
NLM feature flag ID scanning (300-1500) |
discovery/proto_reconstructor.py |
Build .proto files from captured wire data |
discovery/rpcid_detector.py |
Pattern detection for new rpcids |
network_monitor.py |
Real-time CDP network traffic capture |
cdp_bridge.py |
CDP WebSocket connection management |
importers/protocol_monitor.py |
Chrome Protocol Monitor JSON import |
reporting/api_doc_generator.py |
Auto-generate API reference docs from registry |
nexus_sink.py |
Store discoveries in Nexus knowledge base |
Notebook Management:
ub2Bae ListNotebooks · CCqFvf CreateNotebook/ResumeSession · mFtdI GetNotebook · e3bVqc NotebookInfo · s0tc2d RenameNotebook · WWINqb DeleteNotebook · dI5Y8 ShareNotebook · jzEKsc GetSharedNotebook
Source Management:
izAoDd AddSource · tGMBJ DeleteSource · wXbhsf ListSources · tr032e ReadSource · hizoJc SourceDetail · o4cbdc RegisterFiles · K4YCPe SourceMetadata · jtGGne SourcesAdvanced · bfEAsb ProcessSource
Q&A & Chat:
CYK0Xb CreateNote (Q&A with citations) · Bgzyjc GenerateFreeFormStreamed (real chat — rotated from tJHFsf)
Notes & Artifacts:
cYAfTb SaveNote · R7cb6c SaveReport · gArtLc ListArtifacts
Document Generation:
VfAZjd AISummary · ciyUvf GenerateDoc · xqEXEf GenerateGuide · yyryJe GenerateMindMap
Audio:
sqTeoe ListAudioTypes
Research:
Ljjv0c FastResearch · QA9ei DeepResearch · LBwxtb AddResearchSource
Threads & History:
hPTbtc GetThreadIds · khqZz ReadThread · GzgSEd GetChatHistory · GfmCOc DeleteChatHistory · cFji9 SyncNotes/MindMap
User & Account:
JFMDGd UserProfile · ozz5Z FeatureFlags/AccountState · ZwVcOc UserPlan/SessionInit · DYBcR GetLocale · AUrzMb Analytics (discovered 2026-03-23)
Export:
Krh3pd ExportToSheets
Implemented:
GenerateFreeFormStreamed (real chat — streaming, progressive full text)
Discovered (heap analysis + ARGUS crawl):
CreateArtifact · DeriveArtifact · GenerateArtifact · GetArtifactUserState · UpsertArtifactUserState · CheckSourceFreshness · DiscoverSourcesAsync · DiscoverSourcesManifold · CancelDiscoverSourcesJob · FinishDiscoverSourcesRun · MutateSource · RefreshSource · DeleteSources · MutateProject · DeleteProjects · ListFeaturedProjects · UpdateFeaturedNotebookStatus · DeleteChatTurns · ListChatSessions · MutateNote · GetOrCreateAccount · ReportContent · GeneratePromptSuggestions · GenerateReportSuggestions
Core: otAQ7b GenerateContent · aPya6c SessionInit · ESY5D GetHistory · L5adhe DraftInit · PCck7e ShareConversation · NXpLKc GetLinkedNotebooks · ku4Jyf CodeExecution
New (2026-03-23): HcT8bb ListStorybookGems · XqA3Ic GetStorybookDetail · ZKcapf ListSavedInfo · jGArJ ListMyContent · sJBwce GetSubscriptionTiers
Services: MakerSuiteService · MakersuiteAppletControlService
Key methods: GenerateContent · StreamGenerateContent · CreatePrompt · GetModel · ListModels
Endpoint: opal.google.com/_/Opal/data/batchexecute
Key rpcid: ug7pge OpalGeminiInit
Auth: Same Cookie + at CSRF as NLM
| Service | Auth Method | SAPISIDHASH? | Tokens Needed |
|---|---|---|---|
| NotebookLM (batchexecute) | Cookies + CSRF | NO | SID, SSID, APISID, __Secure-3PSID + at + bl + f.sid |
| NotebookLM (gRPC chat) | Cookies + CSRF + browser headers | NO | Same + x-browser-validation (via Chrome MCP or faked) |
| Gemini | Cookies + CSRF | NO | Same as NLM (different bl and f.sid) |
| Opal | Cookies + CSRF | NO | Same as NLM (shared nlm_meta.json) |
| AI Studio | API Key or SAPISIDHASH | YES | AIza... key or cookie-based |
| GitHub Copilot | GitHub Bearer token | N/A | GitHub cookies → /chat/token → 1hr Bearer |
| Google Colab | Cookies + SAPISIDHASH | YES | Same cookies + SHA1(ts + SAPISID + origin) |
| Google Sheets/Drive | Cookies + SAPISIDHASH | YES | Same cookies + SHA1 hash |
| LMStudio | Optional Bearer | N/A | Config-driven (lmstudio.api_token), local only |
import hashlib, time
ts = str(int(time.time()))
raw = f"{ts} {cookies['SAPISID']} {origin_url}"
hash_value = hashlib.sha1(raw.encode()).hexdigest()
header = f"SAPISIDHASH {ts}_{hash_value}"
# Add as: Authorization: SAPISIDHASH <timestamp>_<hash>Services that use it: Colab, Sheets, Drive, AI Studio Services that REJECT it: NotebookLM, Gemini, Opal (causes HTTP 400)
CosySim uses both a Chrome-free HTTP client and Chrome MCP, each for what they do best:
┌─────────────────────────────────────────────────┐
│ Chrome-Free HTTP Client (nlm_transport.py) │
│ ├── All batchexecute RPCs (42 operations) │
│ ├── Faked headers (x-browser-*, sec-ch-*, etc) │
│ ├── Cookie auth from disk (nlm_cookies.json) │
│ ├── No browser needed at runtime │
│ └── Used by: SDK, agents, scheduled tasks │
├─────────────────────────────────────────────────┤
│ Chrome MCP (chrome-devtools-mcp) │
│ ├── GenerateFreeFormStreamed (gRPC chat) │
│ ├── ARGUS discovery crawls │
│ ├── Network traffic capture + analysis │
│ ├── Auth refresh (cookie + token extraction) │
│ └── Used by: ARGUS, live debugging, chat │
├─────────────────────────────────────────────────┤
│ CDP Auth Recovery (cdp_auth_recovery.py) │
│ ├── Refreshes cookies for both methods │
│ ├── Harvests API keys from AI Studio │
│ ├── Runs every 15 minutes via scheduler │
│ └── Syncs to GoogleAccountPool for all clients │
└─────────────────────────────────────────────────┘
Local agents (LMStudio models) access everything through the Flask proxy API and MCP skills — they don't need to know which method is used internally.
This document is a living record. As Google continues to evolve their internal APIs, ARGUS continues to discover, and this journal continues to grow. v2.0 — 2026-03-23.