Scope-aware data tools

analyseData and processData run model-written JavaScript over any combination of the agent’s places, routes, traffic incidents, custom polygons, traffic area analytics and your own GeoJSON. Each entry kind reaches the code as one per-entry record, such as placesByEntry. Code generation lists them all. For your own GeoJSON, the model picks property keys from the entry’s data profile rather than guessing.

Documenting every kind makes these tools large. Scope narrows each tool to the kinds a turn touches. This page explains how, and how to make your own tools scopable.

Why scope exists

A tool that documents all six input kinds, with each kind’s schema, costs thousands of tokens on every turn that offers it, even when the turn touches one kind. When a user asks “chart the places by category”, only places matters, so the other five kinds needn’t appear at all.

Unscoped surfaceIntent classifiertoolScopes.analyseData = { kinds: ['places'] }Narrowed surface for this turnplacesEntryIDs, routesEntryIDs, incidentsEntryIDs, geometriesEntryIDs, trafficAreaAnalyticsEntryIDs, byodEntryIDsplacesEntryIDs only tool selectedemit scope alongside selectionprepareStep re-builds description + inputSchema

The model sees a smaller tool for that turn. Scoped or not, the helper docs follow the active kinds. The cross-kind cheat-sheet appears when more than one is active. routeUtils appears with routes, and cluster with incidents.

Three default data tools are scopable: analyseData, processData and clusterIncidents. clusterIncidents covers incidents alone until a scope adds the kinds the query filters against, such as routes.

setMapStyling scopes the same way, by knob family rather than data kind. Its families are look, baseMap, pois, trafficFlow, trafficIncidents and terrain for the map’s own, and places, routes, geometries, ranges, trafficAreaAnalytics and trafficIncidentDetails for the layers the agent drew. Its set parameter then lists only those families’ knob ids, though any id stays settable. The tool refuses an unknown id and names the closest ones it resembles.

The scope shape

import type { EntryDataKind } from '@tomtom-org/maps-sdk-plugin-agent-toolkit';
// EntryDataKind = 'places' | 'routes' | 'incidents'
// | 'customGeometries' | 'trafficAreaAnalytics' | 'byod'
type DataToolScope = { kinds: readonly EntryDataKind[] };

All three scopable data tools use this shape, limited to the kinds left enabled by dataEntries. setMapStyling takes the same { kinds } with its knob families. EntryDataKind names data kinds, which differ from the state-slice keys in EntryModeSliceName (routes vs routing).

Mandatory scope

The classifier must emit a valid scope for every scopable tool it picks. An answer without one fails validation. After two failed attempts, the tool keeps its unscoped surface for that turn, which is larger but fully functional.

With classifier: false, scoping never engages and the tools always use their unscoped surface.

Observing scopes

const agent = createMapAgent(map, {
model: openai('gpt-4o'),
onClassify: (result) => {
console.log('tools:', result?.activeToolNames);
console.log('scopes:', result?.toolScopes);
// e.g. { analyseData: { kinds: ['places', 'routes'] } }
},
});

Watch toolScopes across a few turns to confirm the classifier picks minimal kind sets.

Add a scopable custom tool

Custom tools opt into the same narrowing with three pieces:

  1. A scopeSchema (Zod) with the scope shape your tool understands.
  2. A one-line scopePrompt that tells the classifier how to scope the tool.
  3. A ToolEntryBuilder instead of a static ToolEntry. The agent calls it again each turn with the parsed scope.
import { z } from 'zod';
import type { ToolEntryBuilder } from '@tomtom-org/maps-sdk-plugin-agent-toolkit';
type FleetScope = { kinds: ('vehicles' | 'jobs')[] };
const fleetScopeSchema = z.object({ kinds: z.array(z.enum(['vehicles', 'jobs'])).min(1) });
const fleetAnalyseBuilder: ToolEntryBuilder<MyState, FleetScope> = ({ scope }) => ({
description: scope
? `Analyse ${scope.kinds.join(' + ')} entries via dynamic JS.`
: 'Analyse fleet data.',
inputSchema: buildFleetSchema(scope), // narrowed when scope is set
execute: executeFleetAnalyse,
scopeSchema: fleetScopeSchema,
scopePrompt: 'Emit `{ kinds: ["vehicles" | "jobs"] }` listing only the kinds the user query touches.',
});
createMapAgent<MyState>(map, {
model: openai('gpt-4o'),
state: { fleet: new FleetState() },
tools: { fleetAnalyse: fleetAnalyseBuilder },
});

The classifier reads the scopePrompt and then requires a scope whenever it picks the tool. scope is undefined when the agent is created and on turns without a scope. For those, return a working unscoped entry.

Why builder, not static entry

Only a builder can be rebuilt per turn. A static ToolEntry with a scopeSchema keeps its full schema whatever scope the classifier emits.

  • Code generation — the runtime, sandbox and threat model for analyseData, processData and the other code-generation tools.
  • How it works — where in the turn the classifier emits scopes.
  • Customizing tools — non-scopable custom tools.
  • State — what each entry kind looks like in state.
  • Securing your agent — what the toolkit bounds per turn, and what needs a server.