This is the documentation of version 0.64.0. Go to the latest version, 1.0.0-rc.5.

Code generation

Experimental. analyseData, processData and executeMaplibreCode run model-written JavaScript; their inputs, injected identifiers and output contracts may change without notice. In the browser, the data tools' code runs isolated with no DOM, storage or network. executeMaplibreCode — and the data tools under Node or when the isolation can't start — run in your app's own realm, with its privileges. Read the threat model before shipping.

Three tools take JavaScript as a parameter instead of a fixed argument list. The model writes the code; the plugin compiles it with new AsyncFunction(...paramNames, code) and calls it with the injected inputs.

That is how the agent answers open-ended questions — "bar chart of POI categories", "union the boundaries of these cities", "focus the camera on the worst traffic on the route" — without a dedicated parameter for every variation: one tool per intent, any computation inside.

Where it shows up

Family Tools What the code does
Analyse analyseData Aggregate state into a result — counts, group-bys, top-N, hex bins, Chart.js configs, cross-kind correlations. Read-only: the result is recorded against every contributing entry and returned to the model. monitor: true recomputes it whenever a source entry changes; monitorAnalysis switches that on or off later.
Process processData Transform state into one new entry: places (with optional attached geometries and placeConnections), standalone geometries (a new customGeometries entry), or byod. fitOnMap moves the camera alongside any of them.
MapLibre executeMaplibreCode Run arbitrary JS against the live map for anything no other tool covers — custom layers, animations, fog, raster overlays. Returns a diff of added / removed / updated sources and layers.

Execution model

Each tool that accepts code builds an AsyncFunction whose parameters are a fixed set of injected identifiers, then calls it. Both data tools narrow their inputs per turn — see Scope-aware data tools.

Where the code runs:

The body works like any other async function body: top-level await is fine, locals and helper consts are fine, and the body must end with return …;. Importantly:

Pre-flight sanitisation

LLMs regularly prepend const turf = require('@turf/turf'), const h3 = await import('h3-js'), or const placesByEntry = arguments[0].placesByEntry out of habit. These collide with the injected parameters and would throw at parse time ("Identifier 'X' has already been declared") before the body even runs.

stripInjectedRedeclarations defensively removes those lines — but only when the RHS is one of:

A const geometriesByEntry = … that genuinely shadows an injected name (e.g. const geometriesByEntry = placesByEntry['places-2'].features.map(...)) is left untouched. This is deliberate — a naive name-only filter would silently corrupt legitimate user code.

Input copying

