Core library for map-based applications with LLM-powered data analysis. Interactive MapLibre GL JS map with an agentic chatbot for natural language data queries and map control. Individual apps (different datasets, branding, deployments) import this library from the CDN and provide their own configuration.
- Frontend: MapLibre GL JS, PMTiles (vectors), COG + TiTiler (rasters), ES modules
- Data: STAC catalog → unified dataset records with visual + parquet assets
- Analytics: SQL queries via MCP (Model Context Protocol) to DuckDB on H3-indexed parquet
- LLM: OpenAI-compatible Chat Completions API (multiple models via proxy)
- Deployment: App JS/CSS is loaded from jsDelivr CDN. Every downstream app pins a version tag or commit SHA —
@mainis no longer used in production or demos. Downstream apps only need a static HTML file — see the geo-agent-template repo.
main.js— Bootstrap: loads config, initializes catalog → map → tools → agent → UIdataset-catalog.js— Fetches STAC collections, builds unified recordsmap-manager.js— Creates MapLibre map, manages layers/filters/stylesmap-tools.js— the local tools the LLM agent can call (map control, styling, legends, geocoding)tool-registry.js— Unified dispatch for local + remote (MCP) toolsmcp-client.js— MCP transport wrapper (connect once, lazy reconnect)agent.js— LLM orchestration loop (agentic tool-use cycle)chat-ui.js— Chat UI with collapsible tool-call blocks
Settled architectural calls are recorded in docs/design/ so they aren't re-litigated. Read the relevant note before reopening one of these questions.
docs/design/tool-call-parsing.md— why tool-call dialect recovery (parseEmbeddedToolCalls/looksLikeAttemptedToolCallinapp/agent.js) lives in the harness and is kept, not moved to the server/proxy or replaced by a library.
app/layers-input.json— Static config: STAC catalog URL, collection IDs, map viewconfig.json— Generated at deploy time by k8s (LLM models + API keys from secrets)- Both are merged by
main.jsat startup; runtime config overrides static config - Full configuration reference:
docs/guide/configuration.md(the VitePress site is the canonical user docs)
Each collection's assets array supports three formats:
1. Simple string — include a STAC asset by ID, using defaults:
"assets": ["cpad-holdings-pmtiles"]2. Object — override display name, style, filters, etc.:
"assets": [
{
"id": "irrecoverable-total-2018-cog",
"display_name": "Irrecoverable Carbon (2018)"
}
]3. Versioned — multiple STAC assets behind one logical layer, selectable via dropdown:
"assets": [
{
"id": "watersheds",
"display_name": "Watersheds",
"versions": [
{ "label": "L3 – Major Basins", "asset_id": "hydrobasins_level_03" },
{ "label": "L4", "asset_id": "hydrobasins_level_04" },
{ "label": "L5", "asset_id": "hydrobasins_level_05" },
{ "label": "L6 – Sub-catchments", "asset_id": "hydrobasins_level_06" }
],
"default_version": "L6 – Sub-catchments"
}
]When versions is present, the layer panel shows one checkbox + a <select> dropdown. Switching versions swaps the visible map asset without adding/removing layer entries. The default_version matches by label (falls back to first entry if not found). All per-asset config options (default_style, default_filter, colormap, etc.) apply to every version uniformly.
Works for both PMTiles (vector) and COG (raster) assets. All versions must share the same layer type.
The main branch is protected: direct pushes are rejected. All changes must go through a pull request.
Committing and pushing changes:
- Make changes, then commit:
git add <files> git commit -m "<message>"
- Create a feature branch and push:
git checkout -b <branch-name> git push -u origin <branch-name>
- The push output includes a PR URL — open it to create the pull request:
remote: Create a pull request ... by visiting: remote: https://github.com/boettiger-lab/geo-agent/pull/new/<branch-name> - After the PR is merged, always clean up:
git checkout main git pull git branch -d <branch-name>
If the user confirms the PR has been merged and asks to "clean up" or "switch back to main", run all three cleanup commands together.
Downstream apps pin a specific jsDelivr ref — either a release tag (@vX.Y.Z, preferred) or a commit SHA. @main is not used by any deployed app; merging a PR here does not propagate to downstream apps until those apps bump their pin. Coordinating that bump across the fleet is the job of the private geo-agent-ops repo.
Release flow:
- Merge feature PRs to
mainhere. - Cut a release with
gh release create vX.Y.Z --target main --generate-notes(or edit notes for migration-relevant changes). - geo-agent-ops opens PRs against each downstream app to bump its pin.
Because every consumer is pinned, jsdelivr.net cache purges are normally unnecessary; the only situation that needs them is a SHA-pinned demo where a force-push rewrote the SHA's content (don't do this).
Live deployments:
- boettiger-lab/geo-agent-template → canonical starting point for new apps; deployed to GitHub Pages as live demo (pinned to a tag).
Downstream apps serve a static HTML file that loads the pinned app code from jsDelivr at runtime. Two cases are easy to conflate:
- The library ships a new version here. Pinned downstream apps are unaffected and need no action — that's the whole point of pinning. Nothing to roll out.
- A downstream app bumps its own pin. This edits the app's
index.html, so the new file must be re-served. How depends on the deployment:- GitHub Pages (e.g. the geo-agent-template demo) — auto-rebuilds on push; nothing further needed.
- k8s pods that
git cloneat pod start (initContainer, e.g. thepadusdeployment of geo-agent-template) — the running pod keeps serving theindex.htmlit cloned at startup, so you mustkubectl -n biodiversity rollout restart deployment/<name>for the initContainer to re-clone the new pin. Nokubectl applyis needed whenindex.htmlcomes from the clone rather than a ConfigMap. - k8s pods serving content from a ConfigMap (private configmap-based apps) — regenerate and
kubectl applythe ConfigMap, then rollout restart.
- Local:
cd app && python -m http.server 8000 - Create a local
app/config.jsonwith LLM model configs for development config.jsonis in.gitignore— never committed (contains API keys)
npm testruns the suite;npm run test:coverageprints a per-module coverage table.- Tests live in
test/; CI runs them on every PR (.github/workflows/test.yml). - The test runner is vitest. Browser-bound modules are not in jsdom — they're left to manual verification in deployed apps.
When a PR touches a covered module, expect tests to change too. When it touches an uncovered one, verify by hand on a representative downstream app.
| Module | Coverage | What changes here should be tested |
|---|---|---|
app/transcriber.js |
100% | test/transcriber.test.js — endpoint resolution, error paths, abort signal |
app/mcp-client.js |
99% | test/mcp-client.test.js — connect / reconnect / callTool retry / resources / prompts |
app/tool-registry.js |
98% | test/tool-registry.test.js — registration, dispatch, argsRewriter, schema cleaning |
app/map-tools.js |
98% | test/map-tools.test.js — local-tool execute paths, get_schema MCP delegate |
app/dataset-catalog.js |
89% | test/dataset-catalog.test.js — STAC parsing, layer config shape, prompt rendering |
app/agent.js |
48% | test/agent-retry.test.js — retry / abort / timeout. The conversation loop is uncovered (tested manually). |
app/main.js |
0% | Bootstrap — verify by loading the app locally. |
app/chat-ui.js, app/map-manager.js, app/layout-manager.js, app/map-draw.js, app/h3geo.js, app/animation-manager.js, app/voice-input.js |
0% | Browser-bound (DOM, MapLibre, MediaRecorder). Verify visually in a deployed app — no harness yet. |
Overall line coverage is reported on each PR via the coverage workflow.