llm_tool_calling
Turn any Dart function into an LLM tool with one annotation. Works with OpenAI, Claude, Gemini and MCP. No hand-written JSON schemas.
/// Gets the current weather for a city.
@Tool()
String getWeather(@Param('City name, e.g. Kanpur') String city) => '...';
Run build_runner, and the model can call it: the JSON schema, argument
validation and the call into your function are generated for you.
Why llm_tool_calling?
- No hand-written JSON schemas. Your Dart function is the single source of truth. Rename a parameter or add one, and the schema follows on the next build, instead of silently drifting out of sync.
- One package for every provider. OpenAI, Claude, Gemini, MCP and OpenAI-compatible APIs (Mistral, Groq, DeepSeek, Ollama...) are built in. No extra package per SDK, and no runtime dependencies at all: it runs on every platform, including web.
- Validation that helps the model. Wrong arguments never reach your
code. Every problem is reported in one message written for the model,
e.g.
passengers[0].age must be an integer, got string, so it fixes the call itself on the next turn. - Safe by default for risky actions. Mark a tool
@Tool(requiresConfirmation: true)and it only runs after the user says yes. Without a confirmation step, it never runs. - Real Dart types. Enums, lists, nested classes (freezed too), nullable
parameters and defaults. JSON quirks are handled for you:
5.0becomes anint,"economy"becomesCabin.economy. - Provider rules handled. Tool and parameter names follow the strictest provider's rules, so a tool that works with one model works with all of them.
- Tested against the real thing. Each provider format is checked against that provider's official Dart SDK, and the Firebase adapter was tested live against Gemini.
How it works
your @Tool() function
│ dart run build_runner build
▼
getWeatherTool (+ allTools: every tool in the file)
│ allTools.toOpenAiJson() / toAnthropicJson() / toGeminiJson() / toMcpJson()
▼
the model ── calls a tool ──► allTools.invoke(name, arguments)
validate → confirm (if needed) → run your function
◄── result.toText() ─────────────┘
Quick start (60 seconds)
1. Install
dart pub add llm_tool_calling dev:llm_tool_calling_generator dev:build_runner
(In a Flutter app, use flutter pub add with the same arguments.)
2. Write a tool in, for example, lib/tools.dart:
import 'package:llm_tool_calling/llm_tool_calling.dart';
part 'tools.g.dart';
/// Gets the current weather for a city.
@Tool()
String getWeather(
@Param('City name, e.g. Kanpur') String city, {
@Param('Use Celsius instead of Fahrenheit') bool celsius = true,
}) => 'Sunny, ${celsius ? '31°C' : '88°F'} in $city';
3. Generate
dart run build_runner build
This creates lib/tools.g.dart with getWeatherTool, and allTools, a
list of every tool in the file.
4. Use it with your SDK (see Use with your SDK):
final tools = allTools.toOpenAiJson(); // or toAnthropicJson(), toGeminiJson()...
// When the model calls a tool:
final result = await allTools.invoke('getWeather', {'city': 'Kanpur'});
print(result.toText()); // Sunny, 31°C in Kanpur
Before and after
Before: you write and maintain the schema and the dispatch code by hand, and keep them in sync with the function forever.
String getWeather(String city, {bool celsius = true}) => '...';
final getWeatherTool = ToolDefinition(
name: 'getWeather',
description: 'Gets the current weather for a city.',
parametersSchema: {
'type': 'object',
'properties': {
'city': {'type': 'string', 'description': 'City name, e.g. Kanpur'},
'celsius': {
'type': 'boolean',
'description': 'Use Celsius instead of Fahrenheit',
},
},
'required': ['city'],
'additionalProperties': false,
},
execute: (args) => getWeather(
args['city'] as String,
celsius: args['celsius'] as bool? ?? true,
),
);
After: the function is the single source of truth.
/// Gets the current weather for a city.
@Tool()
String getWeather(
@Param('City name, e.g. Kanpur') String city, {
@Param('Use Celsius instead of Fahrenheit') bool celsius = true,
}) => '...';
Rename a parameter, add one, or change a default, and the schema and dispatch code follow on the next build.
Use with your SDK
Everything is built into this package; you don't add anything per SDK.
| Method | For |
|---|---|
allTools.toOpenAiJson() |
OpenAI Chat Completions, and compatible APIs (Mistral, Groq, DeepSeek, Ollama...) |
allTools.toOpenAiResponsesJson() |
OpenAI Responses API |
allTools.toAnthropicJson() |
Anthropic (Claude) |
allTools.toGeminiJson() |
Gemini (as parametersJsonSchema) |
allTools.toMcpJson() |
Model Context Protocol servers |
allTools.invoke(name, arguments) |
Running the model's call: validates, asks for confirmation, runs, and never throws |
invoke takes the arguments as a map, or as a JSON string (how OpenAI sends
them), and returns a ToolResult. Send result.toText() back to the model
(or result.toJson() where the API wants an object), and use
result.isError where the API has an error flag. Invalid arguments, unknown
tools and exceptions from your function all become an error the model can
read and fix.
Import SDKs with a prefix (
as openai,as anthropic,as mcp): most AI SDKs also have classes calledTool, and Anthropic's has aToolDefinition, just like this package.
OpenAI
With openai_dart. A complete loop:
ask a question, run every tool the model calls, return its answer.
import 'package:openai_dart/openai_dart.dart' as openai;
Future<String?> askOpenAi(String question) async {
final client = openai.OpenAIClient.fromEnvironment(); // OPENAI_API_KEY
final tools = allTools.toOpenAiJson().map(openai.Tool.fromJson).toList();
final messages = <openai.ChatMessage>[openai.ChatMessage.user(question)];
try {
while (true) {
final response = await client.chat.completions.create(
openai.ChatCompletionCreateRequest(
model: 'gpt-5.5',
messages: messages,
tools: tools,
),
);
if (!response.hasToolCalls) return response.text;
messages.add(
openai.ChatMessage.assistant(toolCalls: response.allToolCalls),
);
for (final call in response.allToolCalls) {
final result = await allTools.invoke(
call.function.name,
call.function.arguments, // a JSON string; invoke decodes it
);
messages.add(
openai.ChatMessage.tool(toolCallId: call.id, content: result.toText()),
);
}
}
} finally {
client.close();
}
}
Using the Responses API? Use toOpenAiResponsesJson() instead.
Claude
With anthropic_sdk_dart:
import 'package:anthropic_sdk_dart/anthropic_sdk_dart.dart' as anthropic;
Future<String> askClaude(String question) async {
final client = anthropic.AnthropicClient.fromEnvironment(); // ANTHROPIC_API_KEY
final tools = [
for (final json in allTools.toAnthropicJson())
anthropic.ToolDefinition.custom(anthropic.Tool.fromJson(json)),
];
final messages = <anthropic.InputMessage>[
anthropic.InputMessage.user(question),
];
try {
while (true) {
final response = await client.messages.create(
anthropic.MessageCreateRequest(
model: 'claude-opus-5-5',
maxTokens: 16000,
tools: tools,
messages: messages,
),
);
if (!response.hasToolUse) return response.text;
messages.add(response.toInputMessage());
final results = <anthropic.InputContentBlock>[];
for (final toolUse in response.toolUseBlocks) {
final result = await allTools.invoke(toolUse.name, toolUse.input);
results.add(
anthropic.InputContentBlock.toolResultText(
toolUseId: toolUse.id,
text: result.toText(),
isError: result.isError, // lets Claude see the call failed
),
);
}
messages.add(anthropic.InputMessage.userBlocks(results));
}
} finally {
client.close();
}
}
Gemini
- In a Flutter app with Firebase: use the
llm_tool_calling_firebase_aiadapter. One call runs the whole loop:chat.sendMessageWithTools(Content.text(question), allTools). (firebase_ai needs typed schema objects rather than JSON, which is why this one part is a separate package.) - Gemini REST API, or another Gemini SDK: send
allTools.toGeminiJson()as thefunctionDeclarations, and run eachfunctionCallwithallTools.invoke(call['name'], call['args']), sending backresult.toJson()as itsfunctionResponse.
MCP server
With mcp_dart: serve your tools to
Claude Desktop, Cursor and other MCP clients.
import 'package:mcp_dart/mcp_dart.dart' as mcp;
Future<void> main() async {
final server = mcp.McpServer(
const mcp.Implementation(name: 'weather', version: '1.0.0'),
);
for (final tool in allTools.toMcpJson().map(mcp.Tool.fromJson)) {
server.registerTool(
tool.name,
description: tool.description,
inputSchema: tool.inputSchema as mcp.JsonObject,
annotations: tool.annotations, // destructiveHint from requiresConfirmation
callback: (args, extra) async {
final result = await allTools.invoke(tool.name, args);
return mcp.CallToolResult(
content: [mcp.TextContent(text: result.toText())],
isError: result.isError,
);
},
);
}
await server.connect(mcp.StdioServerTransport());
}
Any other SDK, or plain HTTP
Send the JSON from the table above in your request (most SDKs can build
their tool objects from it with fromJson), and pass each tool call the
model makes to allTools.invoke(name, arguments). That's all the
integration there is.
Tools that need confirmation
Pass confirm to invoke to ask the user before running a tool marked
@Tool(requiresConfirmation: true). It's called after validation, so users
are never asked about a call that would fail:
final result = await allTools.invoke(
name,
arguments,
confirm: (tool, args) => showConfirmDialog(tool.name, args),
);
Without confirm, these tools never run, and the model is told they need
the user's confirmation. If the user declines, the model is told that too.
Compatibility
Generated schemas use plain JSON Schema (type, properties, required,
enum, items, nested objects, description, additionalProperties), and
generated tool and parameter names follow the strictest provider rules. They
work with:
| Provider | Use | Arguments arrive as | Notes |
|---|---|---|---|
| OpenAI (Chat Completions, Responses) | toOpenAiJson() / toOpenAiResponsesJson() |
JSON string (invoke accepts it as is) |
Works as-is. Strict mode (strict: true) also needs every field in required, so it only fits tools without optional parameters. |
| Anthropic Claude | toAnthropicJson() |
Object (input) |
Current models don't allow forced tool_choice; use auto and name the tool in your prompt. |
| Google Gemini 2.5+ | toGeminiJson() (parametersJsonSchema) |
Object (args) |
Use parametersJsonSchema, not parameters: the older field rejects additionalProperties. Works with firebase_ai via the same field. |
| Mistral | toOpenAiJson() |
JSON string | Same shape as OpenAI. |
| Ollama (local models) | toOpenAiJson() |
Object | How well the model fills nested objects depends on the model. |
| Other OpenAI-compatible APIs (DeepSeek, Groq, xAI…) | toOpenAiJson() |
Usually a JSON string | Same request shape as OpenAI. |
Some SDKs still send schemas to Gemini in the older parameters field. If
yours does, pass withoutAdditionalProperties(tool.parametersSchema)
instead; unknown arguments are still rejected by tool(args).
Checked against each provider's official documentation in October 2026.
To test it live with your own API keys, run
provider_check.dart:
it sends a tool with nested objects, lists and enums to every provider you
have a key for, and checks that the model's arguments pass validation.
Annotations
@Tool() |
Marks a top-level function as a tool. |
@Tool(name: 'get_weather') |
Name sent to the LLM. Defaults to the function name. Must start with a letter or _, then letters, digits, _ or -, up to 63 characters. |
@Tool(description: '...') |
Description sent to the LLM. Defaults to the function's doc comment (/// or /** */). One of the two is required. |
@Tool(requiresConfirmation: true) |
Sets ToolDefinition.requiresConfirmation, so your app can ask the user before running it (e.g. for deleting or paying). |
@Param('...') |
Description of one parameter. Optional, but it helps the LLM a lot. |
@Param('...', name: 'game_id') |
The name the LLM sees and sends, e.g. snake case, while your Dart parameter stays gameId. Works on class fields too. |
The generated variable is always <functionName>Tool, e.g. getWeatherTool,
even when you set a custom name.
All tools in a file
Each file with tools also gets a list of all of them, in source order, named after the file so that several tool files never clash:
| File | Generated list |
|---|---|
tools.dart |
allTools |
weather.dart or weather_tools.dart |
weatherTools |
flight_booking.dart |
flightBookingTools |
// Send every tool to the LLM, from one file or several:
final tools = [...weatherTools, ...flightBookingTools];
Supported types
| Dart type | JSON Schema | Notes |
|---|---|---|
String |
"string" |
|
int |
"integer" |
5.0 is accepted and converted to 5. |
double |
"number" |
5 is accepted and converted to 5.0. |
num |
"number" |
|
bool |
"boolean" |
|
any enum |
"string" with "enum": [...] |
Sent as value names, e.g. "celsius". |
| your own classes | "object" with "properties": {...} |
See Class parameters. |
List<T> of any type above |
"array" with "items": {...} |
Includes lists of enums, classes and lists. Items can't be nullable. |
Parameters can be positional or named, and any of them can be nullable or have a default value:
| Parameter | Required in schema? |
|---|---|
String city / {required String city} |
Yes |
String? city / {String? city} / {required String? city} |
No (missing means null) |
{String unit = 'metric'} / [int count = 3] |
No (missing means the default) |
Functions can return anything, including Future<T> and void (void
tools return null).
Map, Set, DateTime and generic classes are not supported yet. Using
them is a build-time error, not a silent bug.
Class parameters
A class parameter becomes a nested object schema, built from the class's unnamed constructor: each constructor parameter is a field, with the same rules as tool parameters (types, required, nullable, defaults).
/// A person on the flight.
class Passenger {
Passenger({required this.name, required this.age, this.bags = 1});
/// Full name as on the passport.
final String name;
final int age;
final int bags;
}
/// Books a flight.
@Tool(requiresConfirmation: true)
String bookFlight(List<Passenger> passengers, String from, String to) => '...';
- Descriptions come from
@Param('...')on the constructor parameter, or the field's///doc comment forthis.nameparameters. The class's doc comment describes the object, unless the tool parameter has@Param. - Defaults like
this.bags = 1work. For classes in another file, the default must be a literal, an enum value or a const list of those; otherwise you get a build error explaining how to fix it. - freezed classes work: their unnamed
factoryconstructor is used. - Classes can contain other classes, enums and lists. A class can't contain
itself (e.g. a linked-list
Node); that's a build-time error. - Classes from a prefixed import (
import 'models.dart' as models;) work.
Validation and errors
invoke (and tool(args), that is ToolDefinition.call) validates the
arguments before running your function. It reports every problem at
once:
| Problem | Message |
|---|---|
Required argument missing or null |
city is required |
| Argument not in the schema | colour is not a known argument |
| Wrong JSON type | city must be a string, got integer |
| Value not in an enum | unit must be one of "celsius", "fahrenheit", got "kelvin" |
| Wrong list item (every one is checked) | tags[2] must be a string, got integer |
| Problem inside an object | booking.passengers[0].age is required, passenger.seat is not a known field |
If anything is wrong, your function does not run. invoke returns a
failed ToolResult, and tool(args) throws a ToolArgumentException.
Either way, the message is written for the LLM:
Invalid arguments for "getWeather": city is required; colour is not a known argument
Send it back as the tool result and most models will correct the call on
their next turn. You can also read e.toolName and e.errors directly.
Good to know:
invokenever throws: exceptions from your function become a failedToolResulttoo.tool(args)lets them through, for when you want to handle them yourself.execute(args)runs the function without validation or confirmation. Preferinvoke.- Generated schemas include
"additionalProperties": false, matching the "unknown argument" check. invokeenforcesrequiresConfirmationthroughconfirm;tool(args)andexecutedon't, so checktool.requiresConfirmationyourself if you use them.validateArguments(schema, args)is public if you want to validate hand-written schemas yourself.
Troubleshooting
Undefined name 'getWeatherTool'
- Add
part 'your_file.g.dart';to the file with your tools. - Run
dart run build_runner build, ordart run build_runner watchto rebuild on every save. @Toolonly works on top-level functions. On a class method it is an error, but in a file that has no other top-level annotation it is skipped without a message. Move the method out of the class.
The name 'ToolDefinition' is defined in the libraries ...
- Another package you import also has a
ToolDefinition(for exampleflutter_ai_core). Import one of them with a prefix, e.g.import 'package:llm_tool_calling/llm_tool_calling.dart' as ltc;and annotate with@ltc.Tool(). The generated code follows your prefix.
Could not resolve annotation for ...
- The file uses
@Toolwithout importingpackage:llm_tool_calling/llm_tool_calling.dart.
Tool "x" needs a description
- Add a
///doc comment above the function, or use@Tool(description: '...'). The LLM relies on it to decide when to call your tool.
Tool name "..." is invalid
- To work with every provider, a name must start with a letter or
_and use only letters, digits,_and-, up to 63 characters. Use@Tool(name: 'valid_name'). This also applies to function names that contain$.
Parameter "..." has a name some LLM providers reject
- Gemini only accepts letters, digits and
_in parameter names, so rename parameters or fields that contain$.
Parameter "x" has type ..., which is not supported yet
- Use
String,int,double,num,bool, an enum, your own class, or aListof these; see Supported types. The same message starts withField "passenger.birthday"when it's a field of a class.
... but list items can't be nullable
- Use
List<String>instead ofList<String?>. The whole list can still be nullable:List<String>?.
... which has no unnamed constructor / ... which is abstract / ... which contains itself
- Class parameters are built through the class's unnamed constructor. See Class parameters.
Field "..." has a default value that can't be copied into the generated code
- The class is in another file and its default isn't a literal, an enum value or a const list of those. Make the field nullable or required, or use a simpler default.
Conflicting outputs were detected
- Run
dart run build_runner build --delete-conflicting-outputs.
Using json_serializable or freezed in the same file? That's fine.
All of them write into the same shared .g.dart part.
Roadmap
- An optional MCP package that registers
allToolson anMcpServerin one line (today it's the short loop shown above). llm_tool_calling_flutter: an approval widget forrequiresConfirmationtools.
Packages
| Package | Purpose | Add as |
|---|---|---|
llm_tool_calling |
Annotations, ToolDefinition, validation |
dependency |
llm_tool_calling_generator |
The build_runner code generator |
dev dependency |
llm_tool_calling_firebase_ai |
Adapter for Firebase AI Logic (firebase_ai) |
dependency |
Author
Built and maintained by Amit Gupta. Bug reports, ideas and pull requests are welcome on GitHub. If this package saves you time, a like on pub.dev or a star on GitHub helps others find it.
Libraries
- llm_tool_calling
- Annotate Dart functions with
@Tool()and get LLM tool definitions: JSON schema, argument validation and type-safe dispatch.