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.
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_graphicadds a point, a line or a polygon. The input is GeoJSON geometry and an optional ArcGIS symbol.add_layeradds a feature, GeoJSON, GeoParquet, tile, vector tile, imagery or portal item layer. It takes a URL or a local file path.remove_layerandclear_graphicsremove content.go_tomoves the map to a center and zoom, or to a layer.map_statetells 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.
- Claude calls a tool, for example
add_graphic. - One
tool.callhook in the mod gets the call. It keeps only the fields that are in the tool schema. - The hook sends
POST /cmdto the bridge on127.0.0.1:8765. - The bridge sends the command to each open map page as a Server-Sent Event.
- The page does the command with the SDK and sends the result and its map state back with
POST /ack. - 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.
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:
| Setup | Map ready | Command to frame | Frames/s |
|---|---|---|---|
| Raw CDP, Metal GPU | 1.30 s | 64 ms | 58.8 |
| Raw CDP, SwiftShader | 3.04 s | 86 ms | 58.1 |
| puppeteer-core, Metal GPU | 1.32 s | 50 ms | 58.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.
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.
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.
- Start Claude Code.
- Type
/plugin install arcgis-map --marketplace mraad/claude-mod-map. - Type
/mapor/map browser. - 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
GeoJSONLayertakes 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