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

Customizing tools

You can remove tools users shouldn't trigger from the built-in registry, replace a default with one wired to your backend, add domain tools, or start from a blank slate and hand-pick built-ins. The four patterns combine in one createMapAgent call.

How the registry is resolved

Three sources, in this order, make the tool set the agent exposes:

  1. DEFAULT_TOOLS - The 56 built-in tools, unless you set includeDefaultTools: false.
  2. dataEntries - A disabled kind drops its tools, as listed in TOOLS_BY_DATA_ENTRY_KIND, and leaves the data tools' inputs. byod: { enabled: false } removes addByodSource, setByodLayers and updateByodDisplay.
  3. Your tools option - Per-key overrides. They win over both, so a tool you pass under a dropped name comes back.
DEFAULT_TOOLSdataEntries narrowingtools { ... } overridesAgent's live tool set 1. start from defaults2. drop disabled kinds3. apply per-key overrides

Remove a default tool

To drop a tool, pass false under its key. Do this when a tool is irrelevant to your use case, such as language switching in a single-language deployment, or harmful, such as executeMaplibreCode where prompts are untrusted.

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

The classifier doesn't see removed tools, and the model can't call them.

Replace a default tool

Pass a full ToolEntry under a built-in's key, and the toolkit never instantiates the built-in. Usually you wire a built-in to your backend. Here, getCurrentLocation reads the fleet system instead of the browser's geolocation API:

import { z } from 'zod';

const agent = createMapAgent(map, {
    model: openai('gpt-4o'),
    tools: {
        getCurrentLocation: {
            description: 'Returns the user position from the company fleet system.',
            inputSchema: z.object({}),
            execute: async () => {
                const position = await fleetApi.getDriverPosition();
                return { position };
            },
            classificationPrompt: 'Get the driver\'s current GPS position from the fleet system.',
            tags: ['location'],
        },
    },
});

Keep the original's input and output shape where you can, because other tool descriptions and the system prompt may assume it. If yours differs, say so in its description or your systemPromptSuffix, so the model doesn't pass the old fields.

Add a custom tool

Custom tools receive the same state as the built-ins, so they can hand results to the built-in display flow. Add places with state.places.addPlaceResult and your GeoJSON with state.byod.addEntry.

import { z } from 'zod';
import type { Place } from '@tomtom-org/maps-sdk/core';
import type { ToolEntry } from '@tomtom-org/maps-sdk-plugin-agent-toolkit';

const getFleetVehicle: ToolEntry = {
    description: 'Get the current map position of a fleet vehicle by ID and add it to the places history.',
    classificationPrompt: 'Locate or display a fleet vehicle on the map by its ID.',
    inputSchema: z.object({ vehicleId: z.string().describe('The fleet vehicle identifier') }),
    execute: async ({ vehicleId }, state) => {
        const position = await fleetApi.getPosition(vehicleId);
        const place: Place = {
            type: 'Feature',
            id: `vehicle-${vehicleId}`,
            geometry: { type: 'Point', coordinates: position },
            properties: {
                type: 'POI',
                address: { freeformAddress: '' },
                poi: { name: `Vehicle ${vehicleId}`, categories: [], localizedCategories: [] },
            },
        };
        // Append to the places history; the model can then show it via updatePlacesDisplay,
        // route to it via setRoute, or recall it later through recallState.
        const entryId = await state.places.addPlaceResult(place, `Vehicle ${vehicleId}`);
        return { vehicleId, entryId, position };
    },
    tags: ['location'],
    examplePrompts: ['Where is vehicle TT-001?', 'Show fleet vehicle on the map'],
};

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

The tool takes part in intent classification through its classificationPrompt. The classifier reads that line, not the description. A tool whose schema should change with what the user asked is a scopable custom tool.

Resolve a place in a custom tool

A custom tool that needs one place by name can run the same lookup as the built-in locatePlace tool:

import { getViewportBias, locatePlace, type ToolEntry } from '@tomtom-org/maps-sdk-plugin-agent-toolkit';
import { z } from 'zod';

const findStore: ToolEntry = {
    description: 'Find one of our stores by name, near the map view when zoomed in, and add it to the places history.',
    classificationPrompt: 'Locate a named store of ours.',
    inputSchema: z.object({ storeName: z.string() }),
    execute: async ({ storeName }, state, options) => {
        try {
            const position = getViewportBias(state.baseMap);
            const place = await locatePlace(storeName, 'poi', position && { position }, options);
            if (!place) return { status: 'not_found' };
            return { entryId: await state.places.addPlaceResult(place, storeName) };
        } catch (error) {
            return { error: `Store lookup failed: ${error instanceof Error ? error.message : String(error)}` };
        }
    },
};

A failed or cancelled lookup rejects, so execute catches it and returns an error shape, as the next section explains.

Errors are part of the contract

Every execute catches its own failures and returns an error shape, typically { error: string }. A throw surfaces as an opaque step failure the model can't reason about. Name the missing entity and the next valid action:

execute: async ({ vehicleId }, state) => {
    try {
        const position = await fleetApi.getPosition(vehicleId);
        // …
    } catch (error) {
        return { error: `Fleet vehicle "${vehicleId}" not found. Call listFleetVehicles to see available IDs.` };
    }
}

Start from a blank slate

For a narrowly scoped agent that should not stray into general map control, set includeDefaultTools: false and re-add the built-ins you want from DEFAULT_TOOLS. tools takes any tool definition, an entry or a builder:

import { createMapAgent, DEFAULT_TOOLS } from '@tomtom-org/maps-sdk-plugin-agent-toolkit';

const agent = createMapAgent(map, {
    model: openai('gpt-4o'),
    includeDefaultTools: false,
    tools: {
        getFleetVehicle,                         // your custom tool
        locatePlace: DEFAULT_TOOLS.locatePlace,  // re-add built-ins
        flyTo: DEFAULT_TOOLS.flyTo,
        analyseData: DEFAULT_TOOLS.analyseData,
    },
});

dataEntries still narrows the data tools you re-add, such as analyseData. The site selection agent example builds its agent this way.

Putting it all together

A real deployment usually combines all four patterns:

const agent = createMapAgent(map, {
    model: openai('gpt-4o'),

    // Narrow the data surface — drops every byod-related default tool.
    dataEntries: {
        byod: { enabled: false },
        routes: { entryMode: 'single' },
    },

    // Per-key overrides on top of what dataEntries left.
    tools: {
        // Remove what doesn't fit the deployment
        setLanguage: false,
        executeMaplibreCode: false,

        // Replace with a backend-backed version
        getCurrentLocation: fleetCurrentLocationTool,

        // Add new domain tools
        getFleetVehicle,
        getDispatchJobs,
    },
});

Verifying your registry

Use onClassify to confirm the right tools survive resolution and the classifier picks up your custom tools:

const agent = createMapAgent(map, {
    model: openai('gpt-4o'),
    tools: { getFleetVehicle },
    onClassify: (result) => {
        console.log('Tools selected for this turn:', result?.activeToolNames);
    },
});

If a custom tool doesn't activate when expected, rework its classificationPrompt. A prompt that's too vague fires every turn, and one that's too narrow never does. alwaysActive: true offers the tool on every step regardless.

Related