A Map Beside the Chat: An ArcGIS Mod for Claude Code

Claude Code now has mods. A mod is a plugin with a hooks module: a TypeScript file whose functions Claude Code calls when events occur. A mod can add tools for Claude, slash commands, and panes beside the chat. When I read that, I had one question. Can I put a live ArcGIS map next to Claude, and can Claude draw on it while I talk to it?

The answer is yes, with one interesting detour. In this post, I show the mod, explain how the parts connect, and tell you what does not work yet. The source is on GitHub: mraad/claude-mod-map.

ArcGIS map with a dark basemap, seven labeled red city markers and a blue geodesic route from Sydney to San Francisco.
Markers in seven cities, labels, a geodesic route and a dark basemap, all placed with the mod's tools.

What the mod does

The mod gives Claude six tools. Each tool changes a map that uses the ArcGIS Maps SDK for JavaScript 5.2.

  • add_graphic adds a point, a line or a polygon. The input is GeoJSON geometry and an optional ArcGIS symbol.
  • add_layer adds a feature, GeoJSON, GeoParquet, tile, vector tile, imagery or portal item layer. It takes a URL or a local file path.
  • remove_layer and clear_graphics remove content.
  • go_to moves the map to a center and zoom, or to a layer.
  • map_state tells Claude what is on the map.

The command /map shows the map in the Claude Code pane on the right side. The command /map browser shows the same map in a Chrome app window on the right half of the screen. You can open both. Each tool changes both.

The mod has no npm dependencies. It uses the Node standard library, the mods API and the Google Chrome that is already on my Mac. The basemap is OpenStreetMap, so you do not need an API key.

How the parts connect

A hooks module has no DOM and no Node. It talks to the outside world only through the engine interface. So the map cannot live inside the mod. The map lives in a web page, and the mod talks to that page through a small local server. I call that server the bridge.

  1. Claude calls a tool, for example add_graphic.
  2. One tool.call hook in the mod gets the call. It keeps only the fields that are in the tool schema.
  3. The hook sends POST /cmd to the bridge on 127.0.0.1:8765.
  4. The bridge sends the command to each open map page as a Server-Sent Event.
  5. The page does the command with the SDK and sends the result and its map state back with POST /ack.
  6. The hook gives the result to Claude.

The mod starts the bridge when the session starts, and the bridge stops with the mod. The bridge also prints one line on its standard output for each map change, for example STATE {...}. The mod reads these lines and updates the pane header and the status line. A plain text protocol on stdout was the simplest channel I found, and it needs no polling.

The detour: a map in a terminal pane

The pane is drawn in the terminal. It has text, boxes and buttons. It does not have a web view. But it has an Image element, and Ghostty can show pictures. So I render the map somewhere else and send pictures of it into the pane.

The bridge starts a headless Chrome on the same map page. It drives Chrome over the Chrome DevTools Protocol on --remote-debugging-pipe, with no library. Chrome sends a PNG frame each time the map changes. The bridge writes the frame to a file, then renames it, so the terminal never reads half a picture. The mod swaps the picture in the pane with $.ui.blit, without a redraw.

Map frame from the pane: Italy with a blue outline from a filtered feature layer.
A pane frame. The page hides its layer list in this mode. The Italy outline is a feature layer with the filter COUNTRY = 'Italy'.

Mouse input goes the other way. Under the picture, the pane has an empty Client element, and the picture sits on top of it in an absolute box. The Client gets the pointer and key events and posts them to the mod. The mod sends them to the bridge, and the bridge gives them to Chrome as real mouse and key events. A drag pans the map. A double-click zooms in. One detail took some thought: a later post in the same frame replaces an earlier one. So each post carries the last 32 events with sequence numbers, and the mod forwards only the new ones.

Before I wrote the viewer, I measured. Lightpanda is the fastest headless browser in published benchmarks, but it has no rendering engine, and the SDK needs WebGL2. So I compared Chrome setups on my M4 Max:

SetupMap readyCommand to frameFrames/s
Raw CDP, Metal GPU1.30 s64 ms58.8
Raw CDP, SwiftShader3.04 s86 ms58.1
puppeteer-core, Metal GPU1.32 s50 ms58.7

The browser driver made almost no difference. The GPU flag did: --use-angle=metal cut the time to a ready map from 3.0 s to 1.3 s. So the viewer uses raw CDP, which needs no dependency, and the Metal GPU.

Seven cities, one GeoJSON document

Here is a request that I like to test with: "Create markers in Sydney, Dubai, Beirut, Florence, Paris, New York and San Francisco. Connect them with a geodesic line. Make the basemap dark. Put everything in one GeoJSON document."

The GeoJSON part is the interesting one. A GeoJSON line is straight in longitude and latitude, so it is not geodesic. To make it geodesic, I put 32 points on the great circle for each leg. The result is one FeatureCollection with seven Points and one LineString of 193 points, about 27,400 km long. It is in the repository as examples/cities-route.geojson.

Europe and the Middle East on a dark basemap with Paris, Florence, Beirut and Dubai labels and the blue route.
The same route at zoom 4. The labels are text symbols in their own graphics layer.

The dark basemap shows a gap in my tool set. The mod has no basemap tool, and a new layer always goes on top. So Claude added the Esri Dark Gray tile layers, removed the graphics layers, and added them again on top. It worked, but a set_basemap tool is the better answer.

GeoParquet, and one HEAD request

The SDK has a ParquetLayer, so GeoParquet support was a small step. The mod sends a local path to the bridge. The bridge registers the file and serves it at a random URL. The first test failed with "Failed to parse file". The cause was not the file. ParquetLayer sends a HEAD request first to get the file size, then range requests for the footer. My bridge answered HEAD with 404. After I added HEAD and range support, 300 sample points loaded at once.

Kuwait with orange GeoParquet points, a green country outline, a red polygon and a blue marker.
300 points from a local GeoParquet file (orange), a filtered feature layer (green) and two graphics.

A local server is still a server

The bridge listens only on 127.0.0.1, but any web page in your browser can send requests to 127.0.0.1. So the bridge refuses a request when the Host header is not 127.0.0.1:8765 (this stops DNS rebinding) or when the Origin header is from a different site. That check does not stop other programs on the same computer. So the mod also makes a random token each time it loads, and the bridge refuses the control requests without it. File URLs use random keys. The bridge is still for one user on one computer, and the documentation says so.

Try it

You need macOS, Claude Code v2.1.287 or later, Node.js and Google Chrome. A mod runs with your permissions, so read the code first.

  1. Start Claude Code.
  2. Type /plugin install arcgis-map --marketplace mraad/claude-mod-map.
  3. Type /map or /map browser.
  4. Ask Claude to put something on the map.

What does not work yet

  • The terminal does not send the scroll wheel to a pane. In the pane, you zoom with a double-click, the map buttons or the + and - keys.
  • The pane picture assumes a terminal cell of 1:2. Other cell shapes stretch the picture.
  • The port is fixed at 8765, so there is one map per computer. During testing, another program took that port, and the tools stopped until it was free again.
  • A GeoJSONLayer takes one geometry type. My seven-city document has points and a line, so it cannot load as one layer.
  • The hidden Chrome for the pane uses 1.2 to 1.9 GB of memory. The bridge stops it 90 seconds after the pane closes.

Next, I want a set_basemap tool and a geometryType option on add_layer, so one GeoJSON document can load as two layers. The code, the tests and a full STE100 HTML document are in the repository. More to come.

Comments

Popular posts from this blog

How to use Esri Flex API on Android and iPhone

Augmented Reality, iPhone and ArcGIS Server

Weighted Map Feature Clustering With Attributes