diff --git a/.github/styles/config/vocabularies/VGI/accept.txt b/.github/styles/config/vocabularies/VGI/accept.txt
new file mode 100644
index 0000000..555b9da
--- /dev/null
+++ b/.github/styles/config/vocabularies/VGI/accept.txt
@@ -0,0 +1,41 @@
+VGI
+vgi
+[Vv]gi-python
+[Vv]gi-rpc
+DuckDB
+Arrow
+[Aa]pache Arrow
+Haybarn
+pyarrow
+PyArrow
+RecordBatch
+RecordBatches
+mkdocstrings
+MkDocs
+uv
+uvx
+subprocess
+scalar
+[Aa]ggregations?
+[Dd]eserialize
+[Ss]erializable
+Diátaxis
+ATTACH
+classmethod
+dataclass
+runnable
+bool
+config
+[Mm]etadata
+namespace
+namespaces
+struct
+async
+stdin
+stdout
+optimizer
+[Pp]ushdown
+JWT
+HTTP
+OAuth
+TTL
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 491b74f..471cea6 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -65,6 +65,50 @@ jobs:
run: uv run ty check vgi/
continue-on-error: true
+ docs:
+ name: Docs (build + examples + prose)
+ runs-on: ubuntu-latest
+ env:
+ DISABLE_MKDOCS_2_WARNING: "true"
+ steps:
+ - uses: actions/checkout@v5
+
+ - name: Install uv
+ uses: astral-sh/setup-uv@v8.2.0
+
+ - name: Set up Python 3.13
+ run: uv python install 3.13
+
+ - name: Install dependencies
+ run: uv sync --all-extras --group docs
+
+ - name: Install d2
+ run: curl -fsSL https://d2lang.com/install.sh | sh -s --
+
+ - name: Build docs (strict)
+ run: uv run mkdocs build --strict
+
+ - name: Test documentation examples
+ run: uv run pytest tests/test_documentation_examples.py tests/test_examples_workers.py -q
+
+ # Prose lint. Runs on every PR as a signal. Kept non-blocking for now:
+ # the Google package + spelling check needs a vocabulary-tuning pass
+ # against a real run before it can gate without false positives. Flip
+ # fail_on_error to true (and drop continue-on-error) once the vocab in
+ # .github/styles/config/vocabularies/VGI/accept.txt is settled.
+ - name: Prose lint (Vale)
+ uses: errata-ai/vale-action@v2
+ continue-on-error: true
+ with:
+ files: docs
+ fail_on_error: true
+
+ - name: Link check (lychee)
+ uses: lycheeverse/lychee-action@v2
+ with:
+ args: "--no-progress --offline docs/**/*.md *.md"
+ fail: true
+
s3-offload-localstack:
name: S3 Offload Tests (LocalStack)
runs-on: ubuntu-latest
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
new file mode 100644
index 0000000..5ef1d7e
--- /dev/null
+++ b/.github/workflows/docs.yml
@@ -0,0 +1,46 @@
+name: Docs
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - "docs/**"
+ - "mkdocs.yml"
+ - "vgi/**"
+ - "pyproject.toml"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: pages
+ cancel-in-progress: true
+
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v5
+
+ - name: Install uv
+ uses: astral-sh/setup-uv@v8.1.0
+
+ - name: Set up Python
+ run: uv python install 3.13
+
+ - name: Install dependencies
+ run: uv sync --group docs
+
+ - name: Install d2
+ run: curl -fsSL https://d2lang.com/install.sh | sh -s --
+
+ - name: Build docs
+ run: uv run mkdocs build --strict
+
+ - name: Deploy to Cloudflare Pages
+ uses: cloudflare/wrangler-action@v3
+ with:
+ apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+ accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+ command: pages deploy site --project-name=vgi-python-docs --commit-dirty=true
diff --git a/.gitignore b/.gitignore
index 101c370..bc1c35f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,6 +6,11 @@ dist/
wheels/
*.egg-info
+# MkDocs build output
+/site/
+# MkDocs plugin caches (d2 diagram render cache)
+/.cache/
+
# Virtual environments
.venv
diff --git a/.vale.ini b/.vale.ini
new file mode 100644
index 0000000..50f4422
--- /dev/null
+++ b/.vale.ini
@@ -0,0 +1,19 @@
+# Vale prose-lint config for vgi-python docs.
+# Run locally with: vale docs/ (after `vale sync` to fetch the Google package)
+StylesPath = .github/styles
+
+# Only fail on errors. The Google package emits many style suggestions/warnings;
+# gating on those would be noisy, so the CI gate enforces error-level issues
+# (Vale core checks + spelling against the VGI vocabulary) only.
+MinAlertLevel = error
+
+Packages = Google
+
+Vocab = VGI
+
+[*.md]
+BasedOnStyles = Vale, Google
+
+# Snippet directives and our auto-generated API pages aren't prose to lint.
+[docs/api/*]
+BasedOnStyles =
diff --git a/DOCS_ACCEPTANCE_CRITERIA.md b/DOCS_ACCEPTANCE_CRITERIA.md
new file mode 100644
index 0000000..0853b9f
--- /dev/null
+++ b/DOCS_ACCEPTANCE_CRITERIA.md
@@ -0,0 +1,122 @@
+# VGI-Python Documentation — Acceptance Criteria (review-ready v1)
+
+> Status: DRAFT for senior DX-engineer review. Derived from a requirements interview.
+> This document defines what "done and good" means for the documentation rework
+> before the site goes live at `vgi-python.query.farm`.
+
+## North Star
+
+**A developer who has never used VGI can build and run a real worker fast.**
+Everything on the site is optimized around that job-to-be-done; depth is available
+but never blocks the fast path.
+
+## Target audience (mixed — serve all via progressive disclosure)
+
+The reader could be a Python developer new to DuckDB/Arrow, a DuckDB/SQL user newer
+to Python, or someone fluent in both. Therefore:
+
+- The happy path is skimmable by experts (dense, copy-paste-ready).
+- Newcomers are served by **progressive disclosure**: inline "New to Arrow? →" /
+ "New to DuckDB extensions? →" callouts and links, not walls of prerequisite text.
+- We never assume knowledge silently; we either explain briefly or link out.
+
+## Information architecture — Diátaxis
+
+Top-level navigation is reorganized into the four Diátaxis modes (this directly
+addresses the current "hard to orient" problem):
+
+1. **Tutorial** — one guided, end-to-end "build your first worker" path.
+2. **How-to guides** — task-oriented recipes ("Add a table function", "Run over
+ HTTP with auth", "Persist aggregate state").
+3. **Concepts** — explanations: worker lifecycle (bind/init/process/finalize),
+ transports, the Arrow data model, catalogs & ATTACH, parallel workers.
+4. **API Reference** — the existing auto-generated mkdocstrings pages.
+
+The current 11 hand-written guides are **re-homed** into How-to vs Concepts (not
+left in a flat "Guides" bucket).
+
+## Scope
+
+### In scope for v1 (must be fully documented: tutorial coverage + how-to + runnable example)
+
+- **All four function patterns**: scalar, table, table-in-out, aggregate.
+- **Catalogs / ATTACH model** — how functions are surfaced to DuckDB.
+- **State storage** — `FunctionStorage` backends for stateful/aggregate functions.
+- **Auth + HTTP transport** — running a worker over HTTP with bearer/JWT auth.
+- **Filter pushdown & column statistics** — optimizer integration for table functions.
+
+### Out of scope for v1 (reference-only / deferred — must NOT block launch)
+
+- Transactor (transactional DB access)
+- External storage / large-payload offload (S3/GCS)
+- Observability (OpenTelemetry / Sentry)
+- Sharding / meta-worker, cross-language client codegen, standalone secret service
+
+These remain available in the auto-generated API reference but get no tutorial/how-to
+investment in v1.
+
+## Headline acceptance test (Time-To-First-Success)
+
+> **An unfamiliar developer, working unaided from the docs, has both a custom
+> scalar function AND a custom table function callable from DuckDB within
+> ≤20 minutes.**
+
+- "Callable from DuckDB" = `SELECT my_cat.my_scalar(col) FROM t` returns rows, and
+ `SELECT * FROM my_cat.my_table(args)` returns rows.
+- Engine for the timed path: **Haybarn** (`uvx haybarn-cli`) as the primary happy
+ path; a stock-DuckDB variant (`INSTALL vgi FROM community; LOAD vgi;`) shown in a
+ callout/tab for portability.
+- Every place a test participant gets stuck is logged and fixed before sign-off.
+
+## Per-page orientation standard (applies to every tutorial / how-to / concept page)
+
+Each page must contain:
+
+1. **Lead "what + who" line** — one sentence at the top: what this page is and who
+ it's for (reader self-orients in <10 s).
+2. **Prerequisites stated** — explicit assumed knowledge, prior steps, and required
+ extras (`vgi-python[http]`, etc.), with links.
+3. **At least one complete, runnable example** — no elisions; covered by the CI
+ example tests (see Quality Gates).
+4. **"Next steps" links** — a closing section pointing to the logical next page(s);
+ no dead ends.
+
+## Example correctness bar
+
+- **100% of Python code blocks are copy-paste runnable and CI-tested** (e.g. via
+ `pytest-examples`, already a dev dependency).
+- The tutorial worker is **built and queried end-to-end in an automated test**.
+- A broken example fails the build.
+
+## Quality gates (all three required to sign off v1)
+
+1. **Fresh-dev usability test** — ≥1 developer unfamiliar with VGI completes the
+ headline acceptance test (scalar + table from DuckDB, ≤20 min, unaided). All
+ stumbling points resolved.
+2. **Senior DX reviewer rubric** — named senior DX engineer(s) score the site
+ against a written checklist: orientation, scannability, completeness vs the
+ in-scope list, correctness, navigation, and the per-page standard above. All
+ must-fix items resolved before merge.
+3. **Automated quality gates in CI**:
+ - `mkdocs build --strict` passes with zero warnings (no broken links / refs).
+ - All documentation examples execute successfully.
+ - Link check + prose/style lint pass.
+
+## Definition of Done (v1)
+
+- [ ] Diátaxis nav live (Tutorial / How-to / Concepts / API Reference); existing
+ guides re-homed.
+- [ ] Guided tutorial takes a reader from zero → scalar + table function queried
+ from Haybarn, with the stock-DuckDB variant noted.
+- [ ] How-to + runnable example exists for each in-scope topic (4 patterns +
+ catalogs + state storage + auth/HTTP + pushdown/stats).
+- [ ] Concept pages cover lifecycle, transports, Arrow model, catalogs, parallelism.
+- [ ] Every page meets the 4-point orientation standard.
+- [ ] All examples runnable and CI-tested; tutorial validated end-to-end in CI.
+- [ ] Out-of-scope topics confined to reference; not advertised as v1 guides.
+- [ ] All three quality gates passed and signed off.
+
+## Open items to confirm with reviewers
+
+- Named senior DX reviewer(s) and the recruited fresh-dev test participant.
+- Final wording/threshold of the prose-style lint (e.g. Vale ruleset), if adopted.
diff --git a/DOCS_REVIEW_RUBRIC.md b/DOCS_REVIEW_RUBRIC.md
new file mode 100644
index 0000000..e3408fa
--- /dev/null
+++ b/DOCS_REVIEW_RUBRIC.md
@@ -0,0 +1,67 @@
+# vgi-python docs — senior DX review rubric
+
+> Status: review checklist for the review-ready v1 of the documentation
+> (see `DOCS_ACCEPTANCE_CRITERIA.md`). A reviewer scores each item Pass / Fix /
+> N/A. Every **Fix** must be resolved (or explicitly waived) before the site
+> goes live at `vgi-python.query.farm`.
+
+Reviewer: ________________ Date: ________________ Commit: ________________
+
+## 1. Orientation (the problem this rework targets)
+
+- [ ] The home page makes it obvious in <10 s what vgi-python is and where to start.
+- [ ] Top-level nav clearly separates **Tutorial / How-to / Concepts / API Reference**
+ (Diátaxis); a newcomer can tell which to open for their need.
+- [ ] Every tutorial/how-to/concept page opens with a **"what + who"** line.
+- [ ] No page is a dead end — each ends with **"Next steps"** links.
+
+## 2. The fast path (job-to-be-done: ship a worker fast)
+
+- [ ] The tutorial gets a reader from zero → a **scalar + table** function callable from
+ DuckDB, and is realistically completable in **≤20 minutes**.
+- [ ] The first step yields a working query quickly (scalar before table).
+- [ ] Haybarn is the primary path; the stock-DuckDB variant is present and correct.
+- [ ] Copy-paste works: the worker shown is complete and runnable as-is.
+
+## 3. Completeness vs. the in-scope list
+
+- [ ] All four function patterns are documented with a runnable example: scalar, table,
+ table-in-out, aggregate.
+- [ ] Catalogs / ATTACH, state storage, auth + HTTP, and filter pushdown & stats each have a
+ how-to.
+- [ ] Out-of-scope topics (transactor, external storage, observability, sharding/codegen/secret
+ service) are reference-only and not advertised as v1 guides.
+
+## 4. Correctness
+
+- [ ] Every code example is accurate and runs (CI: `test_documentation_examples.py` +
+ `test_examples_workers.py` green).
+- [ ] SQL snippets use correct catalog/function names and match the worker shown.
+- [ ] Conceptual claims (lifecycle phases, transports, Arrow semantics) are accurate.
+- [ ] API reference renders for every in-scope module (CI: `mkdocs build --strict` green).
+
+## 5. Scannability & progressive disclosure
+
+- [ ] Pages use headings, tables, and short paragraphs; an expert can skim.
+- [ ] Newcomer background is in collapsible callouts, not blocking the main flow.
+- [ ] Prerequisites and required extras (`[http]`, `[oauth]`, …) are stated where needed.
+
+## 6. Navigation & polish
+
+- [ ] No broken links (CI: lychee + strict build green).
+- [ ] Search returns sensible results for common terms (worker, scalar, aggregate, ATTACH).
+- [ ] Light/dark themes, logo, and code-copy all work.
+
+## Automated gates (must be green at review time)
+
+- [ ] `mkdocs build --strict` — zero warnings
+- [ ] `pytest tests/test_documentation_examples.py tests/test_examples_workers.py`
+- [ ] lychee link-check
+- [ ] Vale prose lint (advisory until vocab is tuned; note residual warnings)
+
+## Sign-off
+
+- [ ] All **Fix** items resolved or waived (waivers noted below).
+- [ ] Fresh-dev usability test passed (see `DOCS_USABILITY_TEST.md`).
+
+Waivers / notes:
diff --git a/DOCS_USABILITY_TEST.md b/DOCS_USABILITY_TEST.md
new file mode 100644
index 0000000..cd3f095
--- /dev/null
+++ b/DOCS_USABILITY_TEST.md
@@ -0,0 +1,71 @@
+# vgi-python docs — fresh-dev usability test
+
+> The headline acceptance gate: an unfamiliar developer, working **only** from the
+> docs, builds and runs a worker. This protocol scripts that test and captures
+> what to fix. See `DOCS_ACCEPTANCE_CRITERIA.md` for the full criteria.
+
+## Goal (pass condition)
+
+> A developer **new to VGI**, working **unaided** from the published docs, reaches a custom
+> **scalar AND table** function callable from DuckDB in **≤20 minutes**.
+
+"Callable from DuckDB" means both:
+
+- `SELECT greetings.greeting('Alice')` returns `Hello, Alice!`, and
+- `SELECT * FROM greetings.greeting_series(3)` returns 3 rows.
+
+## Participant criteria
+
+- Comfortable writing Python; **has not** used VGI before.
+- A mix is ideal across runs: at least one participant new to DuckDB extensions, and at least
+ one new to Apache Arrow (this is the "serve all" audience we're validating).
+- Has Python 3.13+ and `uv` installed (or we install them first, off the clock).
+
+## Facilitator rules
+
+- **Do not help.** Point the participant at the docs home page and the timer; then observe in
+ silence. Answer only "I can't help with that — what would you try?"
+- Record the clock at each milestone and **every** point of confusion verbatim.
+- Capture the participant's words ("I don't know what a catalog is here") — those become doc fixes.
+
+## Script
+
+1. Start screen recording (or take notes) and the timer.
+2. Give the participant only this: *"Using the vgi-python docs, build a worker that adds two
+ functions to DuckDB — one that greets a name, and one that generates a series of greetings —
+ and run both from SQL. Start at the docs home page."*
+3. Observe until success or 30 minutes elapsed (let them run past 20 so we learn where the tail is).
+4. Debrief: what was confusing, what was missing, what they expected to find and didn't.
+
+## Milestones (record the time reached)
+
+| Milestone | Time | Notes |
+|---|---|---|
+| Found the tutorial / starting point | | |
+| Worker file written | | |
+| SQL engine launched (Haybarn or DuckDB) | | |
+| Worker attached (`ATTACH …`) | | |
+| **Scalar query returned a result** | | |
+| **Table query returned rows** | | |
+| Total time to success (or DNF) | | |
+
+## Stumble log
+
+| # | Where (page / step) | What happened | Severity (block/slow/nit) | Fix |
+|---|---|---|---|---|
+| 1 | | | | |
+| 2 | | | | |
+| 3 | | | | |
+
+## Outcome
+
+- [ ] Passed (≤20 min, unaided, both functions) — participant: ____________ time: ______
+- [ ] Did not pass — root cause: ____________________________________________
+
+**Every block/slow stumble must produce a doc fix (or an explicit waiver) before sign-off.**
+
+## Runs
+
+| Date | Participant background | Result | Time | Fixes filed |
+|---|---|---|---|---|
+| | | | | |
diff --git a/README.md b/README.md
index e3b9901..853480a 100644
--- a/README.md
+++ b/README.md
@@ -13,6 +13,10 @@
Created by Query.Farm
+
---
## See It in Action
@@ -112,7 +116,7 @@ VGI lets you extend DuckDB with Python functions that run in separate processes,
| Traditional Extensions | VGI Workers |
|----------------------|-------------|
-| C/C++ compilation required | Any language but first Python and Typescript and Go |
+| C/C++ compilation required | Any language with an Apache Arrow library |
| Tied to DuckDB version | Version independent |
| Complex build/release cycle | Ship a script or executable |
| Runs in-process | Process isolation |
diff --git a/docs/api/arguments.md b/docs/api/arguments.md
new file mode 100644
index 0000000..664ad9b
--- /dev/null
+++ b/docs/api/arguments.md
@@ -0,0 +1,17 @@
+# Arguments & Schema
+
+Function signatures are declared with the argument types below. The framework derives Arrow
+schemas from these declarations and validates inputs before your function runs. See the
+[Argument Serialization](../argument-serialization.md) guide for how these map to the wire.
+
+## Arguments
+
+::: vgi.arguments
+
+## Argument specifications
+
+::: vgi.argument_spec
+
+## Schema helpers
+
+::: vgi.schema_utils
diff --git a/docs/api/auth.md b/docs/api/auth.md
new file mode 100644
index 0000000..b96ca27
--- /dev/null
+++ b/docs/api/auth.md
@@ -0,0 +1,21 @@
+# Auth & Secrets
+
+HTTP workers authenticate requests via a pluggable callback that populates `CallContext.auth` with
+an `AuthContext`. Bearer-token and JWT/JWKS authenticators ship in `vgi.auth`. Secrets (credentials)
+flow through the secret protocol so workers never see raw values unless explicitly resolved. See the
+[Authentication](../authentication.md) guide.
+
+`AuthContext` and `CallContext` are re-exported from `vgi-rpc`. The bearer/chain authenticators
+require `pip install vgi-python[http]`; JWT authentication additionally requires `[oauth]`.
+
+## Auth
+
+::: vgi.auth
+
+## Secret protocol
+
+::: vgi.secret_protocol
+
+## Secret service
+
+::: vgi.secret_service
diff --git a/docs/api/catalogs.md b/docs/api/catalogs.md
new file mode 100644
index 0000000..6bb5c7c
--- /dev/null
+++ b/docs/api/catalogs.md
@@ -0,0 +1,7 @@
+# Catalogs
+
+A worker can expose a database-like catalog — schemas, tables, views, macros, indexes, secrets,
+and settings — so DuckDB can `ATTACH` it. See the [Catalog Interface](../catalog-interface.md)
+guide for the conceptual overview.
+
+::: vgi.catalog
diff --git a/docs/api/client.md b/docs/api/client.md
new file mode 100644
index 0000000..d3e5f51
--- /dev/null
+++ b/docs/api/client.md
@@ -0,0 +1,6 @@
+# Client
+
+The Python `Client` spawns or connects to a worker, streams Arrow data to and from its functions,
+and surfaces errors as `ClientError`. It is the pure-Python counterpart to the DuckDB extension.
+
+::: vgi.client
diff --git a/docs/api/exceptions.md b/docs/api/exceptions.md
new file mode 100644
index 0000000..93ef46b
--- /dev/null
+++ b/docs/api/exceptions.md
@@ -0,0 +1,8 @@
+# Exceptions
+
+VGI raises typed exceptions for binding, catalog, schema, and execution errors. Several
+validation errors also live alongside the features they guard (see
+[Arguments](arguments.md#vgi.arguments.ArgumentValidationError) and
+[Metadata](metadata.md)).
+
+::: vgi.exceptions
diff --git a/docs/api/filters.md b/docs/api/filters.md
new file mode 100644
index 0000000..59f9bc0
--- /dev/null
+++ b/docs/api/filters.md
@@ -0,0 +1,8 @@
+# Filter Pushdown
+
+Table functions can receive SQL `WHERE` predicates pushed down from DuckDB, letting the worker
+prune data at the source. Filters arrive as a `PushdownFilters` tree of typed nodes; use
+`deserialize_filters` to decode them. See the [Filter Pushdown](../filter-pushdown.md) guide for
+the protocol and a worked example.
+
+::: vgi.table_filter_pushdown
diff --git a/docs/api/functions.md b/docs/api/functions.md
new file mode 100644
index 0000000..70dddc7
--- /dev/null
+++ b/docs/api/functions.md
@@ -0,0 +1,32 @@
+# Functions
+
+VGI exposes four function patterns. Pick the one that matches how your data flows:
+
+| Pattern | Shape | Base class |
+|---|---|---|
+| **Scalar** | 1 row in → 1 row out | [`ScalarFunction`](#vgi.scalar_function.ScalarFunction) / `ScalarFunctionGenerator` |
+| **Table** | no input → rows out | [`TableFunctionGenerator`](#vgi.table_function.TableFunctionGenerator) |
+| **Table-in-out** | rows in → rows out (streaming) | [`TableInOutFunction`](#vgi.table_in_out_function.TableInOutFunction) / `TableInOutGenerator` |
+| **Aggregate** | grouped rows → one row per group | [`AggregateFunction`](#vgi.aggregate_function.AggregateFunction) |
+
+All four ultimately derive from the shared [`Function`](#vgi.function.Function) base.
+
+## Scalar functions
+
+::: vgi.scalar_function
+
+## Table functions
+
+::: vgi.table_function
+
+## Table-in-out functions
+
+::: vgi.table_in_out_function
+
+## Aggregate functions
+
+::: vgi.aggregate_function
+
+## Function base
+
+::: vgi.function
diff --git a/docs/api/http.md b/docs/api/http.md
new file mode 100644
index 0000000..3a8146f
--- /dev/null
+++ b/docs/api/http.md
@@ -0,0 +1,6 @@
+# HTTP
+
+HTTP-transport utilities: a human-facing worker info page, demo blob storage for externalized
+payloads, and request-size middleware. Requires `pip install vgi-python[http]`.
+
+::: vgi.http
diff --git a/docs/api/index.md b/docs/api/index.md
new file mode 100644
index 0000000..b894dfc
--- /dev/null
+++ b/docs/api/index.md
@@ -0,0 +1,47 @@
+---
+description: "vgi-python API reference — functions, arguments, worker, client, catalogs, state storage, filter pushdown, auth, and observability."
+---
+
+# API Reference
+
+## Where to Start
+
+New to VGI? Follow this path:
+
+1. **Pick a function pattern** — `ScalarFunction`, `TableFunctionGenerator`,
+ `TableInOutFunction`, or `AggregateFunction` ([Functions](functions.md))
+2. **Declare arguments** — `Param`, `ConstParam`, `Returns`, `TableInput` ([Arguments & Schema](arguments.md))
+3. **Host them in a worker** — subclass `Worker`, then `vgi-serve` it over stdio or HTTP ([Worker & Serving](worker.md))
+4. **Connect from DuckDB or Python** — the DuckDB extension or the Python `Client` ([Client](client.md))
+
+Everything else — catalogs, state storage, filter pushdown, auth, observability — is optional and
+added incrementally.
+
+## Modules
+
+| Module | Description | Required? |
+|---|---|---|
+| [Functions](functions.md) | `ScalarFunction`, `TableFunctionGenerator`, `TableInOutFunction`, `AggregateFunction`, `Function` | Yes |
+| [Arguments & Schema](arguments.md) | `Param`, `ConstParam`, `Returns`, `TableInput`, `ArgumentSpec`, `schema` | Yes |
+| [Worker & Serving](worker.md) | `Worker`, the `vgi-serve` entry point | Yes |
+| [Client](client.md) | `Client`, `ClientError`, catalog client helpers | If calling workers from Python |
+| [Catalogs](catalogs.md) | `Catalog`, `Schema`, `Table`, `View`, `CatalogStorage` | If exposing a catalog |
+| [State Storage](storage.md) | `FunctionStorage`, SQLite / Azure SQL / Cloudflare DO backends | If functions keep state |
+| [Metadata & Protocol](metadata.md) | `ResolvedMetadata`, `FunctionExample`, `FunctionStability`, protocol types | For introspection |
+| [Filter Pushdown](filters.md) | `PushdownFilters`, filter node types, `deserialize_filters` | If accepting pushed-down filters |
+| [Auth & Secrets](auth.md) | `AuthContext`, `CallContext`, bearer/JWT authenticators, secret protocol | HTTP: `[http]`; JWT: `[oauth]` |
+| [Observability](observability.md) | OpenTelemetry tracing, worker logging configuration | `[otel]` for tracing |
+| [HTTP](http.md) | Worker page, blob storage, request-size middleware | `pip install vgi-python[http]` |
+| [Transactor](transactor.md) | `TransactorClient`, `TransactorProtocol` | `pip install vgi-python[transactor]` |
+| [Exceptions](exceptions.md) | VGI exception types | — |
+
+## Import Convention
+
+The most common symbols are re-exported from the top-level `vgi` package:
+
+```python
+from vgi import ScalarFunction, TableInOutFunction, AggregateFunction, Param, Returns, Worker
+```
+
+Subpackages (`vgi.catalog`, `vgi.client`, `vgi.http`, `vgi.transactor`) are imported explicitly.
+Optional modules require their corresponding extras to be installed.
diff --git a/docs/api/metadata.md b/docs/api/metadata.md
new file mode 100644
index 0000000..a77a10b
--- /dev/null
+++ b/docs/api/metadata.md
@@ -0,0 +1,18 @@
+# Metadata & Protocol
+
+Functions describe themselves through metadata — stability, examples, parameter info, ordering and
+null semantics — which DuckDB reads for introspection and the query optimizer. The protocol and
+invocation types model the request/response lifecycle on the wire. See the
+[Metadata](../metadata.md) guide for authoring metadata via nested `Meta` classes.
+
+## Metadata
+
+::: vgi.metadata
+
+## Invocation lifecycle
+
+::: vgi.invocation
+
+## Protocol
+
+::: vgi.protocol
diff --git a/docs/api/observability.md b/docs/api/observability.md
new file mode 100644
index 0000000..bda42e5
--- /dev/null
+++ b/docs/api/observability.md
@@ -0,0 +1,19 @@
+# Observability
+
+!!! note "Advanced — reference only"
+ OpenTelemetry tracing and Sentry reporting are advanced, opt-in features without a dedicated
+ how-to guide yet. This page is the API reference.
+
+Workers can emit OpenTelemetry traces and structured logs. Tracing is a no-op unless the `[otel]`
+extra is installed and a tracer provider is configured; logging configuration helpers shape worker
+log output for the CLIs.
+
+## OpenTelemetry
+
+Requires `pip install vgi-python[otel]` for live tracing; otherwise a no-op tracer is used.
+
+::: vgi.otel
+
+## Logging configuration
+
+::: vgi.logging_config
diff --git a/docs/api/storage.md b/docs/api/storage.md
new file mode 100644
index 0000000..d7e08ce
--- /dev/null
+++ b/docs/api/storage.md
@@ -0,0 +1,20 @@
+# State Storage
+
+Stateful functions (notably distributed aggregates) persist per-group state through a
+`FunctionStorage` backend so it survives across worker invocations and processes. The default is
+SQLite; Azure SQL and Cloudflare Durable Object backends are available for multi-worker
+deployments.
+
+## Function storage
+
+::: vgi.function_storage
+
+## Azure SQL backend
+
+Requires `pip install vgi-python[azure]`.
+
+::: vgi.function_storage_azure_sql
+
+## Cloudflare Durable Object backend
+
+::: vgi.function_storage_cf_do
diff --git a/docs/api/transactor.md b/docs/api/transactor.md
new file mode 100644
index 0000000..e9711c8
--- /dev/null
+++ b/docs/api/transactor.md
@@ -0,0 +1,12 @@
+# Transactor
+
+!!! note "Advanced — reference only"
+ The transactor is an advanced feature without a dedicated how-to guide yet. This page is the
+ API reference; start from the [tutorial](../tutorial/index.md) and
+ [function patterns](../how-to/function-patterns.md) if you're new to VGI.
+
+The transactor is a long-lived subprocess that gives worker functions transactional access to a
+database, mediated by `TransactorClient` over the `TransactorProtocol`. Requires
+`pip install vgi-python[transactor]`.
+
+::: vgi.transactor
diff --git a/docs/api/worker.md b/docs/api/worker.md
new file mode 100644
index 0000000..bef01d8
--- /dev/null
+++ b/docs/api/worker.md
@@ -0,0 +1,23 @@
+# Worker & Serving
+
+A `Worker` hosts your functions (and optional catalog) in a separate process. It speaks the VGI
+protocol over Arrow IPC — either stdin/stdout (subprocess transport) or HTTP. The `vgi-serve`
+CLI (`vgi.serve`) is the zero-boilerplate entry point for running one.
+
+```python
+from vgi import Worker, ScalarFunction
+
+class MyWorker(Worker):
+ functions = [MyScalarFunction()]
+
+# vgi-serve my_module:MyWorker # stdio
+# vgi-serve my_module:MyWorker --http # HTTP
+```
+
+## Worker
+
+::: vgi.worker
+
+## Serving
+
+::: vgi.serve
diff --git a/docs/assets/apple-touch-icon.png b/docs/assets/apple-touch-icon.png
new file mode 100644
index 0000000..f17e82c
Binary files /dev/null and b/docs/assets/apple-touch-icon.png differ
diff --git a/docs/assets/favicon-16x16.png b/docs/assets/favicon-16x16.png
new file mode 100644
index 0000000..e3c805d
Binary files /dev/null and b/docs/assets/favicon-16x16.png differ
diff --git a/docs/assets/favicon-32x32.png b/docs/assets/favicon-32x32.png
new file mode 100644
index 0000000..dd46943
Binary files /dev/null and b/docs/assets/favicon-32x32.png differ
diff --git a/docs/assets/favicon.ico b/docs/assets/favicon.ico
new file mode 100644
index 0000000..8677db0
Binary files /dev/null and b/docs/assets/favicon.ico differ
diff --git a/docs/assets/kinds/aggregate.svg b/docs/assets/kinds/aggregate.svg
new file mode 100644
index 0000000..8f6f9f5
--- /dev/null
+++ b/docs/assets/kinds/aggregate.svg
@@ -0,0 +1,7 @@
+
diff --git a/docs/assets/kinds/buffering.svg b/docs/assets/kinds/buffering.svg
new file mode 100644
index 0000000..10a3cf6
--- /dev/null
+++ b/docs/assets/kinds/buffering.svg
@@ -0,0 +1,13 @@
+
diff --git a/docs/assets/kinds/scalar.svg b/docs/assets/kinds/scalar.svg
new file mode 100644
index 0000000..8e946f4
--- /dev/null
+++ b/docs/assets/kinds/scalar.svg
@@ -0,0 +1,7 @@
+
diff --git a/docs/assets/kinds/table-in-out.svg b/docs/assets/kinds/table-in-out.svg
new file mode 100644
index 0000000..69cdd25
--- /dev/null
+++ b/docs/assets/kinds/table-in-out.svg
@@ -0,0 +1,11 @@
+
diff --git a/docs/assets/kinds/table.svg b/docs/assets/kinds/table.svg
new file mode 100644
index 0000000..384082b
--- /dev/null
+++ b/docs/assets/kinds/table.svg
@@ -0,0 +1,8 @@
+
diff --git a/docs/assets/logo.png b/docs/assets/logo.png
new file mode 100644
index 0000000..f6cf175
Binary files /dev/null and b/docs/assets/logo.png differ
diff --git a/docs/assets/social-card.png b/docs/assets/social-card.png
new file mode 100644
index 0000000..866be10
Binary files /dev/null and b/docs/assets/social-card.png differ
diff --git a/docs/concepts/index.md b/docs/concepts/index.md
new file mode 100644
index 0000000..ebc2df6
--- /dev/null
+++ b/docs/concepts/index.md
@@ -0,0 +1,66 @@
+---
+description: "How VGI works: the worker process model, transports, the Apache Arrow data model, the call lifecycle, and parallel workers."
+---
+
+# Concepts
+
+**What this is:** the mental model behind VGI — enough to design workers correctly and debug them
+confidently.
+**Who it's for:** developers who want the "why," not just the "how." None of this is
+required to ship your first worker; do the [tutorial](../tutorial/index.md) for that.
+
+## The worker process model
+
+A VGI worker is an ordinary process — not code loaded into DuckDB. DuckDB launches your worker
+(the `LOCATION` in `ATTACH`) and exchanges **Apache Arrow** record batches with it over a
+transport. Because your code runs in its own process, it can use any Python library, can't crash
+the database, and isn't tied to DuckDB's ABI or release cycle.
+
+## Transports
+
+The same worker runs over two transports without code changes:
+
+- **Subprocess** (default) — DuckDB (or the Python `Client`) spawns the worker and talks to it over
+ stdin/stdout. Best for local/co-located use; the simplest path.
+- **HTTP** — the worker runs as a network service (`vgi-serve … --http`). Adds authentication,
+ externalized payloads, and stateless stream resume. See
+ [Serve over HTTP with auth](../how-to/http-auth.md).
+
+Per-call code can branch on the transport, but most workers never need to.
+
+## The Arrow data model
+
+Functions receive and return **columns**, not rows. A scalar function's `compute` gets a
+`pa.StringArray`/`pa.Int64Array` (a whole column) and returns one the same length; table functions
+emit `pa.RecordBatch`es. Operating on columns with `pyarrow.compute` is what keeps data transfer
+and processing fast — there's no per-row Python call overhead, and the columnar bytes move without
+re-serialization. Argument and result types are declared with `Annotated[...]`, and VGI derives the
+Arrow schema (and thus the SQL signature) from them — see
+[Argument Serialization](../argument-serialization.md).
+
+## The call lifecycle
+
+Every call flows through a small set of phases:
+
+- **bind** — declare the output schema from the arguments (and, for table-in-out, the input schema).
+- **init** — set up per-call state (`initial_state`).
+- **process** — called one or more times to produce output; a generator emits batches until
+ `out.finish()`.
+- **finalize** — (aggregates / table-in-out) emit final results after all input is seen.
+
+Scalar functions use a simplified version (no `finalize`): each input batch maps to one output
+batch until the input ends. The exact phase ordering, including the distributed/multi-worker case,
+is diagrammed in the [Function Lifecycle reference](../lifecycle.md).
+
+## Parallel workers
+
+For throughput, the client can run **several worker processes** and distribute input batches across
+them round-robin, collecting results. Aggregates merge partial state across workers via their
+`combine` phase, which is why generator and aggregate state must be serializable (see
+[Persist state across workers](../how-to/state-storage.md)).
+
+## Next steps
+
+- **Phase-by-phase detail** → [Function Lifecycle](../lifecycle.md).
+- **How types cross the wire** → [Argument Serialization](../argument-serialization.md).
+- **Apply it** → [How-to guides](../how-to/index.md).
diff --git a/docs/contributing-docs.md b/docs/contributing-docs.md
new file mode 100644
index 0000000..de9dd9e
--- /dev/null
+++ b/docs/contributing-docs.md
@@ -0,0 +1,89 @@
+---
+description: "Conventions for writing vgi-python documentation: the per-page orientation standard, example rules, and a page template."
+---
+
+# Writing docs
+
+**What this is:** the conventions every vgi-python documentation page follows.
+**Who it's for:** anyone adding or editing docs. Following these keeps the site easy to orient in
+and keeps examples from rotting.
+
+## Diátaxis: pick the right mode
+
+Every page belongs to exactly one of four modes. If a page is doing two jobs, split it.
+
+| Mode | Answers | Lives under |
+|---|---|---|
+| **Tutorial** | "Teach me, step by step, by doing." | `docs/tutorial/` |
+| **How-to** | "How do I accomplish task X?" | `docs/how-to/` |
+| **Concept** | "Why does it work this way?" | `docs/concepts/` |
+| **Reference** | "What exactly is the signature/contract?" | `docs/api/` (auto-generated) |
+
+## The per-page orientation standard
+
+Every tutorial, how-to, and concept page **must** contain, in order:
+
+1. **A lead "what + who" block** — one sentence on what the page is, then **on its own line**
+ (use a trailing ` `) one phrase on who it's for, so a reader self-orients in under 10 seconds.
+ (See the top of this page.)
+2. **Prerequisites** — assumed knowledge, prior steps, and required extras (e.g.
+ `pip install vgi-python[http]`), with links. Use a list or an admonition.
+3. **At least one complete, runnable example** — no `...` elisions in the primary example. It must
+ pass the documentation-example tests (see below). *Exception:* advanced pages whose feature
+ isn't exercisable from a self-contained snippet (HTTP serving, auth, optimizer pushdown) may
+ lead with an illustrative `test="skip"` sketch — but label it illustrative and point to a
+ runnable worker or the reference for the real thing.
+4. **A "Next steps" section** that advances the reader along the funnel: prefer a sibling **how-to**
+ or a **concept** page, then the **reference** for the full contract. Don't jump straight from a
+ how-to into auto-generated reference. No dead ends.
+
+## Example rules
+
+- **Prefer one source of truth.** Worker code lives in `examples/*.py` and is embedded with a
+ snippet so docs and tests share one file:
+
+ ```text
+ ```python
+ --8<-- "examples/calc_worker.py"
+ ```
+ ```
+
+- **Examples must run in CI.** `examples/*.py` are imported and exercised by the test suite;
+ inline (non-snippet) Python blocks are executed by `tests/test_documentation_examples.py`. A
+ broken example fails the build.
+- **Mark illustrative-only blocks.** If a block genuinely can't run standalone (SQL, shell, a
+ partial snippet), use a non-`python` fence or the ` ```python test="lint" ` / ` test="skip" `
+ setting so the harness lints but doesn't execute it. **Do not put blank lines inside a
+ `test="skip"` block** — the renderer doesn't own that info string, so a blank line splits the
+ fence and leaks the delimiters into the page. Keep skip snippets blank-line-free.
+- **Progressive disclosure for newcomers.** Put background that experts can skip inside a
+ collapsible admonition (`??? info "New to X?"`).
+
+## Page template
+
+Copy this skeleton when starting a new how-to or concept page:
+
+```markdown
+---
+description: "One-line summary used for SEO and search."
+---
+
+# Page title
+
+**What this is:** one sentence.
+**Who it's for:** one phrase.
+
+## Prerequisites
+
+- ...
+
+##
+
+```python
+--8<-- "examples/your_worker.py"
+```
+
+## Next steps
+
+- [Related page](...)
+```
diff --git a/docs/how-to/catalogs.md b/docs/how-to/catalogs.md
new file mode 100644
index 0000000..3495cee
--- /dev/null
+++ b/docs/how-to/catalogs.md
@@ -0,0 +1,65 @@
+---
+description: "How to expose a VGI worker as a DuckDB catalog: schemas, function qualification, and tables/views via ATTACH."
+---
+
+# Expose a catalog
+
+**What this is:** how a worker presents itself to DuckDB as a catalog — a named namespace of
+schemas, functions, tables, and views you reach with `ATTACH`.
+**Who it's for:** developers who've finished the [tutorial](../tutorial/index.md) and want to
+understand how their functions get qualified names, or who want to expose data (not just functions).
+
+## Prerequisites
+
+- You can build and run a worker (see the [tutorial](../tutorial/index.md)).
+- Familiarity with the function patterns is helpful: [Function patterns](function-patterns.md).
+
+## The model
+
+Every worker exposes one **`Catalog`** with a name. Inside it are one or more **`Schema`**
+namespaces (DuckDB's default is `main`), each holding functions — and optionally tables and views.
+You attach the catalog and address its contents by name:
+
+```sql
+ATTACH 'calc' (TYPE vgi, LOCATION 'uv run calc_worker.py');
+
+-- catalog.function (functions in `main` are reachable as catalog.name)
+SELECT calc.double(21);
+
+-- catalog.schema.object (fully qualified)
+SELECT * FROM calc.main.series(3);
+```
+
+The worker from the tutorial is exactly this — a catalog named `calc` with a `main` schema holding
+the two functions:
+
+```python
+--8<-- "examples/calc_worker.py"
+```
+
+The SQL name of a function is the snake_case of its class name (`Double` → `double`), unless you
+override it with a `Meta.name` (as `sum_worker.py` does for `vgi_sum`).
+
+## Exposing data: tables and views
+
+A catalog can expose more than functions:
+
+- **`View`** — a named SQL query DuckDB evaluates. Pure SQL; no data provider needed:
+
+ ```python
+ from vgi.catalog import View
+ View(name="recent", definition="SELECT * FROM calc.series(5)")
+ ```
+
+- **`Table`** — a queryable table. Define it with an explicit `columns` schema (you supply the
+ scan) or back it with a `TableFunctionGenerator` so the schema is derived from the function.
+
+Both are passed to a `Schema(..., tables=[...], views=[...])`. The full set of options —
+constraints, generated columns, column comments, filter requirements — is covered in the
+[Catalog Interface reference](../catalog-interface.md).
+
+## Next steps
+
+- **Persist per-group state** → [State storage](state-storage.md).
+- **Full catalog options** (tables, views, constraints) → [Catalog Interface reference](../catalog-interface.md).
+- **Exact API** → [API Reference: Catalogs](../api/catalogs.md).
diff --git a/docs/how-to/function-patterns.md b/docs/how-to/function-patterns.md
new file mode 100644
index 0000000..26b4197
--- /dev/null
+++ b/docs/how-to/function-patterns.md
@@ -0,0 +1,227 @@
+---
+description: "How to write each of the four VGI function patterns: scalar, table, table-in-out, and aggregate — with a complete runnable worker for each."
+---
+
+# Function patterns
+
+**What this is:** a recipe showing each of the five VGI function patterns, with a complete,
+runnable worker for each.
+**Who it's for:** developers who've finished the
+[tutorial](../tutorial/index.md) and want to know which pattern fits their problem.
+
+## Prerequisites
+
+- You can build and run a worker (see the [tutorial](../tutorial/index.md)).
+- Python 3.13+, `uv`, and a DuckDB-compatible engine (Haybarn or stock DuckDB).
+
+## Which pattern do I need?
+
+
+
+| Pattern | Shape | Use it when… | SQL |
+|---|---|---|---|
+| **Scalar** | 1 row → 1 row | you transform each row independently | `SELECT f(col) FROM t` |
+| **Table** | args → N rows | you generate rows from arguments | `SELECT * FROM f(args)` |
+| **Table-in-out** | N rows → M rows | you reshape/filter a streamed table | `SELECT * FROM f((SELECT …))` |
+| **Aggregate** | grouped rows → 1 row/group | you accumulate per `GROUP BY` group | `SELECT f(col) FROM t GROUP BY k` |
+| **Buffering** | stream → [state] → stream | you must see *every* row first (sort, top-k, full reduction) | `SELECT * FROM f((SELECT …))` |
+
+## Scalar
+
+
+`1 row → 1 value`{ .kind-banner__formula }
+
+Runs on each row independently and returns a single value — a pure per-row transform.
+{ .kind-banner__desc }
+
+
+
+One row in, one row out. Operate on the whole column with `pyarrow.compute`; the `Annotated`
+types are the schema.
+
+```python
+--8<-- "examples/calc_scalar_worker.py"
+```
+
+```sql
+ATTACH 'calc' (TYPE vgi, LOCATION 'uv run calc_scalar_worker.py');
+SELECT calc.double(n) FROM (VALUES (1), (2), (3)) AS t(n);
+```
+
+Scalars aren't just for numbers — any column type works. A string transform looks the same; here
+`compute` joins `Hello, ` + name + `!` across the column:
+
+```python
+--8<-- "examples/greeting_scalar_worker.py"
+```
+
+```sql
+SELECT greetings.greeting(name) FROM (VALUES ('Alice'), ('Bob')) AS t(name);
+```
+
+## Table
+
+
+`args → N rows`{ .kind-banner__formula }
+
+A table-valued source: scalar arguments in, a whole set of rows out.
+{ .kind-banner__desc }
+
+
+
+Generate rows from arguments, no input table. Declare a typed args dataclass and a `FIXED_SCHEMA`;
+`process` emits batches until `out.finish()`. The `@bind_fixed_schema` / `@init_single_worker`
+decorators wire up the common single-worker lifecycle. (This is the full tutorial worker — the
+scalar `double` plus the `series` generator.)
+
+```python
+--8<-- "examples/calc_worker.py"
+```
+
+```sql
+ATTACH 'calc' (TYPE vgi, LOCATION 'uv run calc_worker.py');
+SELECT * FROM calc.series(3);
+```
+
+### Streaming with state
+
+The tutorial's `series` emits every row in a single `process` call. That's fine for small results,
+but `process` is actually called *repeatedly* until you call `out.finish()` — so for large output
+you emit a bounded chunk per call and remember your place in a small **state** object. The state
+extends `ArrowSerializableDataclass` so it survives HTTP state round-trips:
+
+```python
+--8<-- "examples/series_streaming_worker.py"
+```
+
+```sql
+SELECT * FROM calc.series(1000000); -- streamed CHUNK rows per process() call
+```
+
+## Table-in-out
+
+
+`N rows → 1 value`{ .kind-banner__formula }
+
+Folds many rows down into a single value per group.
+{ .kind-banner__desc }
+
+
+
+Accumulate input rows into per-group state, then emit one row per group. Aggregates are driven by
+DuckDB's `GROUP BY` and run in three phases — `update` (fold a batch into per-group state),
+`combine` (merge partial states across parallel workers), and `finalize` (state → output row).
+
+```python
+--8<-- "examples/sum_worker.py"
+```
+
+```sql
+ATTACH 'aggregates' (TYPE vgi, LOCATION 'uv run sum_worker.py');
+SELECT category, aggregates.vgi_sum(value) FROM t GROUP BY category;
+```
+
+??? info "State must be an `ArrowSerializableDataclass`"
+ Table generators and aggregates keep state between calls. Because that state is serialized
+ across parallel workers (aggregates) and HTTP round-trips, the framework requires it to extend
+ `ArrowSerializableDataclass`. Annotate fields with `ArrowType(...)` so the wire type is
+ explicit.
+
+## Buffering
+
+
+`stream → [state] → stream`{ .kind-banner__formula }
+
+Holds every input row in state before emitting — the basis for sorts, top-k, and full-stream reductions.
+{ .kind-banner__desc }
+
+
+
+When a function must see the **whole** input before it can produce *any* output — a global sort,
+top-k, or a full reduction — use a buffering function. Unlike table-in-out (which emits per input
+batch), it runs in three phases: `process` (the **sink** — stash each batch, return a `state_id`),
+`combine` (reduce all the partials once), and `finalize` (the **source** — stream the result out).
+Because the phases can run in different worker processes, state can't live in memory — it goes in
+`params.storage`, a shared store keyed to this call (its `execution_id`). See
+[Persist state across workers](state-storage.md) for the backends behind it.
+
+```python
+--8<-- "examples/row_count_worker.py"
+```
+
+```sql
+ATTACH 'buffers' (TYPE vgi, LOCATION 'uv run row_count_worker.py');
+SELECT * FROM buffers.row_count((SELECT * FROM big_table));
+```
+
+??? info "Buffering vs. table-in-out"
+ Both consume a relation, but a **table-in-out** function emits *per input batch* and never
+ holds the whole input — use it for streaming transforms (filter, enrich, reshape). Reach for
+ **buffering** only when output genuinely depends on every row. See
+ [Persist state across workers](state-storage.md) for the storage backends `params.storage` uses.
+
+## Next steps
+
+- **Persist state across workers** → [State storage](state-storage.md).
+- **Let the optimizer prune work** → [Integrate with the optimizer](pushdown-and-statistics.md).
+- **Understand the call lifecycle** → [Concepts](../concepts/index.md).
+- **Exact signatures** → [API Reference: Functions](../api/functions.md).
diff --git a/docs/how-to/http-auth.md b/docs/how-to/http-auth.md
new file mode 100644
index 0000000..b4cf460
--- /dev/null
+++ b/docs/how-to/http-auth.md
@@ -0,0 +1,70 @@
+---
+description: "How to serve a VGI worker over HTTP and authenticate callers with bearer tokens or JWT/OAuth."
+---
+
+# Serve over HTTP with authentication
+
+**What this is:** how to run a worker over **HTTP** instead of a subprocess, and gate it with
+authentication.
+**Who it's for:** developers deploying a worker as a network service.
+**Requires:** `pip install vgi-python[http]` (and `[oauth]` for JWT).
+
+## Prerequisites
+
+- A working worker (see the [tutorial](../tutorial/index.md)).
+- `pip install vgi-python[http]`; for JWT/OAuth also `vgi-python[oauth]`.
+
+## Serve over HTTP
+
+The same worker that runs over a subprocess also runs over HTTP — add `--http`:
+
+```bash
+vgi-serve my_worker.py --http
+```
+
+DuckDB still attaches the worker over HTTP the usual way (`ATTACH ... (TYPE vgi, LOCATION
+'http://...')`). You only need the **Python `Client`** when you're calling the worker *from Python*
+rather than from SQL — for tests, scripts, or another service. It connects with `transport="http"`
+instead of spawning a subprocess, and exposes the same call methods:
+
+```python
+# illustrative — calling the worker from Python over HTTP
+from vgi.client import Client
+
+with Client(transport="http", base_url="http://localhost:8080", bearer_token="token1") as client:
+ ... # same .scalar_function() / .table_function() calls as the subprocess transport
+```
+
+## Add authentication
+
+The quickest setup is static bearer tokens via an environment variable — comma-separated
+`token=principal` pairs:
+
+```bash
+VGI_BEARER_TOKENS="token1=alice,token2=bob" vgi-serve my_worker.py --http
+```
+
+Unauthenticated requests get HTTP 401. Authenticated requests carry the principal in an
+`AuthContext`. Your function reads it through an optional `ctx` parameter: declare `ctx` in the
+signature and the framework injects a per-call `CallContext` (auth, logging, transport info) — you
+don't pass it from SQL.
+
+```python
+# illustrative — reading the injected ctx in a function
+class Secret(ScalarFunction):
+ @classmethod
+ def compute(cls, value, *, ctx) -> ...:
+ ctx.auth.require_authenticated() # raises if anonymous
+ principal = ctx.auth.principal # "alice"
+ ...
+```
+
+For JWT/JWKS and RFC 9728 OAuth resource metadata, set `VGI_JWT_ISSUER` / `VGI_JWT_AUDIENCE`
+(requires `vgi-python[oauth]`) — see the [Authentication reference](../authentication.md) for the
+full variable list and programmatic `make_wsgi_app(authenticate=...)` API.
+
+## Next steps
+
+- **Full auth options** (JWT, OAuth metadata, custom callbacks) →
+ [Authentication reference](../authentication.md).
+- **Request context** (`ctx.auth`, logging) → [API Reference: Auth & Secrets](../api/auth.md).
diff --git a/docs/how-to/index.md b/docs/how-to/index.md
new file mode 100644
index 0000000..70281c3
--- /dev/null
+++ b/docs/how-to/index.md
@@ -0,0 +1,35 @@
+---
+description: "Task-oriented recipes for building VGI workers: function patterns, catalogs, state, auth, and optimizer integration."
+---
+
+# How-to guides
+
+**What this is:** focused, task-oriented recipes for getting a specific thing done.
+**Who it's for:** developers who've finished the [tutorial](../tutorial/index.md) and want to build
+something real. Each guide assumes you can already write and run a basic worker.
+
+## Recipes
+
+- **[Function patterns](function-patterns.md)** — scalar, table, table-in-out, and aggregate
+ functions, with a runnable worker for each. *(Start here.)*
+- **[Expose a catalog](catalogs.md)** — surface schemas, functions, tables, and views to DuckDB
+ via `ATTACH`.
+- **[Persist state across workers](state-storage.md)** — shared, durable state for distributed
+ aggregates.
+- **[Serve over HTTP with auth](http-auth.md)** — run a worker as a network service and gate it
+ with bearer/JWT auth.
+- **[Integrate with the optimizer](pushdown-and-statistics.md)** — accept pushed-down filters and
+ report column statistics.
+Each recipe ends with a **Next steps** section that links onward to a concept page and the full
+reference for that topic.
+
+## Reference & tooling
+
+- **[Function Metadata](../metadata.md)** — describe your functions for introspection.
+- **[CLI](../cli.md)** — invoke functions and inspect workers from the shell.
+
+## Next steps
+
+- New here? Start with the [tutorial](../tutorial/index.md).
+- Want the "why" behind the API? See [Concepts](../concepts/index.md).
+- Need exact signatures? See the [API Reference](../api/index.md).
diff --git a/docs/how-to/pushdown-and-statistics.md b/docs/how-to/pushdown-and-statistics.md
new file mode 100644
index 0000000..8bb6fd1
--- /dev/null
+++ b/docs/how-to/pushdown-and-statistics.md
@@ -0,0 +1,72 @@
+---
+description: "How to integrate VGI table functions with DuckDB's optimizer: accept pushed-down filters and report column statistics."
+---
+
+# Integrate with the optimizer
+
+**What this is:** how to make table functions cooperate with DuckDB's query optimizer — receiving
+pushed-down `WHERE` predicates and reporting column statistics so the planner can skip work.
+**Who it's for:** developers whose table functions back real data sources and want less data moved.
+
+## Prerequisites
+
+- A table or table-in-out function (see [Function patterns](function-patterns.md)).
+- Familiarity with your data's shape (which columns are filterable, their ranges).
+
+## Filter pushdown
+
+A table function can receive the `WHERE` predicates DuckDB would otherwise apply *after* the scan,
+and apply them at the source. Opt in with `filter_pushdown = True` in the function's `Meta`. The
+framework deserializes the predicates for you and exposes them on `params.current_pushdown_filters`
+as a `PushdownFilters` tree (or `None` when no filter applies), refreshed before each `process`
+call:
+
+```python
+# illustrative — sketch using your own types
+class Events(TableFunctionGenerator[EventsArgs]):
+ class Meta:
+ filter_pushdown = True # opt in to receiving WHERE predicates
+
+ @classmethod
+ def process(cls, params, state, out):
+ filters = params.current_pushdown_filters # PushdownFilters tree, or None
+ # apply the filters while generating rows, then out.emit(...) / out.finish()
+```
+
+`PushdownFilters` is already decoded — you don't call `deserialize_filters` yourself (that helper is
+for the raw wire bytes). To have the framework apply the filters to your output automatically,
+set `auto_apply_filters = True` in `Meta`. The node types and a worked example are in the
+[Filter Pushdown reference](../filter-pushdown.md) and [API: Filter Pushdown](../api/filters.md).
+
+## Column statistics
+
+When a table reports per-column min/max, null, and distinct-count statistics, DuckDB's optimizer
+can eliminate scans and order joins better. The declarative path is a `statistics` entry on the
+`Table` descriptor:
+
+```python
+# illustrative — `schema` and the table stand in for your own catalog
+from vgi.catalog import Table
+from vgi.catalog.descriptors import ColumnStatisticsInput
+
+Table(
+ name="departments",
+ columns=schema,
+ statistics={"id": ColumnStatisticsInput(min=1, max=10, has_null=False)},
+)
+```
+
+```sql
+-- With statistics the optimizer can prune an impossible predicate entirely:
+EXPLAIN SELECT * FROM mydb.data.departments WHERE id > 100; -- Physical Plan: EMPTY_RESULT
+```
+
+(The snippets above are illustrative — `schema` and the `departments` table stand in for your own
+catalog.) Full details — RPC-based dynamic statistics, TTLs, spatial bounds — are in the
+[Column Statistics reference](../column-statistics.md).
+
+## Next steps
+
+- **Filter format & evaluation** → [Filter Pushdown reference](../filter-pushdown.md) ·
+ [API: Filter Pushdown](../api/filters.md).
+- **Statistics options** → [Column Statistics reference](../column-statistics.md).
diff --git a/docs/how-to/state-storage.md b/docs/how-to/state-storage.md
new file mode 100644
index 0000000..03381c0
--- /dev/null
+++ b/docs/how-to/state-storage.md
@@ -0,0 +1,69 @@
+---
+description: "How to give VGI functions shared, persistent state across worker processes — the default SQLite backend and cloud alternatives."
+---
+
+# Persist state across workers
+
+**What this is:** how functions that span **multiple worker processes** (notably distributed
+aggregates) share and persist state.
+**Who it's for:** developers building aggregates or any
+function that coordinates partial results across workers.
+
+## Prerequisites
+
+- You've built an aggregate or multi-worker function (see
+ [Function patterns → Aggregate](function-patterns.md#aggregate)).
+- For the Azure backend: `pip install vgi-python[azure]`. SQLite and Cloudflare DO need no extra.
+
+!!! note "`vgi-serve`"
+ The commands below use `vgi-serve`, the CLI installed with `vgi-python` that runs a worker
+ module as a long-lived process (the production counterpart to the tutorial's `uv run`). The
+ `--http` flag serves it over HTTP instead of stdin/stdout.
+
+## Two kinds of "state" — don't confuse them
+
+- **Generator cursor state** — the small `ArrowSerializableDataclass` a table generator keeps
+ *within one scan* (see [streaming with state](function-patterns.md#streaming-with-state)). It
+ lives in the worker for the duration of the call.
+- **Shared storage** (this page) — state that must outlive a single call or be shared across
+ **separate worker processes**, e.g. combining partial aggregate results. This is backed by a
+ pluggable store.
+
+## The default: SQLite (zero config)
+
+Under the subprocess transport, shared storage "just works" — all workers share a local SQLite
+database (WAL mode) at the platform state directory. Nothing to configure:
+
+```bash
+vgi-serve my_worker.py
+```
+
+## Choosing a backend
+
+Select a backend with the `VGI_WORKER_SHARED_STORAGE` environment variable:
+
+```bash
+# Local / subprocess (default)
+VGI_WORKER_SHARED_STORAGE=sqlite vgi-serve my_worker.py
+
+# Azure cloud (requires vgi-python[azure])
+VGI_WORKER_SHARED_STORAGE=azure-sql vgi-serve my_worker.py --http
+
+# Edge / multi-cloud
+VGI_WORKER_SHARED_STORAGE=cloudflare-do vgi-serve my_worker.py --http
+```
+
+| Backend | Value | Use case | Dependencies |
+|---|---|---|---|
+| SQLite | `sqlite` (default) | local / subprocess | none (stdlib) |
+| Azure SQL | `azure-sql` | Azure deployments | `vgi-python[azure]` |
+| Cloudflare DO | `cloudflare-do` | edge / multi-cloud | none extra — uses `httpx`, which ships with `vgi-python`; needs a Worker endpoint + token |
+
+The per-backend setup (connection strings, credentials, table provisioning) is documented in the
+[Shared Storage reference](../shared-storage.md).
+
+## Next steps
+
+- **Aggregates** that use this → [Function patterns → Aggregate](function-patterns.md#aggregate).
+- **Full backend setup** → [Shared Storage reference](../shared-storage.md).
+- **Exact API** → [API Reference: State storage](../api/storage.md).
diff --git a/docs/index.md b/docs/index.md
new file mode 100644
index 0000000..0391b60
--- /dev/null
+++ b/docs/index.md
@@ -0,0 +1,114 @@
+---
+description: "vgi-python: add scalar, table, and aggregate functions to DuckDB in pure Python over Apache Arrow — no C++ to compile, no extension to version, no build step."
+---
+
+# vgi-python
+
+**Extend DuckDB in pure Python.** Add scalar, table, and aggregate functions that run in your own
+process and stream data to DuckDB over Apache Arrow — no C++ to compile, no extension to version,
+no build step.
+
+Write a function, `uv run` the script, query it from SQL. Other languages work too.
+
+
+
+
+
+
+Built by [🚜 Query.Farm](https://query.farm).
+
+
+## See it in action
+
+A **scalar** function — one row in, one row out:
+
+```python
+--8<-- "examples/calc_worker.py:scalar"
+```
+
+A **table** function — generate rows from an argument:
+
+```python
+--8<-- "examples/calc_worker.py:table"
+```
+
+Drop both into a `Worker`, add an [inline-script-metadata](https://packaging.python.org/en/latest/specifications/inline-script-metadata/)
+header, and `uv run` it — no virtualenv, nothing to `pip install`. (The
+[tutorial](tutorial/index.md) builds the complete file step by step.)
+
+```sql
+INSTALL vgi FROM community;
+LOAD vgi;
+-- LOCATION is the command that launches the worker.
+ATTACH 'calc' (TYPE vgi, LOCATION 'uv run calc_worker.py');
+
+SELECT calc.double(21); -- 42
+SELECT * FROM calc.series(3); -- 0, 1, 2
+```
+
+That's it. No compilation, no extension versioning, no build process.
+
+[Build this worker step by step in the tutorial →](tutorial/index.md){ .md-button }
+
+## Installation
+
+The package is published on PyPI as `vgi-python` (the `vgi` name was taken), but you `import vgi`
+in code:
+
+```bash
+pip install vgi-python # or: uv add vgi-python
+```
+
+You also need a DuckDB-compatible engine. [Haybarn](https://github.com/Query-farm-haybarn/haybarn),
+Query.Farm's DuckDB distribution, ships the `vgi` extension and runs with no install:
+
+```bash
+uvx haybarn-cli # interactive SQL session
+```
+
+Stock `duckdb` works too — `INSTALL vgi FROM community; LOAD vgi;`.
+
+## Why VGI?
+
+| Traditional extensions | VGI workers |
+|---|---|
+| C/C++ compilation required | Any language with an Apache Arrow library |
+| Tied to a DuckDB version | Version independent |
+| Complex build/release cycle | Ship a script or executable |
+| Runs in DuckDB's process | Isolated worker process — a crash or a heavy dependency can't take DuckDB down |
+| Native code in DuckDB's threads | Your code, optionally fanned out across worker processes |
+
+**The tradeoff:** data crosses a process boundary as Apache Arrow IPC. That's fast and columnar,
+but not free — co-locate workers (subprocess transport) for latency-sensitive paths, and reach for
+VGI when the productivity and isolation win outweighs the hop.
+
+**Use cases:** call REST APIs from SQL, run ML inference, process data with pandas/numpy, build
+custom ETL transforms, expose external data sources as queryable tables and views.
+
+## Function patterns
+
+| Shape | Type | Base class | Use case |
+|---|---|---|---|
+| { height="32" } | **Scalar** | `ScalarFunction` | Per-row transforms (1:1) |
+| { height="32" } | **Table** | `TableFunctionGenerator` | Generate data |
+| { height="32" } | **Table-in-out** | `TableInOutFunction` | Streaming transforms, filtering |
+| { height="32" } | **Aggregate** | `AggregateFunction` | Grouped accumulation |
+| { height="32" } | **Buffering** | `TableBufferingFunction` | Sees every row first (sort, top-k) |
+
+See the [API Reference](api/index.md) for the full surface, or jump into the guides below.
+
+## Documentation
+
+- **[Tutorial](tutorial/index.md)** — build your first worker (scalar + table function callable
+ from DuckDB) in about 20 minutes. **Start here.**
+- **[How-to guides](how-to/index.md)** — task-oriented recipes: function patterns, catalogs,
+ state, auth/HTTP, and optimizer integration.
+- **[Concepts](concepts/index.md)** — how it works: the worker lifecycle, transports, and the
+ Arrow data model.
+- **[API Reference](api/index.md)** — auto-generated from the source, organized by module.
+
+## Project links
+
+- Source: [github.com/Query-farm/vgi-python](https://github.com/Query-farm/vgi-python)
+- PyPI: [vgi-python](https://pypi.org/project/vgi-python/)
+- Built on [vgi-rpc](https://vgi-rpc-python.query.farm/) — the transport-agnostic RPC layer.
diff --git a/docs/overrides/main.html b/docs/overrides/main.html
new file mode 100644
index 0000000..2f51abb
--- /dev/null
+++ b/docs/overrides/main.html
@@ -0,0 +1,45 @@
+{% extends "base.html" %}
+
+{% block extrahead %}
+ {% if page and page.meta and page.meta.description %}
+ {% set page_desc = page.meta.description %}
+ {% else %}
+ {% set page_desc = config.site_description %}
+ {% endif %}
+ {% if page and page.is_homepage %}
+ {% set page_title = config.site_name ~ " — Connect DuckDB to external programs via Apache Arrow" %}
+ {% elif page and page.title %}
+ {% set page_title = page.title ~ " - " ~ config.site_name %}
+ {% else %}
+ {% set page_title = config.site_name %}
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/docs/robots.txt b/docs/robots.txt
new file mode 100644
index 0000000..d78bace
--- /dev/null
+++ b/docs/robots.txt
@@ -0,0 +1,4 @@
+User-agent: *
+Allow: /
+
+Sitemap: https://vgi-python.query.farm/sitemap.xml
diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css
new file mode 100644
index 0000000..7ca338e
--- /dev/null
+++ b/docs/stylesheets/extra.css
@@ -0,0 +1,389 @@
+/* Query.Farm palette — earthy greens, warm golds, and cream tones
+ Matched to https://vgi-rpc.query.farm website theme */
+
+/* ── CSS custom properties (farm palette) ── */
+:root {
+ --farm-green-deep: #2d5016;
+ --farm-green-field: #4a7c23;
+ --farm-green-light: #6ba034;
+ --farm-gold: #c8a43a;
+ --farm-gold-light: #d4b64e;
+ --farm-brown: #6b4423;
+ --farm-cream: #faf8f0;
+ --farm-cream-dark: #f0ece0;
+ --farm-dark: #1a1a0e;
+ --farm-dark-card: #252518;
+ --farm-dark-border: #3a3a28;
+ --farm-text: #2c2c1e;
+ --farm-text-muted: #6b6b5a;
+ --farm-text-light: #f5f0e0;
+ --farm-text-light-muted: #b8b0a0;
+}
+
+/* ── Light mode ── */
+[data-md-color-scheme="default"] {
+ --md-primary-fg-color: var(--farm-green-deep);
+ --md-primary-fg-color--light: var(--farm-green-field);
+ --md-primary-fg-color--dark: #1e3a0e;
+ --md-primary-bg-color: #ffffff;
+ --md-primary-bg-color--light: #ffffffb3;
+ --md-accent-fg-color: var(--farm-gold);
+ --md-accent-fg-color--transparent: #c8a43a33;
+ --md-accent-bg-color: #ffffff;
+ --md-accent-bg-color--light: #ffffffb3;
+ --md-default-bg-color: var(--farm-cream);
+ --md-default-bg-color--light: var(--farm-cream-dark);
+ --md-default-fg-color: var(--farm-text);
+ --md-default-fg-color--light: var(--farm-text-muted);
+ --md-typeset-color: var(--farm-text);
+ --md-code-bg-color: #f5f0e6;
+}
+
+/* Dark mode */
+[data-md-color-scheme="slate"] {
+ --md-primary-fg-color: var(--farm-green-field);
+ --md-primary-fg-color--light: var(--farm-green-light);
+ --md-primary-fg-color--dark: var(--farm-green-deep);
+ --md-primary-bg-color: var(--farm-text-light);
+ --md-primary-bg-color--light: #f5f0e0b3;
+ --md-accent-fg-color: var(--farm-gold-light);
+ --md-accent-fg-color--transparent: #d4b64e33;
+ --md-accent-bg-color: #000000;
+ --md-accent-bg-color--light: #0000004d;
+ --md-default-bg-color: var(--farm-dark);
+ --md-default-bg-color--light: var(--farm-dark-card);
+ --md-default-bg-color--lighter: #25251833;
+ --md-default-bg-color--lightest: #2525180d;
+ --md-default-fg-color: var(--farm-text-light);
+ --md-default-fg-color--light: var(--farm-text-light-muted);
+ --md-typeset-color: var(--farm-text-light);
+ --md-code-bg-color: var(--farm-dark-card);
+}
+
+/* ── Header / nav bar ── */
+[data-md-color-scheme="default"] .md-header {
+ background-color: var(--farm-green-deep);
+}
+
+[data-md-color-scheme="slate"] .md-header {
+ background-color: var(--farm-dark);
+ border-bottom: 1px solid var(--farm-dark-border);
+}
+
+[data-md-color-scheme="default"] .md-tabs {
+ background-color: var(--farm-green-deep);
+}
+
+[data-md-color-scheme="slate"] .md-tabs {
+ background-color: var(--farm-dark);
+ border-bottom: 1px solid var(--farm-dark-border);
+}
+
+/* Nav tabs — gold underline on active/hover */
+.md-tabs__link--active,
+.md-tabs__link:hover {
+ border-bottom: 2px solid var(--farm-gold);
+}
+
+/* ── Sidebar navigation ── */
+[data-md-color-scheme="default"] .md-sidebar {
+ border-right: 1px solid var(--farm-cream-dark);
+}
+
+[data-md-color-scheme="slate"] .md-sidebar {
+ border-right: 1px solid var(--farm-dark-border);
+}
+
+/* ── Admonition styling — farm tones ── */
+.md-typeset .admonition.farm,
+.md-typeset details.farm {
+ border-color: var(--farm-green-field);
+}
+.md-typeset .farm > .admonition-title,
+.md-typeset .farm > summary {
+ background-color: #4a7c231a;
+ border-color: var(--farm-green-field);
+}
+
+/* ── Code block styling ── */
+[data-md-color-scheme="default"] .highlight code {
+ border-left: 3px solid var(--farm-green-field);
+}
+
+/* Inline code */
+[data-md-color-scheme="default"] .md-typeset code {
+ background-color: var(--farm-cream-dark);
+ color: var(--farm-text);
+}
+
+[data-md-color-scheme="slate"] .md-typeset code {
+ background-color: var(--farm-dark-card);
+ color: var(--farm-text-light);
+}
+
+/* ── Links ── */
+[data-md-color-scheme="default"] .md-typeset a {
+ color: var(--farm-green-deep);
+}
+
+[data-md-color-scheme="default"] .md-typeset a:hover {
+ color: var(--farm-green-field);
+}
+
+[data-md-color-scheme="slate"] .md-typeset a {
+ color: var(--farm-green-light);
+}
+
+[data-md-color-scheme="slate"] .md-typeset a:hover {
+ color: var(--farm-gold-light);
+}
+
+/* ── Footer ── */
+.md-footer {
+ border-top: 1px solid var(--farm-cream-dark);
+}
+
+[data-md-color-scheme="default"] .md-footer-meta {
+ background-color: var(--farm-green-deep);
+}
+
+[data-md-color-scheme="slate"] .md-footer {
+ border-top: 1px solid var(--farm-dark-border);
+}
+
+[data-md-color-scheme="slate"] .md-footer-meta {
+ background-color: var(--farm-dark-card);
+}
+
+/* ── Tables — match website styling ── */
+[data-md-color-scheme="default"] .md-typeset table:not([class]) th {
+ background-color: var(--farm-cream-dark);
+ color: var(--farm-text);
+}
+
+[data-md-color-scheme="slate"] .md-typeset table:not([class]) th {
+ background-color: var(--farm-dark-card);
+ color: var(--farm-text-light);
+}
+
+[data-md-color-scheme="default"] .md-typeset table:not([class]) td {
+ border-color: var(--farm-cream-dark);
+}
+
+[data-md-color-scheme="slate"] .md-typeset table:not([class]) td {
+ border-color: var(--farm-dark-border);
+}
+
+/* ── Search ── */
+[data-md-color-scheme="default"] .md-search__input {
+ background-color: #ffffff;
+}
+
+[data-md-color-scheme="slate"] .md-search__input {
+ background-color: var(--farm-dark-card);
+}
+
+/* ── Hero section on landing page ── */
+.hero {
+ padding: 2rem 0;
+ margin-bottom: 2rem;
+ text-align: center;
+}
+
+.hero h1 {
+ font-size: 2.5rem;
+ margin-bottom: 0.5rem;
+}
+
+.hero .tagline {
+ font-size: 1.2rem;
+ opacity: 0.8;
+ margin-bottom: 1rem;
+}
+
+.hero .built-by {
+ font-size: 1.15rem;
+ opacity: 0.85;
+}
+
+.hero .built-by a {
+ color: var(--farm-green-deep);
+ font-weight: 700;
+}
+
+[data-md-color-scheme="slate"] .hero .built-by a {
+ color: var(--farm-green-light);
+}
+
+/* Hero logo */
+.hero-logo {
+ display: flex;
+ justify-content: center;
+ margin-bottom: 1rem;
+}
+
+.hero-logo-img {
+ border-radius: 50%;
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.18);
+ transition: transform 0.3s ease;
+ width: 200px;
+ height: 200px;
+}
+
+.hero-logo-img:hover {
+ transform: scale(1.05);
+}
+
+/* Header logo — slightly bigger than default, full logo at its natural aspect ratio */
+.md-header__button.md-logo img,
+.md-nav__button.md-logo img {
+ height: 1.8rem;
+ width: auto;
+}
+
+/* ── D2 diagrams — constrain size and center ── */
+.md-typeset .d2 {
+ max-width: 360px !important;
+ margin: 1rem auto !important;
+}
+
+.md-typeset .d2 svg {
+ width: 100% !important;
+ height: auto !important;
+}
+
+/* ── Content area cards/separators ── */
+[data-md-color-scheme="default"] .md-typeset hr {
+ border-color: var(--farm-cream-dark);
+}
+
+[data-md-color-scheme="slate"] .md-typeset hr {
+ border-color: var(--farm-dark-border);
+}
+
+/* ── Function-kind banner ──
+ A header card for each function shape: the cardinality glyph alongside its
+ formula and a one-line gloss, tinted in the kind's colour. Ported from the
+ VGI Java docs' KindBanner. Markdown usage (md_in_html + attr_list):
+
+