Summary
Could the domain-specific core of this app be extracted into a small, harness-agnostic library, so that another project — one that already has a fully-functional agent harness (Vercel AI SDK, opencode, Claude Code, a JupyterHub setting, etc.) and uses maplibre-gl directly — can reproduce this functionality by reimporting the harness and depending on just the reusable nucleus?
This is a design discussion, not a committed work item. Recording it so the architecture is on the record.
The question
Today app/agent.js is a hand-rolled agentic tool-use loop and app/chat-ui.js is a hand-rolled chat UI. For this deployment (browser ES modules, no build step, CDN-loaded, talking to a heterogeneous zoo of open-weight models through the NRP proxy) that hand-roll is justified — see the reasoning in [earlier discussion]. But if a consumer already has a real harness, most of that code is scaffolding they don't need. So: what's actually worth keeping/packaging, and what should they just reimport?
Proposed split
Drop — the harness already does this (~2,500 lines):
agent.js control loop (retry/abort/checkpoint) → harness owns the loop
mcp-client.js → harness has first-class MCP
chat-ui.js → host IDE/harness owns the chat surface
tool-registry.js dispatch → harness tool registry
parseEmbeddedToolCalls / extractReasoning shims in agent.js → these exist only to babysit misbehaving open-weight models; with a clean harness (Claude, etc.) they're pure dead weight
Keep & package — the accumulated domain knowledge:
- The tool pack (
map-tools.js, ~19 tools) — the vocabulary of how an LLM drives a geospatial map (showLayer/hideLayer/setFilter/setStyle/filter_by_query/flyTo/setProjection/hex tile layers). The schemas encode hard-won gotchas (modern MapLibre filter expressions; outline_style vs layer_type:"line"; the >20-IDs → filter_by_query discipline). Not in MapLibre, not in a harness.
- The catalog binding (
dataset-catalog.js + the layers-input.json schema) — STAC collections → LLM-legible dataset records, asset config (simple/object/versioned), schema injection into the prompt.
- The domain prompt (
system-prompt.md + rendering) — tool-selection heuristics, DuckDB-on-H3-parquet SQL discipline, the register_hex_tiles → add_hex_tile_layer pattern. Model- and harness-agnostic.
- Map glue — the useful subset of
map-manager.js (STAC asset → PMTiles vector AND COG-via-TiTiler raster layer, filter/style state, version-swap), legend-helpers.js (continuous/graduated legends), h3geo.js/hex-layer-helpers.js (the SQL-aggregation-too-big-for-a-table → tile-layer bridge).
Note: "just use maplibre-gl directly" overshoots. map-manager.js (1,537 lines) is not a MapLibre convenience wrapper — it's the STAC/PMTiles/COG/legend/versioning glue MapLibre deliberately doesn't provide. A reuser on raw MapLibre would re-derive exactly the part worth keeping. So MapLibre is a peer dependency, not a replacement.
The seam is already small
The entire DI point is createMapTools(mapManager, catalog, mcpClient, geocoder, options) (map-tools.js:64). The ~19 tools touch the rest of the app through exactly:
- 16 map methods:
showLayer hideLayer setFilter clearFilter resetFilter setStyle resetStyle setTooltip resetTooltip setProjection flyTo getMapState getLayerSummaries addHexTileLayer removeHexTileLayer syncCheckbox
- 4 catalog methods:
get getAll getIds toStacDict
- 1 query call:
mcpClient.callTool (used only by get_schema + filter_by_query)
Decouple those behind two interfaces (MapOps, Catalog) plus an injected QueryBackend, and the tool pack stops caring whether the implementation is our map-manager or someone else's MapLibre code. Each harness then gets a ~10-line adapter mapping the neutral tool descriptors to its tool-registration format.
Two warts to fix during extraction:
syncCheckbox is UI state (the layer-panel checkbox) leaking into the tool layer — should not be in MapOps; the host observes visibility changes and updates its own controls.
filter_by_query / get_schema are composite (SQL → map op). Keep them as packaged tools rather than letting the LLM compose them — they enforce that matched IDs never enter the model's context.
Rough sizing
Reusable core ≈ tool defs (~640) + catalog builder (~890) + MapOps impl subset of map-manager (~800 of 1,537 once UI/basemap concerns are stripped) + legend/hex helpers (~240) + prompt (~90). The ~2,500 lines of agent.js + chat-ui.js + mcp-client.js + model shims do not come along.
Open questions
- Is there appetite to actually extract a
stac-map-agent (or similarly named) package, or is this purely documentation of the architecture for would-be reusers?
- If extracted: published where (npm? the no-build/CDN model complicates it — see the packaging discussion) and with what peer-dep contract (
maplibre-gl, pmtiles, MCP SDK)?
- Does the extraction help this repo too (clearer module boundaries, the
MapOps interface as a testability seam for the currently-0%-covered map-manager.js), independent of any external consumer?
Summary
Could the domain-specific core of this app be extracted into a small, harness-agnostic library, so that another project — one that already has a fully-functional agent harness (Vercel AI SDK, opencode, Claude Code, a JupyterHub setting, etc.) and uses
maplibre-gldirectly — can reproduce this functionality by reimporting the harness and depending on just the reusable nucleus?This is a design discussion, not a committed work item. Recording it so the architecture is on the record.
The question
Today
app/agent.jsis a hand-rolled agentic tool-use loop andapp/chat-ui.jsis a hand-rolled chat UI. For this deployment (browser ES modules, no build step, CDN-loaded, talking to a heterogeneous zoo of open-weight models through the NRP proxy) that hand-roll is justified — see the reasoning in [earlier discussion]. But if a consumer already has a real harness, most of that code is scaffolding they don't need. So: what's actually worth keeping/packaging, and what should they just reimport?Proposed split
Drop — the harness already does this (~2,500 lines):
agent.jscontrol loop (retry/abort/checkpoint) → harness owns the loopmcp-client.js→ harness has first-class MCPchat-ui.js→ host IDE/harness owns the chat surfacetool-registry.jsdispatch → harness tool registryparseEmbeddedToolCalls/extractReasoningshims inagent.js→ these exist only to babysit misbehaving open-weight models; with a clean harness (Claude, etc.) they're pure dead weightKeep & package — the accumulated domain knowledge:
map-tools.js, ~19 tools) — the vocabulary of how an LLM drives a geospatial map (showLayer/hideLayer/setFilter/setStyle/filter_by_query/flyTo/setProjection/hex tile layers). The schemas encode hard-won gotchas (modern MapLibre filter expressions;outline_stylevslayer_type:"line"; the >20-IDs →filter_by_querydiscipline). Not in MapLibre, not in a harness.dataset-catalog.js+ thelayers-input.jsonschema) — STAC collections → LLM-legible dataset records, asset config (simple/object/versioned), schema injection into the prompt.system-prompt.md+ rendering) — tool-selection heuristics, DuckDB-on-H3-parquet SQL discipline, theregister_hex_tiles → add_hex_tile_layerpattern. Model- and harness-agnostic.map-manager.js(STAC asset → PMTiles vector AND COG-via-TiTiler raster layer, filter/style state, version-swap),legend-helpers.js(continuous/graduated legends),h3geo.js/hex-layer-helpers.js(the SQL-aggregation-too-big-for-a-table → tile-layer bridge).The seam is already small
The entire DI point is
createMapTools(mapManager, catalog, mcpClient, geocoder, options)(map-tools.js:64). The ~19 tools touch the rest of the app through exactly:showLayer hideLayer setFilter clearFilter resetFilter setStyle resetStyle setTooltip resetTooltip setProjection flyTo getMapState getLayerSummaries addHexTileLayer removeHexTileLayer syncCheckboxget getAll getIds toStacDictmcpClient.callTool(used only byget_schema+filter_by_query)Decouple those behind two interfaces (
MapOps,Catalog) plus an injectedQueryBackend, and the tool pack stops caring whether the implementation is ourmap-manageror someone else's MapLibre code. Each harness then gets a ~10-line adapter mapping the neutral tool descriptors to its tool-registration format.Two warts to fix during extraction:
syncCheckboxis UI state (the layer-panel checkbox) leaking into the tool layer — should not be inMapOps; the host observes visibility changes and updates its own controls.filter_by_query/get_schemaare composite (SQL → map op). Keep them as packaged tools rather than letting the LLM compose them — they enforce that matched IDs never enter the model's context.Rough sizing
Reusable core ≈ tool defs (~640) + catalog builder (~890) +
MapOpsimpl subset of map-manager (~800 of 1,537 once UI/basemap concerns are stripped) + legend/hex helpers (~240) + prompt (~90). The ~2,500 lines ofagent.js+chat-ui.js+mcp-client.js+ model shims do not come along.Open questions
stac-map-agent(or similarly named) package, or is this purely documentation of the architecture for would-be reusers?maplibre-gl,pmtiles, MCP SDK)?MapOpsinterface as a testability seam for the currently-0%-coveredmap-manager.js), independent of any external consumer?