The injected state inputs — the per-entry records such as placesByEntry, plus previous / now on monitored runs — are copied before they reach the code, on every path (the browser's isolated run receives a structured clone). Mutating them in place is safe and local:

// Sorting/splicing the injected collection touches a throwaway copy, never live state.
const places = placesByEntry['places-2'];
places.features.sort((a, b) => a.properties.score - b.properties.score);
return { places: { type: 'FeatureCollection', features: places.features.slice(0, 10) } };

Without the copy, an in-place mutation would corrupt the entry the data came from and leak into later turns. The libraries are passed through as they are.

Shadowed globals

A fixed set of network, storage, DOM and process globals — fetch, XMLHttpRequest, WebSocket, EventSource, localStorage, sessionStorage, indexedDB, document, window, globalThis, self, navigator, importScripts, process — is bound to undefined inside the analyseData / processData body, wherever it runs. Code that reaches for one fails loudly instead of making a network call or touching browser state.

This is a tripwire, not a security boundary: the same capabilities stay reachable through constructor walks (({}).constructor.constructor) and other realm escapes, so it catches accidental access, not a determined or jailbroken model. executeMaplibreCode is not shadowed — it needs live browser access. In the browser, the isolated run is the boundary.

Injected identifiers

Tool Injected identifiers
analyseData placesByEntry, routesByEntry, incidentsByEntry, geometriesByEntry, trafficAreaAnalyticsByEntry, byodByEntry, h3, turf, routeUtils, cluster (plus previous, now, log when the analysis is monitored)
processData same as analyseData, without the monitor extras
executeMaplibreCode map (live MapLibre Map instance)

There is one per-entry record per kind, mapping an entry id to that entry's data — no merged variable:

Read one entry by id (placesByEntry['places-2']), or span every requested entry of a kind by merging with Object.values(...): Object.values(placesByEntry).flatMap((fc) => fc.features) for the FeatureCollection kinds (places / routes / byod / trafficAreaAnalytics), and Object.values(incidentsByEntry).flat() for the array kinds (incidents / geometries).

Each record is undefined when its *EntryIDs argument was omitted from the tool call — code must guard before reading (if (placesByEntry) ..., placesByEntry['places-2']?.features.length, etc.). Scope narrowing trims which kinds the tool documents in its prompt, but the runtime sandbox always carries the full set of names; out-of-scope kinds simply arrive as undefined.

Shared libraries

The two data tools get four libraries; executeMaplibreCode gets none, only map:

All four are pure computation. None does spatial search, place lookup, or HTTP. To fetch new places, the agent must call discoverPlaces (or locatePlace) BEFORE the code-generation tool and pass the resulting entry id in.

Turf input gotchas

Turf is fussy about input shape. The runtime hints at the right pattern when it throws, but knowing the rules up front avoids the retry loop:

Performance hints

For large-N nearest/within queries, pre-bucket points with h3 before running turf:

const cells = points.map((p) => h3.latLngToCell(p.geometry.coordinates[1], p.geometry.coordinates[0], 8));
// Only run turf on same- or neighbour-cell pairs — O(N+M) instead of O(N·M).

Pick res so each cell is approximately your query radius (h3 res 8 ≈ 460 m edge; res 9 ≈ 175 m).

Output contract

analyseData — outputFormat

The accepted chart types are: bar, line, pie, doughnut, radar, polarArea, scatter, bubble. Returning a config with any other type is rejected before the result reaches the model.

For per-point detail you'd normally put in a Chart.js tooltip.callbacks function, use a string[] label — Chart.js renders it multi-line on the axis tick AND in the tooltip title. Functions don't survive the JSON boundary.

processData envelope

processData code returns { places?, placeConnections?, geometries?, fitOnMap?, byod? }, with at least one of places, geometries, fitOnMap or byod:

Return-value validation

Every code-generation tool runs its return value through three checks before handing it to the model:

  1. Not undefined. If the body doesn't return (e.g. the code was written as (args) => { return … } so the arrow is created and discarded), the tool surfaces a targeted error telling the model to drop the arrow wrapper.
  2. JSON.parse(JSON.stringify(value)). This normalizes NaN, Infinity, undefined object values, and sparse arrays to null / drops them — silent-poison values that would otherwise break the next turn's ModelMessage validation. Circular references throw and surface as a clear LLM-facing error.
  3. Chart shape check (for outputFormat: "chart" only). The type must be in the whitelist above and data must be a non-null object.

The net effect: anything that comes out of the sandbox is safe to round-trip through the model's next prompt, or surfaces a precise diagnostic that the model can act on.

Self-correcting error hints

When sandbox code throws, the runtime matches the message against a curated list of common pitfalls and appends a targeted Hint: to the error returned to the model. The model usually corrects on the next attempt.

Patterns recognised include:

Pattern What the hint redirects toward
Cannot read property of undefined/null Missing ?. / ??, or a reduce callback without return acc / initial value.
h3.X is not a function / turf.X is not a function Library purpose (hex math / GeoJSON geometry); rejects spatial-search / HTTP use; suggests discoverPlaces as the right escape hatch.
coordinates must be an Array / Unknown Geometry Type Pass the feature, not bare coordinates; guard mixed-kind arrays like incidentsByEntry[id].
Illegal return statement Mismatched braces in a multi-line .map((item) => { ... }).
Identifier 'X' has already been declared Dropped redeclaration (rare after the sanitiser).
Unexpected token / identifier / end of input Body didn't parse as an async-function body; unbalanced braces or stray import/export.
require is not defined / import outside a module Sandbox is not a Node module — inputs and libraries are already injected.
functions / tools / discoverPlaces / recallState is not defined Sandbox can't call other tools — list entry ids in the tool input, or call the tool BEFORE this one.
is not iterable Default with ?? [] before for..of / spread / destructuring.
Cannot access 'X' before initialization Temporal dead zone — move the declaration above the first use.
Assignment to constant variable Use let or mutate in place.
structuredClone / DataCloneError / could not be cloned Return value isn't pure JSON — no functions, no classes, no DOM/Map refs; use string[] labels instead of tooltip callbacks.

So the tool descriptions needn't spell out every guardrail: the runtime corrects the model when it trips.

Threat model

In the browser, the data tools' code is isolated. Everywhere else the security boundary is the model and the prompt path you let users drive, not the runtime: input copying and shadowed globals stop accidental mutation and casual global access, not a determined model. Treat that code like a <script> tag controlled by your model provider:

If your deployment can't tolerate this — say, a page with privileged session cookies or sensitive in-browser state — remove the code-generation tools:

const agent = createMapAgent(map, {
    model: openai('gpt-4o'),
    tools: {
        analyseData: false,
        processData: false,
        executeMaplibreCode: false,
    },
});

Removing executeMaplibreCode alone keeps the isolated data tools. The fixed-schema tools (locatePlace, setRoute, findReachableAreas, the display tools, …) stay usable either way.

Data-tool execution isolation (experimental)

In the browser, analyseData / processData code runs off the main thread in a locked-down realm — a Web Worker inside a sandboxed, opaque-origin <iframe>. The environment picks where it runs (selection by environment). No setup is needed; the optional codeExecution tunes the browser run:

const agent = createMapAgent(map, {
    model: openai('gpt-4o'),
    codeExecution: {
        timeoutMs: 5000, // wall-clock budget per isolated run (default 10000)
    },
});

What this buys you, and what it doesn't:

How the pieces fit

The generated code runs two hops away from your app — across an opaque-origin iframe and into a Web Worker — with postMessage + structuredClone as the only channel between them:

Main thread (host realm)Opaque-origin sandboxed iframe · CSP default-src 'none'Node / SSR — no browser sandbox APIs → mainThreadExecutor (NOT a boundary)analyseData · processDataIframeWorkerExecutor (splits args, 10 s timeout)bootstrap (relays parent ⇄ worker)Web Worker (Blob URL)worker-libs (lazy chunk) — self.turf · self.h3 · self.routeUtils · self.clusterworker-runtime — new AsyncFunction(…) runSandboxedFn(code, args) provides turf / h3 / routeUtils / clusterpostMessagepostMessage (structuredClone){ ok, value } | { error }

The three boundaries map to the threats: the opaque origin stops DOM and storage access, the CSP stops network egress, and worker termination stops runaway loops. The libraries are bundled into the worker, so generated code never imports them.

Why a worker inside an iframe

The two-layer nesting isn't redundant — each layer provides a boundary the other can't:

Running off the main thread is also why heavy turf/h3 computation over large FeatureCollections doesn't freeze the map UI — though the postMessage + structuredClone hop pays a serialization cost at each edge, so it's a net win for compute-heavy bodies, not literally zero main-thread work.

Worker libraries (turf / h3 / routeUtils / cluster)

The worker can't import your app's modules, so it needs the libraries as one self-contained script. The plugin ships them in a separate lazy chunk, loaded only when the isolated run first starts — your main bundle is unaffected.

To pin a build, override with loadWorkerLibrarySource: return UMD or IIFE JavaScript defining self.turf and self.h3, plus self.routeUtils and self.cluster if generated code uses them. The override replaces the bundled source, so re-supply every namespace your code relies on:

codeExecution: {
    loadWorkerLibrarySource: async () => {
        const turf = await (await fetch('/vendor/turf.min.js')).text();
        const h3 = await (await fetch('/vendor/h3-js.umd.js')).text();
        // turf first — routeUtils reads self.turf. Append your own routeUtils / cluster
        // scripts here too if your generated code calls them.
        return `${turf}\n;${h3}`;
    },
},

Selection by environment

Execution mode is not configurable — it's chosen by environment:

Environment Runs in Why
Browser iframe-worker The boundary is the iframe's opaque origin + CSP egress-block + worker termination.
Node / SSR main thread None of those layers exist server-side. A worker_thread would add only termination while exposing fs / net / child_process — isolation in name only — so we don't spin one up. The main-thread runner still deep-copies inputs (so code can't corrupt live state), but it's defense-in-depth, not a boundary.

