Mastra rewrites tool object keys, and toolChoice does not follow
Mastra documents that the object key, not the tool id, becomes the name the model sees. It does not document that the key is rewritten first: invalid characters become underscores and the result is cut to 63. The tools array goes through that rewrite. toolChoice does not.
On this page
Quick answer (September 2026). In Mastra, the name a model sees for a tool comes from the object key you register it under, not from the id you passed to createTool(). Mastra documents that rule. What Mastra does not document is that the key is not used verbatim: formatTools() replaces every character outside a-zA-Z0-9_- with an underscore, prefixes an underscore if the first character is not a letter or underscore, and truncates the result to 63 characters. So tools: { "search.web": t } reaches the provider as search_web. The documented rule holds for keys that are already valid and quietly stops holding for keys that are not. And the rewrite is applied to the tools array only: toolChoice with a named tool is passed through untouched, so forcing a tool by the exact key you registered it under builds a request whose tool_choice names a function absent from its own tools list. Measured first-party on @mastra/core 1.67.0 and 1.0.0.
The rule Mastra does document
Mastra's tools documentation has a section headed Control toolName in stream responses, and it is clear:
Mastra docs, verbatim: "The toolName in stream responses is determined by the object key you use, not the id property of the tool, agent, or workflow."
It gives three worked examples: register under the variable name and you get weatherTool; register under [weatherTool.id] and you get weather-tool; register under "my-custom-name" and you get my-custom-name. It closes with "If you want the toolName to match the tool's id, use the tool's id as the object key."
That is accurate, and it is worth knowing before you read any further: this article is not claiming the key rule is a secret. It is documented. It has also been reported before, in mastra issue 4332 from May 2025, which asked for exactly this to be written down and was closed in November 2025 with the maintainer note "In v1, both object key and ids are valid querying mechanisms".
What follows is the part that is not written down: the key you supply and the name that leaves your process are not always the same string.
Measuring what actually goes on the wire
You do not need an API key to see this. Mastra accepts an OpenAI-compatible model config with a url field, so you can point it at a local server that records the request body and returns an error. Nothing leaves the machine.
// capture.cjs
const http = require('http');
const fs = require('fs');
http.createServer(function (req, res) {
let body = '';
req.on('data', function (chunk) { body += chunk; });
req.on('end', function () {
fs.writeFileSync('captured.json', body);
res.writeHead(400, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: { message: 'captured' } }));
});
}).listen(8731, '127.0.0.1');
Start that, then run one agent turn against it:
// probe.cjs
const { Agent } = require('@mastra/core/agent');
const { createTool } = require('@mastra/core/tools');
const { z } = require('zod');
const weatherTool = createTool({
id: 'get-weather',
description: 'Get the weather',
inputSchema: z.object({ city: z.string() }),
execute: async function () { return { tempC: 20 }; }
});
const agent = new Agent({
name: 'probe',
instructions: 'Always call the get-weather tool.',
model: { id: 'openai/gpt-4o-mini', url: 'http://127.0.0.1:8731/v1', apiKey: 'sk-fake' },
tools: { 'search.web': weatherTool }
});
agent.generate('hi').catch(function (e) { console.log('done:', e.message); });
The captured body:
{
"model": "gpt-4o-mini",
"messages": [
{ "role": "system", "content": "Always call the get-weather tool." },
{ "role": "user", "content": "hi" }
],
"tools": [
{ "type": "function", "function": { "name": "search_web", "description": "Get the weather" } }
],
"tool_choice": "auto"
}
Three names are now in play and none of them agree. The tool's id is get-weather. The key you registered it under is search.web. The model is offered search_web. The system prompt instructs the model to call a tool that does not exist in the request.
The rewrite rules, measured
The transformation lives in Agent.formatTools(). Registering a spread of awkward keys and reading them back off the wire gives the whole shape at once:
Scroll to see more
| Key you register | Name the model receives | Why |
|---|---|---|
weather-EU | weather-EU | already valid, untouched |
search.web | search_web | dot is outside the allowed set |
my tool | my_tool | space is outside the allowed set |
has/slash | has_slash | slash is outside the allowed set |
| 70 times the letter a | 63 times the letter a | truncated |
The allowed set is a-zA-Z0-9_-. Everything else becomes a single underscore, one per character. A key whose first character is not a letter or an underscore gets an underscore prefix, and the prefix is applied before the truncation, so a 63-character key beginning with a digit loses its last character to make room.
One detail worth noticing in that table: weather-EU was registered third and comes back first. Keys that need no rewriting stay where they are; rewritten keys are removed and re-added, so they move to the end of the tool list. If you care about the order tools are presented to the model, a single dot in one key reshuffles the array.
Two things that are not broken
It would be easy to read the above as "Mastra mangles your tools". It does not, and two measurements are worth having so you do not go looking for damage that is not there.
Execution still routes correctly. When the model calls the rewritten name, Mastra finds the tool and runs it. Returning a tool_calls response naming search_web from the capture server, against an agent that registered the tool under search.web, runs the execute function with the parsed arguments and sends the result back in the follow-up turn with the matching tool_call_id. The loop is intact. The rewrite is applied consistently on the way out and on the way back, which is why this is a naming problem rather than a tool that never fires.
Your tools object is not mutated. formatTools() reads like an in-place rewrite, deleting the old key and setting the new one. Measured, the object you pass to the Agent constructor is unchanged afterwards: an object registered with the single key search.web still has exactly that key once the request has been built, and the tool's id is still get-weather. If you share one tool registry across several agents, constructing one agent does not rename anything for the others. That was the first thing I expected to find and it is not there.
Both of those matter for how you weigh the rest. The rewrite is a presentation layer, applied to the request Mastra builds and reversed when the response comes back. The failure modes are the ones where some other string has to agree with it: a name in your prompt, a name in toolChoice, a name in a trace you are grepping, a name in an approval list.
The truncation is 63, and the providers allow 64
The ceiling is not arbitrary. Mastra issue 6302, opened in July 2025 and closed that August, reported MCP tool names blowing the provider limit and quoted the error verbatim:
Reported in issue 6302: "Invalid 'tools[77].function.name': string too long. Expected a string with maximum length 64, but got a string with length 67 instead."
That issue asked for exactly this: "A solution to automatically truncate or validate tool names would be helpful." Truncation now exists, which is a real improvement over a provider rejection. It lands one character short of the published ceiling, so a legal 64-character key is still cut to 63. Nothing breaks. It just means the last character of a name you chose deliberately can disappear with no warning, and two keys differing only in that last character stop being distinguishable.
Which brings us to the one place Mastra does tell you.
The collision error is the good news
Truncation and underscore substitution are both lossy, so two different keys can land on the same name. Mastra checks for this and refuses to run:
Two or more tools resolve to the same name "search_web".
Please rename one of the tools to avoid this collision.
That fires for search.web plus search web, and for any two keys longer than 63 characters that share their first 63. It is a MastraError with the id AGENT_TOOL_NAME_COLLISION, it is thrown rather than logged, and it is the single clearest signal in this whole area. If you have seen that string, you have seen the rewrite, even if you did not realise that was what you were looking at.
It is also worth stating what the collision check is not: it only fires when two keys rewrite onto each other. A single key that is quietly rewritten passes silently, which is the normal case.
The break: toolChoice does not follow
Everything above is a naming surprise. This part is a request your provider will reject.
Mastra's docs demonstrate toolChoice only in its 'required' form. The AI SDK types also accept a named tool, and that path is not normalised. Register a tool under a key that needs rewriting, then force it by that same key:
const agent = new Agent({
name: 'forced',
instructions: 'p',
model: { id: 'openai/gpt-4o-mini', url: 'http://127.0.0.1:8731/v1', apiKey: 'sk-fake' },
tools: { 'search.web': weatherTool }
});
agent.generate('hi', { toolChoice: { type: 'tool', toolName: 'search.web' } })
.catch(function (e) { console.log('done:', e.message); });
The captured body:
{
"tools": [
{ "type": "function", "function": { "name": "search_web" } }
],
"tool_choice": { "type": "function", "function": { "name": "search.web" } }
}
The tools array was rewritten. The tool_choice was not. The request now names a function that is absent from its own tool list, and it does so using the exact string you registered the tool under. Passing the tool's id instead, get-weather, produces the same mismatch for the same reason.
The asymmetry is the whole bug. One field goes through formatTools() and the other does not, so they agree for valid keys and disagree for everything else. If every key in your project is already a legal tool name, you will never see it. Add one dot, or import a tool set whose names came from somewhere else, and the forcing path stops working while the ordinary path keeps working.
Why the built-in tools table is worth checking
The same docs page carries a second statement about naming, in the Built-in tools section:
Mastra docs, verbatim: "Each tool has two names: the export you import, and the tool ID the model sees in tool calls, traces, and toolName fields."
It then tabulates them, mapping askUserTool to ask_user, submitPlanTool to submit_plan, taskWriteTool to task_write, webFetchTool to web_fetch. The id properties on those exports really are those strings, so the table is correct about the tools.
It is not correct about what the model sees. Registering them the obvious way and capturing the request gives:
{
"tools": [
{ "type": "function", "function": { "name": "askUserTool" } },
{ "type": "function", "function": { "name": "submitPlanTool" } },
{ "type": "function", "function": { "name": "taskWriteTool" } },
{ "type": "function", "function": { "name": "webFetchTool" } }
]
}
The key rule wins, as the other section of the same page says it should. Write tools: { ask_user: askUserTool } and the model does see ask_user. Write tools: { askUserTool } and it does not. Two sections of one page disagree, and the one that disagrees is the one with the tidy table you are most likely to copy a name out of when writing a prompt.
How old is this
Not new. @mastra/core 1.0.0 and 1.67.0 are sixty-seven minor versions apart and carry the identical constants in formatTools(): the same a-zA-Z0-9_- allowed set, the same key.length ceiling of 63, the same slice. Running the same probe against 1.0.0 produces the same search_web in tools and the same search.web in tool_choice.
So this is not a regression to bisect. It is a long-standing normalisation layer that the documentation describes one level up from where it actually operates.
What to do about it
Four things, in order of how much they buy you.
Make every tool key a legal tool name. Letters, digits, underscore and hyphen, first character a letter or underscore, 63 characters or fewer. Do that and the documented rule is exactly true, toolChoice works by name, and nothing below matters.
Register built-ins under their ids when you name them in prompts. tools: { ask_user: askUserTool } rather than tools: { askUserTool }. If your system prompt or your evaluation harness refers to ask_user, this is the version that makes that reference real.
Never name a tool in a prompt without checking what the model was offered. The capture server above is twelve lines and answers the question definitively in one turn. This is the same discipline as reading the request body when an SDK rewrites the model settings you sent: the config you wrote and the payload that left your process are two different objects, and only one of them reaches the model.
Treat imported tool sets as untrusted names. MCP servers, Composio, and any registry that generates keys from remote metadata can hand you dots, colons and long prefixes. Those are exactly the keys that get rewritten, and exactly the ones most likely to collide after truncation. Normalise them yourself at the boundary, where you can pick the replacement, rather than letting formatTools() pick it for you.
If you are coming to this from another SDK, the shape will be familiar: agent frameworks generally keep a private notion of a tool's identity alongside the one the provider sees, and the two drift. Anthropic's SDK draws the line in a different place, which is worth reading about in how tools and allowedTools differ at the subagent level, and the basic shape of a typed tool definition is covered in defining custom tools.
What was measured, and what was not
Everything above was produced on @mastra/core 1.67.0 with zod 3.25.76 on Node 24, against a local capture server, with one confirming run on 1.0.0. The quoted provider error comes from the reporter in issue 6302 rather than from a live provider call, so treat the 64-character ceiling as the figure that error reports.
One claim was checked and left open. The issue 4332 closing note says both object keys and ids are valid querying mechanisms in v1. That is about looking a tool up through Mastra's own surfaces, which is a different question from what name is serialised into a provider request, and it was not tested here. Both statements can be true at once: the lookup layer may well accept either string while the wire carries only the key.
Written by
Sofia NievesSofia works on agent evaluation and reliability. She writes about measuring LLM systems before and after they reach production.
Frequently asked questions
Does the id I pass to createTool do anything at all in Mastra?
It is the tool's own identity inside Mastra and is what you would register it under if you want the model to see it. It is not consulted when building the provider request. The name in the tools array, and the toolName reported back in the result, both come from the object key you registered the tool under. Measured on @mastra/core 1.67.0: a tool created with id get-weather and registered as tools: { weatherTool } reaches the provider as weatherTool and reports toolName weatherTool.
Why did my Mastra tool name lose its dots or spaces?
Agent.formatTools() replaces every character outside a-zA-Z0-9_- with an underscore before the tools array is built. A key of search.web becomes search_web and a key of my tool becomes my_tool. It also prefixes an underscore when the first character is not a letter or underscore, and truncates the result at 63 characters. The behaviour is not in the documentation as of September 2026.
What does Two or more tools resolve to the same name mean?
It is a MastraError with id AGENT_TOOL_NAME_COLLISION, thrown when two of your tool keys rewrite onto the same normalised name. The usual causes are two keys differing only in a character that becomes an underscore, such as search.web and search web, or two keys longer than 63 characters sharing their first 63. Rename one of them. The error is thrown rather than logged, so it will stop the run.
Why is Mastra truncating tool names at 63 characters when the limit is 64?
formatTools() enters its rewrite branch when key.length is greater than 63 and then slices to 63. The provider ceiling reported in mastra issue 6302 is 64, so a legal 64-character key is cut by one character. Nothing errors, but the final character of a deliberately chosen name disappears silently, and two keys differing only in that character will collide.
Why does toolChoice with a tool name fail in Mastra?
The tools array is normalised by formatTools() and the toolChoice value is not. If your tool key needed rewriting, the request declares the tool under its rewritten name while tool_choice names the original, so the forced tool is absent from the tools list the provider receives. Passing the tool's id instead has the same effect. The fix is to use keys that are already valid tool names, in which case both fields agree.
Do the built-in Mastra tools really appear to the model as ask_user and task_write?
Only if you register them under those keys. The documentation table maps askUserTool to ask_user, and that is the id on the export, but the name on the wire still comes from the object key. Registering tools: { askUserTool } sends askUserTool. Registering tools: { ask_user: askUserTool } sends ask_user. If your prompts refer to the documented ids, use the ids as the keys.
Related tutorials
Claude Agent SDK: tools vs allowedTools, and what a subagent definition silently accepts
allowedTools auto-approves, tools restricts. That half is documented. What is not: an AgentDefinition has no allowedTools field, and the SDK forwards one verbatim to the CLI anyway, unvalidated.
@openai/agents adds reasoning.effort and text.verbosity by matching your model name
The SDK chooses reasoning.effort and text.verbosity from how your model name is spelled, not from what the model supports. Measured on the wire on 0.18.0, including what it does to a custom gateway deployment name.
Claude Agent SDK Custom Tools: Give Your Agent Its Own Functions (2026)
A runnable 2026 guide to custom tools in the Claude Agent SDK: define functions with tool() / @tool, bundle them into an in-process MCP server, register with allowedTools, and gate risky calls per argument with canUseTool. TypeScript and Python.