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. |
wyoming-public-demo |
Application repo. Configure layers-input.json, system-prompt.md, and k8s/ for this dataset. |
Full docs: boettiger-lab.github.io/geo-agent/docs — 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.
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 pins the geo-agent library version:
<!-- Pinned — immutable, recommended for production -->
<script type="module" src="https://cdn.jsdelivr.net/gh/boettiger-lab/geo-agent@v3.0.2/app/main.js"></script>
<!-- Latest main — staging/development only -->
<script type="module" src="https://cdn.jsdelivr.net/gh/boettiger-lab/geo-agent@main/app/main.js"></script>To upgrade: change the tag in index.html and redeploy. Verify jsDelivr serves the new tag before deploying — new tags can take up to an hour to be indexed:
curl -sI https://cdn.jsdelivr.net/gh/boettiger-lab/geo-agent@vX.Y.Z/app/style.css | grep HTTP
# Must return HTTP/2 200 — a 404 means jsDelivr hasn't indexed the tag yet| 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 private MCP server (
https://private-duckdb-mcp.nrp-nautilus.io/mcp) requires a bearer token (MCP_AUTH_TOKEN) and is restricted to authorized apps only. Do not setmcp_urlto the private server or injectMCP_AUTH_TOKENinto a new app's k8s deployment without explicit authorization.
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.