AI Lab Notebook — Gemini Integration Skill
The Alan Hirsch AI Lab Notebook is a NotebookLM-style variant of /ai-lab that grounds
chat and artifacts in Alan's ~1M-word published corpus. This skill is the operational
manual for the Gemini half of that system: how the corpus reaches the model, what can
silently break, and how to optimize cost and latency.
Authoritative docs (always fetch fresh when in doubt — Gemini APIs move quickly):
- Models: https://ai.google.dev/gemini-api/docs/models
- File API: https://ai.google.dev/gemini-api/docs/files
- Context caching: https://ai.google.dev/gemini-api/docs/caching
- Grounding with Google Search: https://ai.google.dev/gemini-api/docs/grounding
- Pricing: https://ai.google.dev/gemini-api/docs/pricing
1. Where Everything Lives (this repo)
| Concern | Path |
|---|---|
| Notebook page (flag-gated) | src/app/(public)/ai-lab/page.tsx — legacy /ai-lab/notebook → permanentRedirect in src/app/(public)/ai-lab/notebook/page.tsx |
| Chat route | src/app/api/ai-lab-notebook/chat/route.ts |
| Artifacts route | src/app/api/ai-lab-notebook/artifacts/route.ts |
| Source discovery (Google Search grounded) | src/app/api/ai-lab-notebook/sources/discover/route.ts |
| LLM router (Gemini ↔ Claude) | src/lib/ai-lab-notebook/notebook-llm.ts |
| Corpus config (env, model, cache id) | src/lib/ai-lab-notebook/corpus.ts |
| Corpus manifest I/O (Node-only) | src/lib/ai-lab-notebook/corpus-gemini-files.ts |
| Message augmentation | src/lib/ai-lab-notebook/augment-notebook-model-messages.ts |
| Manifest (committed, 48h TTL — legacy) | src/lib/ai-lab-notebook/corpus-manifest.json |
| File Search manifest (persistent) | src/lib/ai-lab-notebook/file-search-manifest.json |
| Build-time upload script (legacy) | scripts/gemini-corpus-upload.ts |
| Context cache creator | scripts/gemini-corpus-cache.ts |
| File Search store creator | scripts/gemini-file-search-create.ts |
| File Search uploader (delta) | scripts/gemini-file-search-upload.ts |
| File Search verifier (canaries) | scripts/gemini-file-search-verify.ts |
| Scope presets + file_search tool | src/lib/ai-lab-notebook/scope.ts |
| Automatic scope inference | src/lib/ai-lab-notebook/scope-inference.ts |
| Golden-query regression suite | tests/corpus-retrieval/ |
SDKs in use:
@google/genai— native Gemini SDK used by both scripts (genai.files.upload,genai.caches.create,genai.files.get)@ai-sdk/google— Vercel AI SDK wrapper used by chat/artifacts/discover routes (google(modelId),google.tools.googleSearch({}))
Do not mix these in the same call site. Scripts use the native SDK; routes use the AI SDK.
2. Four Corpus Delivery Modes — Priority-Resolved
As of Phase 5 (April 2026) the notebook resolves four retrieval modes in
priority order. Selection is env-driven; getCorpusRetrievalMode() in
corpus.ts is the authoritative picker.
| Pri | Mode | Trigger | Where corpus lives | Cost profile | Freshness |
|---|---|---|---|---|---|
| 1 | File Search (preferred) | GEMINI_FILE_SEARCH_STORE_ID set |
Gemini-side indexed chunks with semantic retrieval + citations | Free storage, $0.15/M one-time indexing, standard input for retrieved chunks (~10K tokens/query) | Persistent; delta-upload via pnpm notebook:store:upload |
| 2 | Context Cache | GEMINI_CORPUS_CACHE_ID set |
Cached content blob (whole corpus, up to 1M tokens) | ~25% cached-input discount + storage fee | Re-run pnpm notebook:cache every 7 days |
| 3 | File API manifest | corpus-manifest.json present + unexpired |
File parts attached per request | Full input cost every request | 48h TTL; pnpm build:with-legacy-file-api |
| 4 | Catalog-only | nothing set | Just titles in system prompt — ungrounded | Cheapest | N/A |
Resolution logic is in getCorpusRetrievalMode()
(src/lib/ai-lab-notebook/corpus.ts):
if GEMINI_FILE_SEARCH_STORE_ID valid → "file-search" (Mode 1)
if GEMINI_CORPUS_CACHE_ID valid → "cache" (Mode 2)
else (app side) → "catalog-only" (Mode 4)
(manifest mode is detected at call time
by augment-notebook-model-messages.ts)
Why File Search wins by default (Phase 5 research summary)
- Corpus (~1.8M tokens) exceeds any 1M-token context window. Mode 2/3 must drop books.
- Semantic retrieval surfaces only relevant chunks (~10K tokens) per query → ~99% cost reduction vs Mode 2.
- Automatic citations via
grounding_metadata— no prompt-engineering hacks. - Persistent (no 48h/7d expiry churn).
- Supports metadata filters: scope to
book,concept,framework, etc.
Keep Mode 2 for rollback; keep Mode 3 as a legacy fallback only.
Decision rubric — which mode to use when
- High traffic production (dozens+ calls/hour): Mode A (context cache). The cheapest cache discount (~25% of input) compounds fast. Pay the one-time 7-day cache cost.
- Low traffic / intermittent preview: Mode B (File API manifest). No cache storage
fee; refreshes naturally on every
pnpm build. - Corpus is bigger than the model window: Mode B. Mode A fails at
~1,048,576tokens forgemini-2.5-flash(gemini-corpus-cache.ts:53). The corpus is currently ~1.77M tokens by the chars/4 heuristic, so Mode A only works on a subset (setGEMINI_CACHE_CORPUS_SUBDIRto a subfolder undercorpus/alan_hirsch). - Dev / no API key: Mode C. The build script is designed to exit 0 when
GOOGLE_GENERATIVE_AI_API_KEYis unset (gemini-corpus-upload.ts:126).
The model↔cache locking rule (non-negotiable)
When GEMINI_CORPUS_CACHE_ID is set, the inference model must match the cache
model. The code enforces this at
corpus.ts:142-148:
export const CORPUS_CACHE_MODEL = "gemini-2.5-flash"; // stable only
export function getCorpusModelId() {
if (getCorpusCacheId()) return CORPUS_CACHE_MODEL; // lock to cache model
return process.env.GEMINI_MODEL || "gemini-3-flash-preview";
}
Why it matters: Setting GEMINI_MODEL=gemini-3-flash-preview while
GEMINI_CORPUS_CACHE_ID is also set does nothing — the code silently ignores
GEMINI_MODEL. To experiment with preview models, you must unset
GEMINI_CORPUS_CACHE_ID for that run. Preview models cannot be used as cache models;
they don't support caching at all.
3. Backend Switching — Gemini vs Claude
The notebook can run on either Gemini or Claude. Controlled by a single env var,
resolved in getNotebookLlmBackend()
(notebook-llm.ts:20):
AI_LAB_NOTEBOOK_LLM unset | "gemini" → Gemini backend
AI_LAB_NOTEBOOK_LLM="anthropic" | "claude" → Claude backend
Things that change with the backend:
resolveNotebookLlm()swapsgoogle(...)foranthropic(...)- When Gemini:
providerOptionsincludes{ google: { cachedContent } }if cache id is set - When Claude: no corpus attachment happens at all — the augment step early-returns (see augment-notebook-model-messages.ts:29). Claude only sees the catalog titles in the system prompt + user-pasted sources.
Sources discovery is Gemini-only, always. The /sources/discover route uses
google.tools.googleSearch({}) for live web grounding
(discover/route.ts:38-40).
Google Search grounding has no Claude equivalent. If GOOGLE_GENERATIVE_AI_API_KEY
is missing, source discovery fails even when AI_LAB_NOTEBOOK_LLM=anthropic. Don't try
to route it through Claude.
4. Environment Variables — Complete Reference
| Var | Purpose | Where read |
|---|---|---|
GOOGLE_GENERATIVE_AI_API_KEY |
Gemini API key (native SDK + AI SDK both honor it) | upload/cache scripts; AI SDK picks up automatically |
GEMINI_MODEL |
Inference model when no cache is active | corpus.ts:147, discover/route.ts:33 |
GEMINI_CORPUS_CACHE_ID |
Context cache name (e.g. cachedContents/abc…) |
corpus.ts:124 |
AI_LAB_NOTEBOOK_LLM |
Backend selector (gemini/anthropic) |
notebook-llm.ts:21 |
AI_LAB_NOTEBOOK_ANTHROPIC_MODEL |
Claude model id override | notebook-llm.ts:50 |
ANTHROPIC_API_KEY |
Required when backend = anthropic | notebook-llm.ts:42 |
GEMINI_CORPUS_DIR |
Absolute path to corpus markdown (overrides default) | gemini-corpus-upload.ts:57 |
ALAN_BOOKS_GITHUB_REPO + GITHUB_TOKEN |
Vercel/CI fallback — shallow-clone corpus | gemini-corpus-upload.ts:71-73 |
ALAN_BOOKS_CORPUS_SUBDIR |
Subdir within cloned repo (default corpus/alan_hirsch) |
gemini-corpus-upload.ts:76 |
SKIP_GEMINI_CORPUS_UPLOAD |
true → upload script exits 0 without running |
gemini-corpus-upload.ts:121 |
GEMINI_CORPUS_UPLOAD_STRICT |
true → upload script fails the build if no corpus found |
gemini-corpus-upload.ts:142 |
FORCE_ALAN_BOOKS_CLONE |
Force the clone path locally for testing | gemini-corpus-upload.ts:69 |
GEMINI_CACHE_CORPUS_SUBDIR |
Cache creator — subset to fit in 1M token window | gemini-corpus-cache.ts:50 |
Silent-failure pattern: Almost every "corpus not available" state produces a
console.warn, not an exception. The app then falls back to Mode C (catalog-only) and
the user sees ungrounded answers. Always check Vercel build logs + dev server warnings
before assuming things work.
5. Operational Playbooks
Playbook A — "The notebook isn't citing Alan's books anymore"
- Check backend. If
AI_LAB_NOTEBOOK_LLM=anthropic, Claude never gets the corpus attached — switch to Gemini or accept catalog-only grounding. - Check cache path:
If set, verify the cache still exists (not expired):grep "GEMINI_CORPUS_CACHE_ID" .env.local
TTL is 7 days from creation. If expired → re-runnpx tsx -e "import('@google/genai').then(async m => { const g = new m.GoogleGenAI({ apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY }); const list = await g.caches.list(); for await (const c of list) console.log(c.name, c.expireTime, c.usageMetadata); })"scripts/gemini-corpus-cache.ts. - Check manifest path (Mode B):
Ifcat src/lib/ai-lab-notebook/corpus-manifest.json | head -20expiresAtis in the past: the 48h File API window closed. Re-runnpx tsx scripts/gemini-corpus-upload.ts. The chat path already console-warns this in corpus-gemini-files.ts:27-31. - Check API key — if
GOOGLE_GENERATIVE_AI_API_KEYis unset, both upload and context cache scripts exit 0 silently. The app then runs in Mode C. - Confirm augmentation reached the model. Add a one-time log in the chat route
before streaming:
console.log('[notebook] corpus grounded:', notebookGeminiHasFullCorpusContext());
Playbook B — "Re-run the build-time upload now"
# Standard re-run (uses local ~/Desktop/dev/repos/alan-books corpus)
npx tsx scripts/gemini-corpus-upload.ts
# Force the Vercel/CI clone path for testing
FORCE_ALAN_BOOKS_CLONE=true ALAN_BOOKS_GITHUB_REPO=owner/repo \
GITHUB_TOKEN=ghp_... npx tsx scripts/gemini-corpus-upload.ts
# Point at a specific folder
GEMINI_CORPUS_DIR=/abs/path/to/corpus_subset npx tsx scripts/gemini-corpus-upload.ts
The script writes src/lib/ai-lab-notebook/corpus-manifest.json. Commit it — Vercel
reads from the repo at deploy time, and the prebuild hook runs it again anyway.
Playbook C — "Create or refresh the context cache"
# Full corpus (will fail if > 1M tokens)
npx tsx scripts/gemini-corpus-cache.ts
# Subset (recommended — currently full corpus exceeds gemini-2.5-flash window)
GEMINI_CACHE_CORPUS_SUBDIR=books npx tsx scripts/gemini-corpus-cache.ts
Copy the printed cache name into .env.local:
GEMINI_CORPUS_CACHE_ID=cachedContents/abc123…
Consider a weekly cron — the TTL is 7 days hard-coded at
gemini-corpus-cache.ts:60. A Vercel cron or
GitHub Action running pnpm tsx scripts/gemini-corpus-cache.ts on a 6-day schedule
keeps the cache live without manual intervention. Use the vercel-plugin:cron pattern
or a lightweight cron job; avoid in-app refresh on every request.
Playbook D — "Switch models for an experiment"
# Preview model — requires clearing the cache id first
unset GEMINI_CORPUS_CACHE_ID
GEMINI_MODEL=gemini-3-flash-preview pnpm dev
If you want to test a new model and keep grounding, use Mode B (File API manifest) — it has no model lock.
Playbook E — "Tune the source discovery route"
The grounded-search route prompts for up to 10 sources. Current model defaults to
process.env.GEMINI_MODEL || 'gemini-3-flash-preview'. Flash-tier is appropriate —
there is no benefit to running Pro on source discovery. If discovery is slow, check:
- Network latency to
generativelanguage.googleapis.com maxDuration = 60cap in the route- JSON parse — currently a single
/\[[\s\S]*\]/regex. If the model wraps the JSON in fences or adds prose, the regex still matches the first[…]block. If parsing fails silently (returns{ sources: [] }), log the rawtextbefore parsing.
6. Optimization Reference
Cost hierarchy (cheapest → most expensive at notebook scale)
- Mode A (cache) on flash-lite — best cost if the corpus fits. But
flash-liteis not currently the cache model. - Mode A (cache) on flash — current default. Cached input ≈ 25% of non-cached input; output unchanged.
- Mode B (File API) on flash — no cache discount; 48h refresh.
- Mode B on pro — only when a specific query needs deeper reasoning. The notebook chat route uses one model per request, so routing by query is not supported today.
- Mode A on pro — stable but expensive. Only if cache discount makes it pencil out at your traffic.
Latency tips
- Chat route already streams via
streamText→toTextStreamResponse(). Don't buffer. - Sources discovery is a single non-streaming
generateText— OK because the result is structured JSON, not prose. - Context cache calls have materially lower TTFT than File API calls (Google caches the KV state, not just input tokens).
When to escalate to Vertex AI RAG Engine
The corpus comment in corpus.ts:7-9 mentions Vertex AI RAG Engine as a future path. Consider it only when:
- The corpus materially exceeds 1M tokens and chunk-level retrieval matters
- You need embedding-based semantic search within the corpus rather than full-context
- You're already on Vertex for compliance reasons
For the current ~1M-word corpus and flash-tier usage, stick with context cache or File API. Adding a vector DB hop would increase complexity without obvious win.
6b. The 1M-Token Ceiling (Critical — corpus is bigger than any Gemini model)
Every Gemini model — gemini-2.5-flash, gemini-2.5-pro, gemini-3-flash-preview,
gemini-3-pro-preview — has an input window of exactly 1,048,576 tokens (confirmed
against the live models/ endpoint on 2026-04-11). There is currently no Gemini model
with a larger context.
Alan's full English corpus is ~1.77M tokens by the chars/4 heuristic. It will never fit in a single request regardless of which model you pick. This means:
- Mode B (File API) MUST upload a subset. Uploading the full corpus is a silent time bomb: the upload succeeds, the manifest looks healthy, and every chat request fails at runtime with "The input token count exceeds the maximum number of tokens allowed (1048576)".
- Mode A (context cache) also requires a subset. The cache script's
GEMINI_CACHE_CORPUS_SUBDIRhas always enforced this; the upload script now does too viaGEMINI_UPLOAD_CORPUS_SUBDIR.
The upload script's token pre-check (added 2026-04-11) enforces this hard — it fails the build before upload if the estimated corpus exceeds 1M tokens, and warns if it exceeds the 850K soft budget (leaving headroom for system prompt + user message + output).
Current working subset (as of 2026-04-11)
The default notebook subset is set via environment:
GEMINI_UPLOAD_CORPUS_SUBDIR=the-forgotten-ways,5q,the-permanent-revolution,_topics,_comparisons
This produces ~528K tokens — comfortably under budget, covers the three core books
(The Forgotten Ways, 5Q, The Permanent Revolution) plus topic guides and
inter-book comparison notes. When you add books or change focus, rerun
pnpm notebook:upload with an updated subset env var and commit the new manifest.
When you need the full corpus
Options, in order of effort:
- Narrow the subset per deployment. E.g. a pathway-focused tenant ships with only the books that pathway cites.
- Switch to Mode A cache + subset. Same subset filter, but pays less per request at volume. Cache TTL is 7 days.
- Adopt embeddings + top-k retrieval. Pre-embed the whole corpus with Gemini (or Vertex) embeddings and retrieve the top N chunks per query. This is the NotebookLM approach at scale. Build cost is real; complexity is real. Only do this when a single subset genuinely can't serve your query distribution.
- Wait for a Gemini model with a bigger window. Not a plan.
7. Common Pitfalls (things this repo has already hit)
- Mixing
@google/genaiand@ai-sdk/google. Scripts use the native SDK for file uploads (files.upload,files.get,caches.create); routes use the AI SDK (google(modelId)). File URIs from the native SDK are consumable by the AI SDK via{ type: 'file', data: new URL(uri), mediaType: 'text/plain' }— see augment-notebook-model-messages.ts:36-42. .mdxin corpus. The upload script walks both.mdand.mdx(gemini-corpus-upload.ts:262). Unlike OpenAI vector stores, Gemini's File API accepts arbitrary text — no extension rename required, because files are concatenated intotext/plainblobs and uploaded as batches.- Translation folders. Both scripts explicitly skip
-es,-pt,-pt-BRfolder suffixes. When adding new locales, update the filter in both gemini-corpus-upload.ts:253-259 and gemini-corpus-cache.ts:188-194. - Batch size assumption. Upload script uses
MAX_BATCH_CHARS = 2_000_000(~500K tokens). Don't raise this blindly — the File API accepts larger blobs, but the native SDK filestate: ACTIVEpolling takes longer on bigger files, and the current 30-attempt ×2s loop may time out. - Manifest in git.
corpus-manifest.jsonis committed (corpus-gemini-files.ts:17-19) so Vercel deploys have something to read beforeprebuildruns. Don't gitignore it; stale content is fine becauseprebuildalways regenerates. GEMINI_CORPUS_UPLOAD_STRICT=truelocally. Will fail the build if the corpus isn't present locally. Only set this on Vercel if you're certain the clone path is configured; otherwise every build breaks.
8. Verification Commands
Primary: use the bundled verify script. It does everything below in one call:
pnpm notebook:verify
It reports the resolved corpus mode (as seen by the app after placeholder
filtering), checks each manifest file is ACTIVE on Gemini, and validates any
GEMINI_CORPUS_CACHE_ID against a live caches.get. Non-zero exit on any
problem, so it's safe in CI.
Other package scripts:
pnpm notebook:upload— re-run Mode B upload (same asscripts/gemini-corpus-upload.ts)pnpm notebook:cache— create/refresh a Mode A cache
Lower-level probes (when you need to debug the verify script itself):
# List live Gemini caches on this API key
pnpm tsx -e "
import('@google/genai').then(async ({ GoogleGenAI }) => {
const g = new GoogleGenAI({ apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY });
for await (const c of g.caches.list()) {
console.log(c.name, '| expires:', c.expireTime, '| tokens:', c.usageMetadata?.totalTokenCount);
}
});
"
# List live Gemini files on this API key
pnpm tsx -e "
import('@google/genai').then(async ({ GoogleGenAI }) => {
const g = new GoogleGenAI({ apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY });
for await (const f of g.files.list()) {
console.log(f.name, '| state:', f.state, '| expires:', f.expirationTime, '| name:', f.displayName);
}
});
"
Placeholder-cache auto-rejection (added 2026-04-11)
getCorpusCacheId() at corpus.ts rejects
obvious placeholder values (short hashes, strings containing abc123, xxx,
placeholder, example, todo) and returns null with a console.warn.
This is a defense-in-depth layer on top of the skill guidance — a stale/demo
cache id in .env.local can no longer silently break every chat call.
When the rejection fires:
- App logs:
[corpus] GEMINI_CORPUS_CACHE_ID hash is too short to be real — ignoring - App falls through to Mode B (File API manifest)
pnpm notebook:verifyreports the raw value as a warning and shows the resolved mode that the app will actually use
The fix is still to unset or replace the env var — the auto-rejection just prevents a hard outage while you do it.
9. When to Defer to Other Skills
- Generic Gemini API questions (new tool calls, video input, Vertex, pricing on
unrelated projects): use the
gemini-apiskill. This skill only covers the notebook's integration. - Claude side of the notebook backend: use the
claude-apiskill. The Claude branch ofresolveNotebookLlm()has no corpus attachment logic; it's an ordinarystreamTextcall. - Vector store / RAG alternatives (if the corpus grows past Gemini's window): use
the
openai-vector-storeskill as a reference for what that pattern looks like. - Env var management on Vercel: use the
env-setuporvercel-plugin:envskill.
10. Quick Reference Card
CORPUS DELIVERY:
Mode A cache → GEMINI_CORPUS_CACHE_ID set → cheapest, 7d TTL, model-locked
Mode B files → corpus-manifest.json present → 48h TTL, auto via pnpm build
Mode C catalog → neither → titles only, ungrounded
BACKEND:
AI_LAB_NOTEBOOK_LLM = gemini (default) | anthropic
MODEL LOCK:
if cache id set → model FORCED to gemini-2.5-flash
else → GEMINI_MODEL || gemini-3-flash-preview
SCRIPTS:
pnpm tsx scripts/gemini-corpus-upload.ts # Mode B — files, 48h
pnpm tsx scripts/gemini-corpus-cache.ts # Mode A — cache, 7d
FAIL-OPEN BEHAVIOR:
Missing API key → exit 0, silent fallback to Mode C
Expired manifest → console.warn, silent fallback to Mode C
Wrong backend → Claude ignores corpus entirely (no warning)
Always state explicitly which mode is active and which env vars are driving it when answering operational questions.