This is a template repo for creating geo-agent map applications. The core library lives at boettiger-lab/geo-agent and is loaded from CDN — you never modify it here.
| Repo | Purpose |
|---|---|
geo-agent |
Core library (map, chat, agent, tools). Source of truth for all functionality. |
geo-agent-template |
Starter template. Users fork this and configure three files for their dataset. |
geo-agent-benchmark |
Benchmark home — question banks, gold answers, traps, run results for all geo-agent apps. Run the gate here before shipping a prompt or guidance change. |
Full docs: boettiger-lab.github.io/geo-agent — includes the complete configuration reference, deployment guide, and agent loop internals.
The schema below is kept inline so you can work without a network fetch. If it conflicts with the docs, the docs are authoritative.
You configure: layers-input.json (which datasets to show and how), system-prompt.md (LLM persona and guidelines), and k8s/ manifests if deploying to Kubernetes.
You do not write JavaScript. The core map, chat, agent, and tool modules are loaded from the CDN. Do not create or modify JS files in a client app repo.
Keep system-prompt.md lean. The MCP query tool (list_datasets, get_schema) already provides the agent with dataset titles, descriptions, column schemas, coded values, and exact S3 parquet paths at runtime. Do not duplicate any of this in the system prompt — it drifts out of sync and can contradict the tools.
What belongs in system-prompt.md:
- Domain-specific context the tools cannot provide (e.g., "this dataset has one row per funding transaction, not per site — deduplicate acres before summing")
- Attribution and framing guidance (e.g., how to describe data sources to users)
- Cross-dataset pitfalls (e.g., "Dataset A uses state abbreviations, Dataset B uses full names")
- Map-vs-SQL decision guidance and interaction style
- "Data tool, not advisor" guardrails if the agent should avoid giving policy opinions
What does not belong in system-prompt.md:
- Column listings or S3 paths (use
get_schemainstead — direct the agent to call it) - Multiple SQL examples with hardcoded paths (these go stale and may contradict the MCP tool's own query optimization rules)
- DuckDB configuration details (thread count, extensions)
- Dataset descriptions that repeat what's in the STAC catalog
Instead, add a "Discovering data" section directing the agent to verify against the dataset metadata (via list_datasets / get_schema) before writing any SQL.
Several published-assessment numbers our catalog cannot currently reproduce are already diagnosed and owned elsewhere. They have been independently rediscovered more than once, each time costing hours. Before investigating any mismatch against the 2025 Biodiversity Assessment, check this table; if it is listed, cite the issue and move on.
| Divergence | Status | Owner |
|---|---|---|
Conifer Woodland / pinyon-juniper — SOLVED 2026-08-02, and it was our defect. The FVEG-derived cwhr/cwhr13 assets are not clipped to California: 19.4% of pinyon-juniper (335,544 ac) is in Nevada, where the CA conserved-areas layer cannot reach, so it all scored non-conserved. CA-clipped, PJ is 52.0% vs report 52.6%. The same clip fixes sagebrush (19.9→22.4), low sage (11.6→13.8), bitterbrush (12.1→13.6 exact), conifer woodland (28.8→32.0) and shrub (26.6→27.6) — all Great Basin classes. Juniper reproduced all along because it is 99.9% in-state; treating it as a passing control hid the extent bug for weeks. Before attributing any divergence to vintage or a partner's layer, check that our data is inside California. Handled in two layers: the asset stays faithful to the source footprint and STAC documents the true extent (data-workflows#505), while system-prompt.md carries the app rule to mask features to California (via ca30x30-ecoregion hex on h8, the same CA definition as the pinned denominator). |
App rule shipped; STAC note filed | data-workflows#505, ca-30x30#103 |
The "cwhr13 is missing ~5M acres" claim is FALSE — do not re-investigate. Measured: the res-10 cell union is 102,834,000 ac (mode and fractional builds identical) vs California's 101,498,000 — 1.3% larger, from boundary cells overhanging the state edge. whr13num=0 nodata is 91,371 ac, not 5M. The ~96.5M figure in the cwhr13-hex-fractions STAC note is cell count x H3's global average res-10 area (0.0150475 km²); California's cells run ~6.6% larger (0.016040 km²), so the global constant understates by ~5%. Per-class percentages are unaffected (a uniform area scale cancels in a ratio). Agents quote that note to users, and one derived a bogus "3.718 ac/cell" from it. Note the 1.3% excess is not only edge overhang — ~1.4M ac of it is genuinely out-of-state (data-workflows#505). |
Note fix open; data is correct | data-workflows#504 (ca-30x30#73 closed) |
Wetlands — reconciled, do not re-open. The app feature is three NWI WETLAND_TYPE classes = 1,793,850 ac vs the assessment's 1,735,518 (+3.4%, NWI vintage). The often-quoted "our CA total is 9.14M, nothing reconciles" compared their feature against all eight NWI types, including Deepwater/Lake/Riverine which are not in it. |
Settled | ca-30x30#105 (closed) |
| SLR 5ft: assessment 642,610 ac vs our NOAA-derived 3.90M. Ingest verified faithful; NOAA's connected layer includes existing ocean. Newly-inundated-land reconstructions bracket the target (430k / 695k) — the residual is a land/ocean clip. | Needs clip definition from the partner | ca-30x30#104 (rebuild spec: data-workflows#363) |
Streams / NHD stream order — diagnosed, do not re-derive. Usable STREAMORDER covers 8.7% of California and 97.8% of that is HUC4 1801 (Klamath), so any stream-order answer is one basin mislabelled as the state. That is sparsity in USGS's own NHDFlowlineVAA — not our import; do not re-run the ingest. NHDPlus HR is verified to fix it (99.65% coverage across all 13 CA basins) and is being added alongside, not instead of, the current asset. Three things were measured and ruled out — do not re-investigate: hex-on-linestring (≤1 pp on GAP 1+2 vs exact ST_Intersection), proportional-vs-categorical GAP weighting (no difference), and any FCode redefinition between NHD products (~95% stable). The partner is on a medium-resolution network, so high-res lengths will not match hers even after the fix — GAP 1+2 should still agree, GAP 3+4 will not (it is scale-sensitive: 20% on mainstems, 46% on tributaries). Numbers, SQL and next actions live in the issues. |
App rule pending; upstream in progress | ca-30x30#111, data-workflows#518, #205 |
Streams — bucket listing — public-usgs-nhd denies ListBucket, so glob reads 403. Workaround: enumerate h0 explicitly. |
Fixed | data-workflows#411 (closed) |
FVEG vintage — fveg22_1 is CURRENT, not stale. Do not "fix" it. |
Settled | — |
The pinned area of California (101,498,000 ac / 410,749 km²) is deliberate — do not propose computing it at query time. "The area of California" is not one number: different source polygons disagree (e.g. the US Census total excludes inland water bodies), so the app fixes one definition — the CA-Nature ecoregion extent, SUM(Shape_Area) over ecoregion.parquet in EPSG:3310 — to keep every percentage comparable across questions and to match the conserved-areas layer's own footprint. Re-derived 2026-07-31: exact match. Pinning it also fixed real non-determinism (#87: ad-hoc recomputation gave 25.6% vs 27.8% across runs). This and the statewide 30% goal are the only hardcoded figures in system-prompt.md; every result — percent-conserved, the representation benchmark — is computed at query time. |
Settled | ca-30x30#87 |
Reproduced-and-matching items (statewide headline, all 20 ecoregions, 12 of 13 WHR13 classes, ACE ranks, plant/endemic top-20% after data-workflows#345) are recorded in ca-30x30#82 (closed); do not re-verify those either unless a run contradicts them.
Do not open meta-trackers, umbrella issues, or "batch" issues in this repo. Two of them (#82, #90) grew into multi-thousand-word threads that interleaved finished work, cross-repo pointers, and unanswered questions. The cost is real: deciding whether anything was still actionable required re-reading the entire thread, every time, and two genuinely open partner questions (wetlands extent, floodplain source) sat for weeks with no issue at all because they were bullet points inside one.
Every issue here must satisfy:
- One question or defect, closable on its own without splitting.
- States the gap in numbers (ours vs. theirs) rather than narrating an investigation.
- Names the next action and who takes it — and if that person is a partner, the issue is the question, phrased so it can be pasted into an email verbatim.
- Has an explicit "Done when:" line.
- Carries runnable SQL for any numeric claim, against public paths, with the expected output in a trailing comment. A number nobody can re-derive gets re-derived from scratch.
Rewrite the body; do not append comments. New findings replace the stale text — the issue must always read as the current state of the problem, not as a chronological log of how we got here. GitHub keeps the edit history if anyone needs the old version. An issue whose body is out of date and whose truth lives in comment #7 is the failure mode this section exists to prevent.
Keep an issue in exactly one repo — the one that will act on it. A divergence traced to
a source-data difference with a partner is not an ingest defect: it belongs here, not on
data-workflows. (data-workflows#413 sat on the wrong tracker for three weeks because it
was filed before the diagnosis was in.) Never leave the same open question live in two
repos; the second copy becomes a stale duplicate the moment the first is answered.
Labels that make the tracker scannable: blocked:collaborator (waiting on partner input —
no engineering will help), upstream (fix belongs in another repo; this issue only tracks
re-verification), validation (reproducing the 2025 assessment).
Audit results — query banks, per-question grades, model comparisons — are records, not tasks. Post them, then close the issue. If an audit surfaces N live problems, open N issues and close the audit with an index comment linking them (see #90 for the pattern).
Partner-reported agent failures (wrong numbers, hallucinated codes, speculation) are diagnosed and fixed through a fixed loop: observe logs → reproduce headless → trace each issue to the layer that owns it → fix in that layer → verify → deploy. The canonical reference is the geo-agent-training skill in boettiger-lab/open-llm-proxy (.claude/skills/geo-agent-training/SKILL.md) — read it before working a batch of issues.
The LLM's context is assembled from four layers, each owned by a different repo. You make code/config edits only in this app repo. For a root cause in any other layer, open a GitHub issue on the owning repo (with log evidence, root cause, exact proposed change, and how to verify) — never edit another repo's code, and never open a PR there. Their agents/maintainers action it. Reading sibling repos and their git history for diagnosis is encouraged.
| Layer | Owns | Repo | Symptom that points here |
|---|---|---|---|
| STAC catalog metadata | dataset paths, column schemas, coded values (code→name), dataset descriptions, per-dataset aggregation notes | data-workflows |
model guesses a column/code, can't resolve a class name, wrong paths, missing dataset-specific aggregation rule |
| MCP tool descriptions | SQL construction, H3 join rules, partition pruning, dedup/area-aggregation patterns, raster-vs-vector guidance | mcp-data-server (query-optimization.md, h3-guide.md) |
structurally wrong SQL, bad joins, generic aggregation/uncertainty caveats that apply across apps |
| geo-agent framework | tool orchestration, STAC-first enforcement, map-tool definitions, prompt assembly, LLM call params (e.g. temperature) | geo-agent |
model skips STAC tools, wrong tool type, ListTools spam, sampling/determinism knobs |
| App system prompt (here) | app persona, domain interpretation, attribution/framing, cross-dataset disambiguation, "data tool not advisor" / no-speculation guardrails | this repo (system-prompt.md) |
the issue is purely how this app interprets/presents results for its audience |
Objective vs. subjective — the test for STAC vs. system prompt. STAC metadata (incl. "per-dataset aggregation notes") holds only application-independent truths about a dataset: what a column means, coded values, that an ACE rank is a top quintile by construction. How to use a shared/national dataset for this app — which subset, threshold, or filter defines a "feature" — is a subjective, app-specific choice and belongs in system-prompt.md, even when an authoritative partner specifies it. Example: California 30x30 defines the "wetlands" feature as three NWI WETLAND_TYPE classes and ACE biodiversity features as rank 5; other apps interpret the same national layers differently, so those are app conventions (see system-prompt.md "Feature definitions"), not STAC notes. (Model SQL-handling failures — hex-count-vs-vector area, proportional GAP weighting — go to mcp-data-server, not the prompt.)
Prefer fixing the root cause in the correct layer over papering over it in system-prompt.md. Before adding anything to the system prompt, ask: could STAC metadata, an MCP tool description, or the framework fix this instead? If yes, file the issue there. After tracing a batch, the app repo gets the app-layer edits plus one summary issue linking all the cross-repo issues filed.
The benchmark home is boettiger-lab/geo-agent-benchmark (private): question banks, gold answers, traps and run results for every geo-agent app. Read its AGENTS.md before running anything. It supersedes the older banks in open-llm-proxy/headless/baseline/ and validation/2025-biodiversity-assessment/. open-llm-proxy/headless is the executor only; the benchmark supplies the questions.
Before claiming an issue fixed, reproduce it through a benchmark run, which replays the full geo-agent tool-use loop (catalog load, MCP connect, prompt assembly, tool calls) through the live proxy using this repo's current system-prompt.md and layers-input.json.
- Run the gate before shipping any prompt or guidance change (Kubernetes Job; mounts
PROXY_KEYfrom theopen-llm-proxy-secretsSecret inbiodiversity):Tiers arecd ../geo-agent-benchmark APP_REPO=boettiger-lab/ca-30x30 TIER=regression ./scripts/run_benchmark.sh APP_REPO=boettiger-lab/ca-30x30 TIER=commentary ./scripts/run_benchmark.sh # after a system-prompt change
smoke ⊂ regression ⊂ full, pluscommentaryfor unsupported-commentary compliance. Gates run against the default (prod) MCP. SetMCP_URL=https://dev-duckdb-mcp.nrp-nautilus.io/mcponly to test against an unreleasedmcp-data-serverbuild; a dev run does not measure deployed behaviour. - New regressions become a question YAML with a
traptag insuite/questions/ca-30x30/— not an ad-hoc question list. Record the outcome on the question, and add gold only atvalidation_levelL2 (operator SQL committed) or L3 (matches the published report). - Ad-hoc matrix sweep outside a tier (MRE work):
cd ../open-llm-proxy/headless && TAG=<tag> QUESTIONS_FILE=runs/<file>.txt MODELS="…" TRIALS=2 ./run-matrix-k8s.sh boettiger-lab/ca-30x30. Re-running the same question withTRIALS>1is how determinism regressions are checked. The Job clones the app repo atmain(override withAPP_BRANCH), so it tests exactly what is deployed. - Per-cell transcripts +
summary.tsvprint to the Job's stdout; the full request/response pairs land in the proxy logs. Analyze them peropen-llm-proxy/AGENTS.md+LOGGING.md(./sync-logs.shthen DuckDB over the consolidated parquet, filtered by the--origintag). - Single ad-hoc repro of one failure:
node run.js "QUESTION" --config layers-input.json --system-prompt system-prompt.md --model qwen3(seeheadless/README.md). Not for matrix work.
When testing SQL methodology directly (not the full agent loop), run it against the MCP query tool to confirm the numbers before encoding any guidance.
main is the live branch. The ca-30x30 deployment on k8s (namespace biodiversity) clones from main at pod startup — whatever is on main is what runs in production. (The app's deployment is named ca-30x30, not padus — padus is a different app sharing the cluster. Always target deployment/ca-30x30.)
Workflow for testing CDN pin updates or config changes:
- Create a
test/branch, make changes, verify jsDelivr serves the new SHA. - Merge the
test/branch tomain(fast-forward is fine). - Restart the deployment:
kubectl rollout restart deployment/ca-30x30 -n biodiversity
Do not merge to main before verifying the CDN SHA is live — jsDelivr can take up to an hour to index a new tag.
Full guide: boettiger-lab.github.io/geo-agent/docs/guide/deployment
Read the sections below before fetching that URL — they cover the two common k8s patterns. Fetch the docs only if you need details beyond what's here (e.g., GitHub Pages, Hugging Face Spaces, private data modules).
If you lack credentials or permissions to run
kubectlorgit push, do not attempt to discover or work around credentials. Instead, provide the user with the exact commands to run.
The pod's init container clones the GitHub repo at startup. Push to GitHub first, then restart.
git add <files> && git commit -m "<message>" && git push
kubectl rollout restart deployment/<app-name> -n <namespace>
kubectl rollout status deployment/<app-name> -n <namespace>Restarting without pushing first serves stale code.
When the GitHub repo is private, the pod reads content from a k8s ConfigMap instead of git-cloning. Never edit k8s/content-configmap.yaml directly — it is generated from source files.
# 1. Edit source files (index.html, layers-input.json, system-prompt.md)
# 2. Regenerate the ConfigMap
bash scripts/generate-configmap.sh
# 3. Apply and restart
kubectl apply -f k8s/content-configmap.yaml -n <namespace>
kubectl rollout restart deployment/<app-name> -n <namespace>
kubectl rollout status deployment/<app-name> -n <namespace>
# 4. Commit and push source files (not just the generated configmap)
git add <source-files> k8s/content-configmap.yaml && git commit -m "<message>" && git pushThe git push does not update running pods — step 3 does. Skipping generate-configmap.sh and re-applying serves the old ConfigMap.
For private data modules (rclone sidecar, oauth2-proxy, private parquet credentials): docs/guide/private-deployment
index.html tracks @main by default:
<script type="module" src="https://cdn.jsdelivr.net/gh/boettiger-lab/geo-agent@main/app/main.js"></script>When testing a geo-agent PR: pin to the PR's HEAD commit hash, verify jsDelivr serves it, then return to @main when done:
# Get latest SHA from a PR
gh pr view 166 --repo boettiger-lab/geo-agent --json headRefOid --jq '.headRefOid[:8]'
# Verify jsDelivr serves it before deploying
curl -sI https://cdn.jsdelivr.net/gh/boettiger-lab/geo-agent@<sha>/app/style.css | grep HTTP
# Must return HTTP/2 200Replace all three occurrences of the SHA in index.html (style.css, chat.css, sidebar.css, main.js), commit to main, and restart.
| Field | Required | Type | Description |
|---|---|---|---|
catalog |
Yes | string | STAC catalog root URL |
collections |
Yes | array | Collection specs (see below) |
view |
No | object | { "center": [lon, lat], "zoom": z } |
titiler_url |
No | string | TiTiler server for COG rasters (default: https://titiler.nrp-nautilus.io) |
mcp_url |
No | string | MCP/DuckDB server URL for SQL analytics |
llm |
No | object | LLM config for user-provided key mode (see below) |
welcome |
No | object | { "message": "...", "examples": ["...", "..."] } |
Security note: The public MCP server (
https://duckdb-mcp.nrp-nautilus.io/mcp) is open — no auth token is required or set. Themcp-data-serversupports optional bearer token auth: ifMCP_AUTH_TOKENis set in the server's environment it enforces auth on all requests; if unset, the server is open. The active deployment does not setMCP_AUTH_TOKEN, so no token is needed in client apps.
Each collections entry is a bare string (loads all visual assets) or an object:
| Field | Type | Description |
|---|---|---|
collection_id |
string | Must exactly match the "id" field in the STAC collection JSON — not a label you invent. Verify before use (see below). |
collection_url |
string | Direct STAC collection JSON URL — bypasses root catalog traversal |
group |
string | Layer toggle group label |
assets |
array | Asset selector (see below). Omit to load all visual assets. |
display_name |
string | Override collection title in UI |
Each assets entry is a bare string (the STAC asset key) or a config object:
| Field | Type | Description |
|---|---|---|
id |
string | Required. STAC asset key (e.g., "pmtiles") |
alias |
string | Alternative layer ID — use to create two logical layers from one STAC asset with different filters |
display_name |
string | Layer toggle label |
visible |
boolean | Default visibility (default: false) |
default_style |
object | MapLibre fill paint properties |
outline_style |
object | MapLibre line paint for an auto-added outline layer |
layer_type |
"line" or "circle" |
"line" for LineString features; "circle" for Point features — see warning below |
default_filter |
array | MapLibre filter expression at load time |
tooltip_fields |
array | Property names shown on feature hover |
group |
string | Override collection-level group for this layer |
| Field | Type | Description |
|---|---|---|
id |
string | Required. STAC asset key |
display_name |
string | Layer toggle label |
visible |
boolean | Default visibility (default: false) |
colormap |
string | TiTiler colormap name (e.g., "reds", "viridis") |
rescale |
string | TiTiler min,max range (e.g., "0,150") |
legend_label |
string | Legend label |
legend_type |
string | "categorical" to use STAC classification:classes colors |
Never use "layer_type": "line" to draw polygon outlines. This tells the renderer the tile features are LineString geometries. On a polygon-feature PMTiles file, it causes MapLibre to silently render nothing.
To draw polygon boundaries without a fill, use outline_style and set fill-opacity: 0:
{
"id": "pmtiles",
"display_name": "District Boundaries",
"visible": true,
"default_style": {
"fill-color": "#000000",
"fill-opacity": 0
},
"outline_style": {
"line-color": "#1565C0",
"line-width": 1.5
}
}Only set layer_type when the tile features match the geometry type:
"line"— LineString/MultiLineString features (roads, rivers, transects)"circle"— Point/MultiPoint features (observations, stations, events)
Always fetch the STAC collection JSON and verify — never guess. The collection_id must match the STAC "id" field exactly; a mismatch causes layers to silently not appear. Run this one-liner when you have the collection URL:
Some catalog entries are parent collections that contain sub-collections as "child" links, not assets of their own. The framework only traverses direct children of the root catalog — it does not recurse into parent collections to find nested sub-collections.
Symptom: you set a collection_id that exists in STAC but the layer never appears. The collection is a child of a parent collection, not of the root catalog.
Fix: always inspect the links array of every collection you encounter, and use collection_url to point directly to the sub-collection JSON URL:
import urllib.request, json
url = "<parent_collection_url>"
d = json.loads(urllib.request.urlopen(url).read())
print("id:", d["id"])
for l in d.get("links", []):
if l.get("rel") == "child":
print(" child:", l["href"], "|", l.get("title",""))Then in layers-input.json, set both collection_id (the exact STAC "id") and collection_url (the direct URL) so the framework bypasses root-catalog traversal:
{
"collection_id": "pad-us-4.1-fee",
"collection_url": "https://s3-west.nrp-nautilus.io/public-padus/padus-4-1/fee/stac-collection.json",
"assets": [...]
}curl -s <collection_url> | python3 -c "
import json, sys
d = json.load(sys.stdin)
print('collection_id:', d['id'])
for k, v in d.get('assets', {}).items():
vl = v.get('vector:layers', 'MISSING')
print(f' asset: {k} type: {v.get(\"type\",\"\")} vector:layers: {vl}')
"This also checks vector:layers on each PMTiles asset. If it shows MISSING, the STAC collection needs to be patched before the layer will render — the app falls back to the asset key as the source-layer name, which is almost always wrong.
Alternatively, browse the catalog in STAC Browser:
https://radiantearth.github.io/stac-browser/#/external/s3-west.nrp-nautilus.io/public-data/stac/catalog.json
Open a collection → the collection id is shown at the top. Under Assets, the keys (e.g., "pmtiles", "v2-total-2024-cog") are the id values for asset entries. For PMTiles, the asset's vector:layers field lists internal layer names — the app reads this automatically, no manual config needed.
PMTiles tiles contain only a subset of the parquet columns — tippecanoe selects fields at tile-build time. Do not assume field names from the STAC table:columns schema are available in the tiles. Before setting tooltip_fields or default_filter, inspect the PMTiles metadata directly:
python3 -c "
import urllib.request, struct, json
url = '<pmtiles_url>'
req = urllib.request.Request(url, headers={'Range': 'bytes=0-16383'})
data = urllib.request.urlopen(req).read()
off = struct.unpack_from('<Q', data, 24)[0]
ln = struct.unpack_from('<Q', data, 32)[0]
req2 = urllib.request.Request(url, headers={'Range': f'bytes={off}-{off+ln-1}'})
meta = json.loads(urllib.request.urlopen(req2).read())
for layer in meta.get('vector_layers', []):
print('layer name:', layer['id'])
print('fields:', list(layer.get('fields', {}).keys()))
"The vector_layers[].id value is the internal layer name (must be present in vector:layers in the STAC asset). The vector_layers[].fields keys are the only field names valid for tooltip_fields and default_filter.
Two common causes:
-
collection_idmismatch — the value inlayers-input.jsondoes not match the STAC collection's actual"id"field. Run the one-liner above and compare. The framework silently drops the collection if the IDs don't match. -
Wrong source-layer name — the
vector:layersfield in the STAC asset is missing or incorrect, so the app uses the asset key as the source-layer name and MapLibre finds no matching layer in the tiles. Checkvector:layerswith the one-liner above, and verify it matches thevector_layers[].idvalue from the PMTiles metadata script.
Use the modern match form for list membership:
["match", ["get", "ColumnName"], ["value1", "value2"], true, false]Do not use the legacy ["in", "ColumnName", "value1", "value2"] form — it is silently ignored by current MapLibre.
"llm": {
"user_provided": true,
"default_endpoint": "https://openrouter.ai/api/v1",
"models": [
{ "value": "anthropic/claude-sonnet-4", "label": "Claude Sonnet" },
{ "value": "google/gemini-2.5-flash", "label": "Gemini Flash" }
]
}Omit the llm block entirely for Kubernetes deployments where config.json is injected server-side.