There is no opt-out in the browser. If the iframe fails to start, execution falls back to the main thread with a console warning — never silently.

Experimental. The isolation depends on real-browser behaviour — CSP enforcement, opaque-origin iframes, worker termination — which an end-to-end suite verifies in real Chromium on every CI run. executeMaplibreCode is not isolated: it needs the live map.

Example

A user asks "Bar chart of how many places of each category we have." The classifier picks analyseData with toolScopes.analyseData = { kinds: ['places'] }, narrowing its schema to places. The model emits:

analyseData({
    placesEntryIDs: ['places-2'],
    name: 'category-bar',
    outputFormat: 'chart',
    code: `
        const counts = {};
        for (const p of placesByEntry['places-2'].features)
            for (const c of (p.properties.poi?.categories ?? []))
                counts[c] = (counts[c] ?? 0) + 1;
        const entries = Object.entries(counts).sort((a, b) => b[1] - a[1]);
        return {
            type: 'bar',
            data: {
                labels: entries.map((e) => e[0]),
                datasets: [{ label: 'Places', data: entries.map((e) => e[1]) }],
            },
        };
    `,
});

The plugin runs the body, validates the Chart.js config, records it as category-bar against the source entry, and returns it to the model. The chat UI receives the same config and renders the chart inline.

Cross-kind composition

turf and h3 work on every feature, whichever record it came from, so one call can combine placesByEntry (Points), routesByEntry (LineStrings), incidentsByEntry (Point or LineString), geometriesByEntry (Polygon / MultiPolygon), trafficAreaAnalyticsByEntry (Polygon tiles with metrics) and byodByEntry (mixed GeoJSON).

A few common bridges:

The arguments are GeoJSON Features — pass the whole feature (placesByEntry[id].features[i], routesByEntry[id].features[0], geometriesByEntry[key][i], …), not the bare geometry.coordinates. A few helpers accept coords directly but most expect Features.

Related