How the Agent Toolkit works

The Agent Toolkit sits between your application (chat UI and model provider) and the TomTom Maps SDK. Each user message goes through two phases, intent classification and then a tool loop. Shared state carries context across turns.

Your ApplicationAgent Toolkit PluginMaps SDK for JavaScriptChat UI (BYO)LLM Provider (BYO)Intent ClassifierToolsState

1. Intent classification

Before the model sees any tool, one lightweight LLM call (two if the first answer fails validation) picks the tools this turn needs. Fewer tools in the request means fewer wrong choices and fewer tokens.

  • It reads each tool’s classificationPrompt, a one-line “when to use me”. A custom tool that has one takes part automatically.
  • It sees the last user message plus up to eight earlier messages, each cut to 300 characters. It never sees the system prompt, tool results or state.
  • Tools with alwaysActive: true (clarifyIntent by default) are added every step, whatever the classifier picked.
  • For scopable tools (analyseData, processData, clusterIncidents, and custom tools with a scopeSchema), it also names the data kinds the turn touches, and the tool’s schema shrinks to those kinds. See Scope-aware data tools.
  • If classification fails, the toolkit offers every tool (fail-open).

The default classifier reuses your main model. Give it a smaller one, or change how much history it sees:

import { createMapAgent, createDefaultClassifier } from '@tomtom-org/maps-sdk-plugin-agent-toolkit';
import { openai } from '@ai-sdk/openai';
const agent = createMapAgent(map, {
model: openai('gpt-4o'),
classifier: createDefaultClassifier({
model: openai('gpt-4o-mini'),
maxHistoryMessages: 8, // earlier messages sent (default 8)
maxHistoryMessageLength: 300, // characters kept per earlier message (default 300)
}),
});

Or turn it off. Every tool is then offered every turn, and scopable tools keep their full, unscoped schema:

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

classifier also accepts your own function, (context) => Promise<ClassificationResult | null>. It receives the messages and every tool’s metadata. Returning null offers every tool.

2. Tool loop

The model calls tools, such as geocode, search, route and show on map, in sequence or in parallel until it can answer.

  • Tools return compact summaries (ids, counts, names, status). The geodata stays in state.
  • The toolkit stores results as entries with stable ids (places-3, routes-1), and the model reads them back with recallState. That is why “add a stop after the first one” or “what were those restaurants again?” work turns later.
  • The loop stops after maxSteps steps (default 10), or as soon as the model calls a tool with endsTurnOnCall.

The tool registry lists every tool.

Per-turn sequence

User messageIntent classifierprepareStep rebuilds narrowed inputSchema for scopable toolsTool loopFinal assistant replyPicks active toolsEmits per-tool scopes { analyseData: { kinds: [...] } }Model picks a tool, calls itTool execute returns summaryHeavy data lands in state 1. classify2. activeToolNames + toolScopes3. rebuilt schemas4. call5. persist 6. next step reads state7. done — compose answer

Hooking into the loop

OptionUse it to
prepareStepRun your own per-step logic. Any activeTools it returns are intersected with the classifier’s pick
maxStepsCap the steps per turn (default 10)
providerOptionsPass provider settings such as reasoning effort to every step
stepProviderOptionsReturn per-step provider settings from { activeTools, stepNumber }, merged over providerOptions
outputSchemasSet false for providers that reject structured tool outputs
onToolExecuteObserve each call’s toolName, durationMs, isError and errorMessage
const agent = createMapAgent(map, {
model: openai('gpt-5'),
providerOptions: { openai: { reasoningEffort: 'low' } },
// Think harder only on turns that write code.
stepProviderOptions: ({ activeTools }) =>
activeTools?.some((name) => name === 'analyseData' || name === 'processData')
? { openai: { reasoningEffort: 'medium' } }
: undefined,
onToolExecute: ({ toolName, durationMs, isError }) => console.log(toolName, durationMs, isError),
});

Run one turn at a time per agent, because scoping rewrites the agent’s shared tool objects each turn, and state belongs to the agent too. Create one agent per concurrent conversation.

Per-tool classification prompts

If a custom tool is offered too often, or not when it should be, tune its classificationPrompt:

// Too broad — activates for any location question
classificationPrompt: 'Get fleet vehicle data.'
// Precise — activates only for explicit vehicle ID references
classificationPrompt: 'Locate a fleet vehicle by its ID (e.g. "TT-001"); not for general location queries.'

The system prompt never reaches the classifier, so an instruction there cannot make a tool available. The system prompt guide explains what to do instead.

Observing classification

onClassify receives each turn’s pick and per-tool scopes, or null if the classifier failed open:

const agent = createMapAgent(map, {
model: openai('gpt-4o'),
onClassify: (result) => {
if (result) {
console.log('Selected tools:', result.activeToolNames);
console.log('Per-tool scopes:', result.toolScopes); // e.g. { analyseData: { kinds: ['places', 'routes'] } }
console.log('Classification took:', result.timeMs, 'ms', result.usage.totalTokens, 'tokens');
}
},
});