Skip to content

Extract a harness-agnostic core: what's reusable vs. what to reimport #270

Description

@cboettig

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:

  1. 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.
  2. 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.
  3. 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.
  4. 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?

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions