diff --git a/.agents/skills/lancedb-branch-ops/SKILL.md b/.agents/skills/lancedb-branch-ops/SKILL.md
index cf020ad19..9c7375c8a 100644
--- a/.agents/skills/lancedb-branch-ops/SKILL.md
+++ b/.agents/skills/lancedb-branch-ops/SKILL.md
@@ -1,6 +1,14 @@
---
name: lancedb-branch-ops
-description: Branch management for LanceDB tables via the REST API. Use this skill whenever someone wants to create, delete, list, or switch branches on a LanceDB table — or needs to make sure a write (metadata update, index build, etc.) lands on a specific branch instead of main. Invoke it even without the word "branch" if context makes clear they want an experimental copy of a table, want to isolate changes, or want to confirm a mutation didn't touch main. Covers: branches/list, branches/create, branches/delete, and passing "branch" in describe/update_field_metadata/create_index to target a non-main version.
+description: >-
+ Manage LanceDB table branches through the REST API: list, create, and delete
+ branches; target schema reads, field-metadata updates, and index creation to a
+ named branch; and verify that branch changes remain isolated from main. Use
+ when a task involves branch lifecycle, an experimental or isolated table
+ version, directing an operation to a non-main branch, or confirming that a
+ mutation did not affect main. This skill also explains that LanceDB has no
+ checkout operation; each request selects its target branch in the request
+ body.
---
## Goal
diff --git a/.agents/skills/lancedb/SKILL.md b/.agents/skills/lancedb/SKILL.md
new file mode 100644
index 000000000..2ed62fc39
--- /dev/null
+++ b/.agents/skills/lancedb/SKILL.md
@@ -0,0 +1,79 @@
+---
+name: lancedb
+description: Use when writing, reviewing, debugging, or documenting LanceDB pipelines in Python or TypeScript, especially code that should work across local LanceDB OSS tables and remote LanceDB Enterprise/Cloud tables. Helps avoid non-portable full-table materialization, choose idiomatic query/search patterns, and apply LanceDB performance defaults for ingestion, indexing, filtering, and diagnostics.
+---
+
+# Building LanceDB Pipelines
+
+Use this skill to produce LanceDB pipelines that are portable between local and remote tables (for LanceDB Enterprise/Cloud) and idiomatic for the selected SDK.
+
+## LanceDB Table Modes
+
+LanceDB has two common execution modes:
+
+- **Local table**: embedded, open source, in-process LanceDB. The client opens data from a local path or object storage URI and executes queries in the application process.
+- **Remote table**: LanceDB Enterprise/Cloud table opened through a `db://...` URI. The data may be very large, commonly backed by object storage, and queried through a remote service.
+
+Do NOT assume local-only table helpers exist on remote tables. If the user asks for LanceDB Enterprise, Cloud, `db://...`, production remote access, or a remote table, focus on the remote table path: use `search()` / `query()`, keep reads bounded with `select()` and `limit()`, and avoid table-level full materialization APIs.
+
+## Workflow
+
+1. Identify the SDK: Python, TypeScript, or both.
+2. Identify the table mode: local/embedded OSS, remote Enterprise/Cloud, or portable across both. If the user says "LanceDB Enterprise", choose the remote table path.
+3. Read the matching language branch before writing or changing code:
+ - Python patterns: `references/python/patterns.md`
+ - Python API quick reference: `references/python/api_reference.md`
+ - Python performance guidance: `references/python/performance.md`
+ - TypeScript patterns: `references/typescript/patterns.md`
+ - TypeScript API quick reference: `references/typescript/api_reference.md`
+ - TypeScript performance guidance: `references/typescript/performance.md`
+4. Start with `patterns.md` for the selected SDK. Read `api_reference.md` when choosing method names or return collectors. Read `performance.md` when the task involves ingestion, indexing, filtering, query tuning, diagnostics, or large datasets.
+5. For Python schemas, favor Pydantic models and validate records before writing. Use PyArrow schemas when Arrow-native, streaming, or highly dynamic data makes them materially better suited.
+6. Prefer `search()` or `query()` builders with explicit `select()` and `limit()` for reads.
+7. Avoid table-level full materialization in remote or portable code. This is the main local-vs-remote read pitfall.
+8. After a successful embedded OSS ingestion, call `table.optimize()`. Do not call it for Enterprise/Cloud; remote maintenance is automatic.
+9. For remote Enterprise/Cloud writes, never drop-then-reuse or `mode="overwrite"` the same table name — see "Enterprise: never drop-then-reuse the same table name" below. This is the main local-vs-remote write pitfall.
+10. If reviewing an existing file or repo, run `scripts/check_materialization.py` on the relevant paths and inspect each finding before editing.
+11. Cross-check unfamiliar or non-trivial API claims against the source tree instead of relying on memory.
+
+## Core Portability Rule
+
+Do not write code that assumes a local table API will exist on a remote table. Remote tables can be very large, so whole-table materialization helpers are intentionally unavailable or unsafe.
+
+This does **not** mean result conversion is forbidden. Bounded query/search result collection is normal:
+
+- Python: `table.search(...).select([...]).limit(10).to_pandas()`
+- TypeScript: `await table.search(...).select([...]).limit(10).toArray()`
+
+The unsafe pattern is table-level or unbounded collection, plus local-only dataset escape hatches in remote code:
+
+- Python: `table.to_pandas()`, `table.to_arrow()`, `table.to_polars()`; `table.to_lance()` is local/OSS-only dataset access, not materialization
+- TypeScript: `await table.toArrow()`, `await table.query().toArray()` without `limit()`
+
+## Enterprise: never drop-then-reuse the same table name
+
+LanceDB Enterprise/Cloud splits a **control plane** (DDL: create/drop/rename) from a **data plane** (query nodes that serve reads). Query nodes cache the resolved dataset for a table name for up to `table_cache_ttl` — **default 300 seconds (5 minutes)**. After you drop or overwrite a table, the control plane updates immediately but the data plane keeps serving the *old* dataset until that cache entry expires. During the window the two planes disagree.
+
+The failure this causes: you `drop_table("t")` then immediately `create_table("t", ...)` (or `create_table("t", ..., mode="overwrite")`). The DDL returns success, but every query against `t` returns **`500 Internal Server Error`** (the query node resolves the stale/deleted dataset), and a fresh `describe` may still show the *old* schema/version. It looks like your write silently failed; it didn't — the name is cached.
+
+**`mode="overwrite"` has the same problem** — it is a drop+create of the same name under the hood.
+
+Rules for portable Enterprise ingestion:
+
+1. **Never reuse a table name you just dropped/overwrote within the cache TTL.** Do not use `mode="overwrite"` to replace an existing Enterprise table in place.
+2. To (re)load data, **write to a fresh table name** (e.g. `
_v2`, or a run-stamped suffix). A brand-new name has no cached data-plane entry, so writes and reads work immediately.
+3. Before creating, `list_tables()` and **fail loudly if the name already exists** rather than overwriting — prompt for a new name.
+4. To land on a specific final name that is currently occupied by an old table: drop the old table, **wait out the TTL (~5 min), then `rename_table(fresh_name, final_name)`**. Renaming onto a name whose old dataset is still cached hits the same race, so the wait is mandatory. `rename_table` is a supported control-plane op.
+5. When you hand a table name back to a human, tell them which step still needs the propagation wait (usually: "the old `t` was dropped; run the rename in ~5 minutes").
+
+This is Enterprise/Cloud-specific. Local/OSS tables have no separate data plane, so `mode="overwrite"` and immediate same-name reuse are fine there.
+
+## Script
+
+Run the scanner when reviewing or modifying an existing codebase:
+
+```bash
+python skills/lancedb/scripts/check_materialization.py path/to/file_or_dir
+```
+
+The script reports likely unsafe full-table materialization in Python and TypeScript. Treat results as review prompts, not automatic proof of a bug.
diff --git a/.agents/skills/lancedb/references/python/api_reference.md b/.agents/skills/lancedb/references/python/api_reference.md
new file mode 100644
index 000000000..2c0e34c72
--- /dev/null
+++ b/.agents/skills/lancedb/references/python/api_reference.md
@@ -0,0 +1,105 @@
+# Python API Reference
+
+Quick method reference for Python LanceDB code. Cross-check source for non-trivial claims.
+
+## Connect
+
+```python
+import lancedb
+
+db = lancedb.connect("./camelot-db") # local/OSS
+db = lancedb.connect("db://my-db", api_key=api_key, region=region) # remote
+```
+
+**Place the local database directory next to the script/entrypoint that opens it** (i.e. resolve the path relative to the script, `Path(__file__).parent / "camelot-db"`), not buried under a shared `data/` folder. The Lance dataset is the database, not a data file — keeping it beside its code makes ownership obvious and paths stable regardless of the working directory the script is launched from.
+
+**Do not name the directory `lancedb`** (e.g. `./lancedb`, `./data/lancedb`). It collides with the imported `lancedb` package name, which is confusing to read and easy to shadow in scripts. Give it a name derived from the repo or dataset with a clear prefix/suffix — for example `./-db`, `./_lancedb`, or `./vectordb`.
+
+Async:
+
+```python
+db = await lancedb.connect_async("./camelot-db")
+```
+
+## Table Reads
+
+| Task | Preferred API |
+| --- | --- |
+| Vector search | `table.search(query_vector).limit(k)` |
+| Full scan with filters/projection (sync) | `table.search().where(...).select(...).limit(...)` |
+| Full scan with filters/projection (async) | `table.query().where(...).select(...).limit(...)` |
+| Filter | `.where("col > 10")` |
+| Projection | `.select(["id", "text"])` |
+| Bound result count | `.limit(20)` |
+| Collect bounded result as Python objects (default, no extra deps) | `.to_list()` on query/search result |
+| Collect bounded result as Arrow (default, `pyarrow` always available) | `.to_arrow()` on query/search result |
+| Collect bounded result as pandas (only if project uses pandas) | `.to_pandas()` on query/search result |
+| Collect bounded result as Polars (only if project uses polars) | `.to_polars()` on query/search result |
+
+## Sync vs Async Scan API
+
+The plain-scan entry point differs between the sync and async clients. **Verified against `lancedb` 0.34.0** — re-check if the pinned version changes:
+
+- **Sync** (`lancedb.connect(...)`): the table has **no `.query()` method**. Use `.search()` with no argument for a plain scan; it returns a query builder that supports `.where()`, `.select()`, `.limit()`, and the `.to_list()` / `.to_arrow()` / `.to_pandas()` / `.to_polars()` collectors.
+ ```python
+ rows = table.search().where("status = 'ready'").select(["id", "text"]).limit(20).to_list()
+ ```
+- **Async** (`lancedb.connect_async(...)`): the table has **both** `.query()` and `.search()`. Use `.query()` for a plain scan.
+ ```python
+ rows = await async_table.query().where("status = 'ready'").select(["id", "text"]).limit(20).to_list()
+ ```
+
+Do not call `table.query()` on a sync table — it raises `AttributeError`.
+
+## Local vs Remote Table Methods
+
+| API | Local table | Remote table | Agent guidance |
+| --- | --- | --- | --- |
+| `table.search(...)` | Yes | Yes | Preferred read path (sync + async) |
+| `table.query()` | Async only | Async only | Sync scan path is `table.search()`; `.query()` is the async scan builder |
+| `table.to_pandas()` | Yes | No / unsafe for portability | Avoid in portable code |
+| `table.to_arrow()` | Yes | No / unsafe for portability | Avoid in portable code |
+| `table.to_polars()` | Yes | No / unsafe for portability | Avoid in portable code |
+| `table.to_lance()` | Yes | No | Local/OSS escape hatch only |
+
+## Indexes
+
+Use `create_index(...)` for vector indexes and modern index configs. Use scalar indexes for filtered or merge keys.
+
+Common calls:
+
+```python
+table.create_index("vector")
+table.create_scalar_index("status")
+table.create_fts_index("text")
+```
+
+Check source docs before specifying advanced index config names or parameters.
+
+## Filtering And Recall Knobs
+
+```python
+table.search(query_vector).where("status = 'ready'") # pre-filter by default
+table.search(query_vector).where("status = 'ready'", prefilter=False)
+table.search(query_vector).limit(10).refine_factor(20)
+table.search(query_vector).limit(10).nprobes(50)
+```
+
+Use post-filtering only when fewer than `limit` results are acceptable.
+
+## Diagnostics
+
+```python
+print(table.search(query_vector).where("year > 2000").limit(10).analyze_plan())
+print(table.index_stats("vector_idx"))
+```
+
+Use these before changing indexes or search tuning.
+
+## Maintenance
+
+```python
+table.optimize()
+```
+
+Call this after every successful local/OSS ingestion. It handles compaction, cleanup of old versions according to retention, and index optimization. Do not add this for LanceDB Enterprise/Cloud remote tables; Enterprise handles compaction and cleanup automatically from cluster configuration.
diff --git a/.agents/skills/lancedb/references/python/patterns.md b/.agents/skills/lancedb/references/python/patterns.md
new file mode 100644
index 000000000..4d6be43ef
--- /dev/null
+++ b/.agents/skills/lancedb/references/python/patterns.md
@@ -0,0 +1,173 @@
+# Python Patterns
+
+Use these patterns when writing Python code with `lancedb`.
+
+## Before Writing Code
+
+Choose the output type from what the project actually depends on. **Do not assume `pandas` or `polars` is installed** — they are heavy dependencies that many LanceDB projects do not use. `pyarrow`, by contrast, ships as a LanceDB dependency and is always available, so it is a safe default to lean on.
+
+Default output (after applying `select()` and `limit()`):
+
+- **Python objects**: `.to_list()` — a list of dicts, no extra dependencies. Prefer this for scripts, examples, and agent-generated code unless there is a reason to do otherwise.
+- **PyArrow**: `.to_arrow()` — a `pyarrow.Table`, when the surrounding code is Arrow-native or you need columnar/zero-copy handoff.
+
+Only reach for a DataFrame when the project *already* declares that dependency:
+
+- Pandas projects (pandas in `pyproject.toml`/requirements): `.to_pandas()`.
+- Polars projects (polars declared): `.to_polars()`.
+
+If unsure, check the dependency manifest or the imports in surrounding files. When in doubt, use `.to_list()` or `.to_arrow()`.
+
+## Schema Design and Validation
+
+Favor `LanceModel` and Pydantic validation for Python schemas. They keep field
+types readable, validate source records before a write, and map directly to a
+LanceDB schema. Use `Vector(dimension)` for fixed-size vectors:
+
+```python
+from lancedb.pydantic import LanceModel, Vector
+
+class Document(LanceModel):
+ id: int
+ text: str
+ vector: Vector(384, nullable=False)
+
+rows = [Document.model_validate(row) for row in source_rows]
+table = db.create_table("documents", schema=Document)
+table.add(rows)
+```
+
+Use PyArrow schemas instead when the pipeline is already Arrow-native, needs
+record-batch streaming, or has runtime schema requirements that would make a
+Pydantic model harder to understand. Declare Pydantic as a direct project
+dependency when application code imports it, even if LanceDB also depends on it.
+
+## Recommended Patterns
+
+### Bounded search or query
+
+Use this for application reads, examples, notebooks, and agent-generated scripts:
+
+```python
+results = (
+ table.search(query_vector)
+ .where("status = 'ready'")
+ .select(["id", "text"])
+ .limit(20)
+ .to_list() # or .to_arrow(); .to_pandas()/.to_polars() only if the project uses them
+)
+```
+
+Why: `search()` works across local and remote tables and on both the sync and async clients. `select()` avoids fetching unused columns. `limit()` prevents accidental full-table reads. `.to_list()` and `.to_arrow()` avoid assuming pandas/polars is installed (see "Before Writing Code").
+
+For a **plain scan** (no query vector), the entry point differs by client:
+
+```python
+# Sync client: no .query() method — use .search() with no argument.
+rows = table.search().where("status = 'ready'").select(["id", "text"]).limit(20).to_list()
+
+# Async client: use .query().
+rows = await async_table.query().where("status = 'ready'").select(["id", "text"]).limit(20).to_list()
+```
+
+`table.query()` on a sync table raises `AttributeError` (verified on `lancedb` 0.34.0). See the "Sync vs Async Scan API" section in `api_reference.md`.
+
+### Bounded query result conversion
+
+It is fine to collect bounded query/search results:
+
+```python
+arrow_table = table.search().select(["id"]).limit(100).to_arrow() # sync plain scan
+rows = table.search(query_vector).limit(10).to_list()
+df = table.search(query_vector).limit(10).to_pandas() # only if pandas is a project dep
+```
+
+### Local-only Lance dataset API
+
+`table.to_lance()` does not itself materialize the full dataset. It returns the underlying `lance.LanceDataset`, making the table accessible through the PyLance dataset API. Use it when the task is explicitly local/OSS and needs Lance dataset methods not exposed by LanceDB:
+
+```python
+# Local/OSS only: RemoteTable does not expose table.to_lance().
+ds = table.to_lance()
+for batch in ds.to_batches(columns=["id", "text"], batch_size=10_000):
+ process(batch)
+```
+
+### Async Python
+
+Keep the same shape and bound the result before collecting:
+
+```python
+results = await (
+ async_table.query()
+ .where("status = 'ready'")
+ .select(["id", "text"])
+ .limit(20)
+ .to_list() # or .to_arrow()
+)
+```
+
+## Anti-Patterns
+
+**Avoid the following anti-patterns in your code.**
+
+### Table-level full materialization
+
+Avoid whole-table collectors in portable or large-table code:
+
+```python
+df = table.to_pandas()
+arrow_table = table.to_arrow()
+polars_df = table.to_polars()
+```
+
+Why: local tables expose these whole-table collectors, but remote tables intentionally do not — a remote production table can be far larger than a local development table, so it is easy to accidentally pull the entire table into memory.
+
+`table.to_lance()` is different: it is not a full materialization call, but it is still local/OSS-only and should not appear in code meant to run against remote Enterprise tables.
+
+### Unbounded result collection
+
+Avoid query/search collection without a meaningful limit:
+
+```python
+rows = table.search().to_list() # unbounded plain scan
+rows = table.search(query_vector).to_list() # unbounded vector search
+```
+
+Prefer `select(...).limit(...)` before collecting; for large reads, stream in batches instead.
+
+### Per-row writes
+
+Avoid loops that write one row per call:
+
+```python
+for row in rows:
+ table.add([row]) # one commit + fragment per row
+```
+
+Each `add()` creates a new version and fragment. Pass the whole batch in a single call, or chunk very large inputs:
+
+```python
+table.add(rows) # single commit
+# for very large inputs, add batches of several thousand rows
+```
+
+After the final successful write to an embedded OSS table, call
+`table.optimize()`. Skip this for Enterprise/Cloud tables because their
+maintenance is automatic.
+
+### Drop-then-reuse the same table name (Enterprise/Cloud)
+
+Avoid dropping or overwriting a remote table and then reusing that name right away:
+
+```python
+db.drop_table("my_table")
+table = db.create_table("my_table", data=rows) # reads 500 for ~5 min
+table = db.create_table("my_table", data=rows, mode="overwrite") # same problem
+```
+
+Why: Enterprise/Cloud splits DDL (control plane) from query serving (data plane). The data plane caches the dataset behind a table name for up to `table_cache_ttl` (default 300s / 5 min), so after a drop/overwrite the DDL succeeds but queries against the reused name return `500 Internal Server Error` until the cache expires — and a fresh `describe` may still show the old schema. Instead, write to a **fresh name**, use `list_tables()` and fail if it already exists, then `rename_table(fresh, final)` onto the final name only after the old table's drop has propagated (~5 min). See the "Enterprise: never drop-then-reuse the same table name" section in `SKILL.md`. Local/OSS tables have no separate data plane — overwrite freely there.
+
+### Guessing performance fixes
+
+Avoid changing `nprobes`, `refine_factor`, or index types before checking the query plan and index stats. Diagnose first, then tune one knob at a time.
diff --git a/.agents/skills/lancedb/references/python/performance.md b/.agents/skills/lancedb/references/python/performance.md
new file mode 100644
index 000000000..5fd27440b
--- /dev/null
+++ b/.agents/skills/lancedb/references/python/performance.md
@@ -0,0 +1,131 @@
+# Python Performance Guidance
+
+Use this when writing Python code that ingests data, queries large tables, builds indexes, or investigates latency.
+
+## Ingestion
+
+### Recommended: validate schemas and records with Pydantic
+
+Favor `LanceModel` for readable Python schema definitions and validate source
+records before writing. Use PyArrow directly for Arrow-native or streaming
+pipelines where it is the clearer representation.
+
+```python
+from lancedb.pydantic import LanceModel, Vector
+
+class Document(LanceModel):
+ id: int
+ text: str
+ vector: Vector(384, nullable=False)
+
+rows = [Document.model_validate(row) for row in source_rows]
+table = db.create_table("documents", schema=Document)
+table.add(rows)
+```
+
+### Recommended: bulk ingestion for materialized data
+
+```python
+table.add(arrow_table)
+table.add(df)
+table.add(pa.dataset("data/", format="parquet"))
+```
+
+For very large initial loads, create the table empty first, then call `add(...)`. Passing data directly to `create_table(name, data)` can skip the auto-parallel write path.
+
+### Recommended: iterator ingestion for generated or streamed data
+
+```python
+def batches():
+ for raw in source:
+ vectors = model.encode(raw["text"])
+ yield pa.RecordBatch.from_pydict({**raw, "vector": vectors})
+
+table.add(batches())
+```
+
+Use chunks of several thousand rows or more when practical. Tiny batches and per-row writes create many small fragments.
+
+### Anti-pattern: per-row `add()`
+
+```python
+for row in rows:
+ table.add([row])
+```
+
+Each call creates a version and fragment. This slows ingestion and later queries.
+
+## Indexing
+
+- Build a vector index once brute-force vector search becomes too slow. As a rule of thumb, local brute force is fine below roughly 100K vectors; beyond that, build an index.
+- Use `IVF_PQ` as the general-purpose default. Enterprise builds this automatically.
+- Use scalar indexes for filtered columns and merge/upsert keys.
+- Use `BTREE` for mostly distinct numeric/string/temporal columns, `BITMAP` for booleans and low-cardinality columns, and `LABEL_LIST` for list membership queries.
+- Keep full-text defaults unless phrase queries require position data.
+
+## Querying
+
+Always be explicit:
+
+```python
+table.search(query_vector).select(["id", "title"]).limit(20)
+```
+
+- `select()` reduces bytes read and transferred.
+- `limit()` prevents accidental full-table materialization.
+- Pre-filtering is the default and guarantees returned rows satisfy the predicate.
+- Use post-filtering only when fewer than `limit` results are acceptable.
+
+## Recall Tuning
+
+Tune one knob at a time:
+
+- Quantized indexes: raise `refine_factor` to rescore more candidates on full vectors.
+- HNSW-backed indexes: raise `ef`; start around `1.5 * k`, increase toward `10 * k` if recall is short.
+- IVF candidate breadth: `nprobes` is auto-tuned; override only when a selective pre-filter leaves too few neighbors.
+
+## Maintenance
+
+After every successful embedded OSS/local ingestion, call `table.optimize()`.
+Do not add this to LanceDB Enterprise/Cloud remote table code; remote compaction
+and cleanup are handled automatically based on the Enterprise cluster
+configuration.
+
+Why local maintenance is needed:
+
+- Frequent writes can create many small fragments. Queries then need to scan across more files, which can increase latency.
+- Updates, deletes, and appends create new table versions. Old versions are retained for time travel and rollback, which can grow disk usage.
+- Indexes may have newly added rows that are not yet fully optimized into the index structure.
+
+For local/OSS tables, run `optimize()` after the final successful ingestion
+write. Also run it after later batches of update/delete operations or on a
+regular maintenance schedule:
+
+```python
+table.optimize()
+```
+
+If the user wants more aggressive local disk cleanup, pass a shorter cleanup retention window:
+
+```python
+from datetime import timedelta
+
+table.optimize(cleanup_older_than=timedelta(days=1))
+```
+
+Do not use very short cleanup windows when the application depends on time travel, rollback, or old versions.
+
+## Diagnostics
+
+Before changing code or indexes, inspect:
+
+```python
+print(table.search(query_vector).where("year > 2000").limit(10).analyze_plan())
+print(table.index_stats("vector_idx"))
+```
+
+Look for high scan bytes, missing indexes, fragmented data, and unindexed rows.
+
+## Python Multiprocessing
+
+When using multiprocessing, use `spawn` rather than `fork`. LanceDB is multi-threaded internally, and `fork` plus a multi-threaded process is unsafe.
diff --git a/.agents/skills/lancedb/references/typescript/api_reference.md b/.agents/skills/lancedb/references/typescript/api_reference.md
new file mode 100644
index 000000000..aec07c9cb
--- /dev/null
+++ b/.agents/skills/lancedb/references/typescript/api_reference.md
@@ -0,0 +1,78 @@
+# TypeScript API Reference
+
+Quick method reference for TypeScript LanceDB code. Cross-check source for non-trivial claims.
+
+## Connect
+
+```typescript
+import * as lancedb from "@lancedb/lancedb";
+
+const db = await lancedb.connect("./camelot-db");
+```
+
+**Place the local database directory next to the script/entrypoint that opens it** (resolve the path relative to the module, e.g. via `import.meta.dirname` / `__dirname`), not buried under a shared `data/` folder. The Lance dataset is the database, not a data file — keeping it beside its code makes ownership obvious and paths stable regardless of the working directory the script is launched from.
+
+**Do not name the directory `lancedb`** (e.g. `./lancedb`, `./data/lancedb`). It collides with the imported `lancedb` package/namespace, which is confusing to read. Give it a name derived from the repo or dataset with a clear prefix/suffix — for example `./-db`, `./_lancedb`, or `./vectordb`.
+
+Remote connections use `db://...` plus Enterprise/Cloud credentials and deployment settings. Check current source/docs for exact connection options.
+
+## Table Reads
+
+| Task | Preferred API |
+| --- | --- |
+| Vector search | `table.search(queryVector).limit(k)` |
+| Full scan with filters/projection | `table.query().where(...).select(...).limit(...)` |
+| Filter | `.where("col > 10")` |
+| Projection | `.select(["id", "text"])` |
+| Bound result count | `.limit(20)` |
+| Collect bounded result as objects | `.toArray()` on query/search result |
+| Collect bounded result as Arrow | `.toArrow()` on query/search result |
+| Stream result batches | `for await (const batch of table.query()...)` |
+
+## Local vs Remote Safety
+
+| API | Agent guidance |
+| --- | --- |
+| `table.search(...)` | Preferred read path |
+| `table.query()` | Preferred scan/filter path |
+| `await table.toArrow()` | Avoid in portable or large-table code |
+| `await table.query().toArray()` with no `limit()` | Avoid; unbounded collection |
+| `await table.query().toArrow()` with no `limit()` | Avoid; unbounded collection |
+
+## Indexes
+
+```typescript
+await table.createIndex("vector");
+await table.createIndex("status");
+```
+
+Use vector indexes for large vector search workloads and scalar indexes for filtered columns or merge/upsert keys. Check source/docs before specifying advanced index options.
+
+## Filtering And Recall Knobs
+
+```typescript
+await table.search(queryVector).where("status = 'ready'").limit(10).toArray();
+await table.search(queryVector).limit(10).refineFactor(20).toArray();
+await table.search(queryVector).limit(10).nprobes(50).toArray();
+await table.search(queryVector).limit(10).ef(100).toArray();
+await table.search(queryVector).where("status = 'ready'").postfilter().limit(10).toArray();
+```
+
+Use `postfilter()` only when fewer than `limit` results are acceptable.
+
+## Diagnostics
+
+```typescript
+console.log(await table.search(queryVector).where("year > 2000").limit(10).analyzePlan());
+console.log(await table.indexStats("vector_idx"));
+```
+
+Use these before changing indexes or search tuning.
+
+## Maintenance
+
+```typescript
+await table.optimize();
+```
+
+Call this after every successful local/OSS ingestion. It handles compaction, cleanup of old versions according to retention, and index optimization. Do not add this for LanceDB Enterprise/Cloud remote tables; Enterprise handles compaction and cleanup automatically from cluster configuration.
diff --git a/.agents/skills/lancedb/references/typescript/patterns.md b/.agents/skills/lancedb/references/typescript/patterns.md
new file mode 100644
index 000000000..1aa380968
--- /dev/null
+++ b/.agents/skills/lancedb/references/typescript/patterns.md
@@ -0,0 +1,100 @@
+# TypeScript Patterns
+
+Use these patterns when writing TypeScript code with `@lancedb/lancedb`.
+
+## Recommended Patterns
+
+### Bounded query
+
+Use this for application reads, scripts, and examples:
+
+```typescript
+const rows = await table
+ .query()
+ .where("status = 'ready'")
+ .select(["id", "text"])
+ .limit(20)
+ .toArray();
+```
+
+### Bounded vector search
+
+```typescript
+const rows = await table
+ .search(queryVector)
+ .select(["id", "text"])
+ .limit(20)
+ .toArray();
+```
+
+### Batch streaming for larger reads
+
+When the task needs many rows, avoid collecting everything at once:
+
+```typescript
+for await (const batch of table
+ .query()
+ .where("status = 'ready'")
+ .select(["id", "text"])
+ .limit(10_000)) {
+ process(batch);
+}
+```
+
+## Anti-Patterns
+
+**Avoid the following anti-patterns in your code.**
+
+### Table-level full materialization
+
+Avoid whole-table collectors in portable or large-table code:
+
+```typescript
+const tableArrow = await table.toArrow();
+```
+
+Why: local tables expose these whole-table collectors, but remote tables intentionally do not — a remote production table can be far larger than a local development table, so it is easy to accidentally pull the entire table into memory.
+
+### Unbounded result collection
+
+Avoid query/search collection without a meaningful limit:
+
+```typescript
+const rows = await table.query().toArray(); // unbounded plain scan
+const rows = await table.search(queryVector).toArray(); // unbounded vector search
+```
+
+Prefer `select(...).limit(...)` before collecting; for large reads, stream in batches instead.
+
+### Per-row writes
+
+Avoid loops that write one row per call:
+
+```typescript
+for (const row of rows) {
+ await table.add([row]); // one commit + fragment per row
+}
+```
+
+Each `add()` creates a new version and fragment. Pass the whole batch in a single call, or chunk very large inputs:
+
+```typescript
+await table.add(rows); // single commit
+// for very large inputs, add in chunks of several thousand rows
+```
+
+### Drop-then-reuse the same table name (Enterprise/Cloud)
+
+Avoid dropping or overwriting a remote table and then reusing that name right away:
+
+```typescript
+await db.dropTable("my_table");
+const table = await db.createTable("my_table", rows); // reads 500 for ~5 min
+const table = await db.createTable("my_table", rows, { mode: "overwrite" }); // same problem
+```
+
+Why: Enterprise/Cloud splits DDL (control plane) from query serving (data plane). The data plane caches the dataset behind a table name for up to `table_cache_ttl` (default 300s / 5 min), so after a drop/overwrite the DDL succeeds but queries against the reused name return `500 Internal Server Error` until the cache expires — and a fresh `describe` may still show the old schema. Instead, write to a **fresh name**, use `tableNames()` and fail if it already exists, then `renameTable(fresh, final)` onto the final name only after the old table's drop has propagated (~5 min). See the "Enterprise: never drop-then-reuse the same table name" section in `SKILL.md`. Local/OSS tables have no separate data plane — overwrite freely there.
+
+### Guessing performance fixes
+
+Avoid changing `nprobes`, `refineFactor`, `ef`, or index settings before checking `analyzePlan()` and `indexStats(...)`. Diagnose first, then tune one knob at a time.
diff --git a/.agents/skills/lancedb/references/typescript/performance.md b/.agents/skills/lancedb/references/typescript/performance.md
new file mode 100644
index 000000000..9bf07e9ae
--- /dev/null
+++ b/.agents/skills/lancedb/references/typescript/performance.md
@@ -0,0 +1,78 @@
+# TypeScript Performance Guidance
+
+Use this when writing TypeScript code that ingests data, queries large tables, builds indexes, or investigates latency.
+
+## Ingestion
+
+- Prefer bulk or batched writes.
+- Avoid per-row write loops; they create many small commits/fragments.
+- For generated data, accumulate reasonable batches before adding.
+- For file-backed data, prefer APIs that stream from Arrow/Parquet-style inputs when available.
+
+## Indexing
+
+- Build a vector index once brute-force vector search becomes too slow. As a rule of thumb, local brute force is fine below roughly 100K vectors; beyond that, build an index.
+- Use the general-purpose vector index defaults unless the task has explicit recall/latency requirements.
+- Build scalar indexes for filtered columns and merge/upsert keys.
+- Use full-text index phrase options only when phrase queries require them.
+
+## Querying
+
+Always be explicit:
+
+```typescript
+await table.search(queryVector).select(["id", "title"]).limit(20).toArray();
+```
+
+- `select()` reduces bytes read and transferred.
+- `limit()` prevents accidental full-table collection.
+- Pre-filtering is the default behavior. Use `postfilter()` only when fewer than `limit` results are acceptable.
+
+## Recall Tuning
+
+Tune one knob at a time:
+
+- Quantized indexes: raise `refineFactor(...)` to rescore more candidates on full vectors.
+- HNSW-backed indexes: raise `ef(...)`; start around `1.5 * k`, increase toward `10 * k` if recall is short.
+- IVF candidate breadth: `nprobes(...)` is usually auto-tuned; override only when a selective pre-filter leaves too few neighbors.
+
+## Maintenance
+
+After every successful embedded OSS/local ingestion, call `table.optimize()`.
+Do not add this to LanceDB Enterprise/Cloud remote table code; remote compaction
+and cleanup are handled automatically based on the Enterprise cluster
+configuration.
+
+Why local maintenance is needed:
+
+- Frequent writes can create many small fragments. Queries then need to scan across more files, which can increase latency.
+- Updates, deletes, and appends create new table versions. Old versions are retained for time travel and rollback, which can grow disk usage.
+- Indexes may have newly added rows that are not yet fully optimized into the index structure.
+
+For local/OSS tables, run `optimize()` after the final successful ingestion
+write. Also run it after later batches of update/delete operations or on a
+regular maintenance schedule:
+
+```typescript
+await table.optimize();
+```
+
+If the user wants more aggressive local disk cleanup, pass a shorter cleanup retention window:
+
+```typescript
+const olderThan = new Date(Date.now() - 24 * 60 * 60 * 1000);
+await table.optimize({ cleanupOlderThan: olderThan });
+```
+
+Do not use very short cleanup windows when the application depends on time travel, rollback, or old versions.
+
+## Diagnostics
+
+Before changing code or indexes, inspect:
+
+```typescript
+console.log(await table.search(queryVector).where("year > 2000").limit(10).analyzePlan());
+console.log(await table.indexStats("vector_idx"));
+```
+
+Look for high scan cost, missing indexes, fragmented data, and unindexed rows.
diff --git a/.agents/skills/lancedb/scripts/check_materialization.py b/.agents/skills/lancedb/scripts/check_materialization.py
new file mode 100644
index 000000000..cbd8abc04
--- /dev/null
+++ b/.agents/skills/lancedb/scripts/check_materialization.py
@@ -0,0 +1,135 @@
+#!/usr/bin/env python3
+"""Scan Python and TypeScript for likely unsafe LanceDB materialization."""
+
+from __future__ import annotations
+
+import argparse
+import re
+import sys
+from dataclasses import dataclass
+from pathlib import Path
+
+
+PY_FULL_TABLE = re.compile(r"\b\w+\.(to_pandas|to_arrow|to_polars)\s*\(")
+TS_TABLE_TO_ARROW = re.compile(r"\b\w+\.toArrow\s*\(")
+TS_QUERY_COLLECTOR = re.compile(r"\.query\s*\(\s*\)[\s\S]*?\.to(Array|Arrow)\s*\(")
+
+
+@dataclass
+class Finding:
+ path: Path
+ line: int
+ message: str
+ text: str
+
+
+def iter_files(paths: list[Path]) -> list[Path]:
+ files: list[Path] = []
+ for path in paths:
+ if path.is_dir():
+ files.extend(
+ p
+ for p in path.rglob("*")
+ if p.suffix in {".py", ".ts", ".tsx"} and "node_modules" not in p.parts
+ )
+ elif path.suffix in {".py", ".ts", ".tsx"}:
+ files.append(path)
+ return sorted(set(files))
+
+
+def line_number(text: str, offset: int) -> int:
+ return text.count("\n", 0, offset) + 1
+
+
+def scan_python(path: Path, text: str) -> list[Finding]:
+ findings: list[Finding] = []
+ for match in PY_FULL_TABLE.finditer(text):
+ line_start = text.rfind("\n", 0, match.start()) + 1
+ line_end = text.find("\n", match.start())
+ if line_end == -1:
+ line_end = len(text)
+ line = text[line_start:line_end].strip()
+ if ".search(" in line or ".query(" in line:
+ continue
+ findings.append(
+ Finding(
+ path,
+ line_number(text, match.start()),
+ f"Review Python `{match.group(1)}()` call; table-level materialization is not portable to remote tables.",
+ line,
+ )
+ )
+ return findings
+
+
+def statement_around(text: str, start: int, end: int) -> str:
+ before = max(text.rfind(";", 0, start), text.rfind("\n\n", 0, start))
+ after_candidates = [pos for pos in (text.find(";", end), text.find("\n\n", end)) if pos != -1]
+ after = min(after_candidates) if after_candidates else len(text)
+ return text[before + 1 : after].strip()
+
+
+def scan_typescript(path: Path, text: str) -> list[Finding]:
+ findings: list[Finding] = []
+ for match in TS_TABLE_TO_ARROW.finditer(text):
+ stmt = statement_around(text, match.start(), match.end())
+ if ".query(" in stmt or ".search(" in stmt:
+ continue
+ findings.append(
+ Finding(
+ path,
+ line_number(text, match.start()),
+ "Review TypeScript `table.toArrow()`-style call; table-level materialization is not portable for large/remote tables.",
+ stmt.splitlines()[0].strip(),
+ )
+ )
+
+ for match in TS_QUERY_COLLECTOR.finditer(text):
+ stmt = statement_around(text, match.start(), match.end())
+ if ".limit(" in stmt:
+ continue
+ findings.append(
+ Finding(
+ path,
+ line_number(text, match.start()),
+ "Review unbounded TypeScript query collection; add `limit()` or stream batches.",
+ stmt.splitlines()[0].strip(),
+ )
+ )
+ return findings
+
+
+def scan_file(path: Path) -> list[Finding]:
+ text = path.read_text(encoding="utf-8", errors="replace")
+ if path.suffix == ".py":
+ return scan_python(path, text)
+ if path.suffix in {".ts", ".tsx"}:
+ return scan_typescript(path, text)
+ return []
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("paths", nargs="+", type=Path)
+ parser.add_argument(
+ "--no-fail", action="store_true", help="Always exit 0 after reporting findings."
+ )
+ args = parser.parse_args()
+
+ findings: list[Finding] = []
+ for path in iter_files(args.paths):
+ findings.extend(scan_file(path))
+
+ for finding in findings:
+ print(f"{finding.path}:{finding.line}: {finding.message}")
+ print(f" {finding.text}")
+
+ if findings:
+ print(
+ f"\n{len(findings)} finding(s). Review manually; bounded query result conversion may be OK."
+ )
+ return 0 if args.no_fail or not findings else 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())