tool_schema_generator 1.1.0
tool_schema_generator: ^1.1.0 copied to clipboard
Generate provider-compatible LLM tool schemas and dispatchers from annotated Dart functions.
tool_schema_generator #
A Dart code generator that automatically produces provider-compatible tool schemas for Large Language Models from annotated Dart functions — with a unified runtime registry for dispatch, validation, and multi-provider encoding.
Why #
When building LLM agents, every provider (OpenAI, Anthropic, Gemini) expects a different JSON envelope to describe callable tools. Writing and maintaining these maps by hand — across multiple providers — is tedious and error-prone.
tool_schema_generator solves this with a simple model:
- Annotate your Dart functions with
@Tool(). - Generate —
build_runnerproduces atoolRegistrywith provider-shaped schemas and a validated dispatcher. - Use — pass
toolRegistry.encode()to your LLM and calltoolRegistry.call(name, args)when it responds.
No hand-written JSON. No duplicated schemas. One source of truth.
Features #
- Zero boilerplate — names, types, nullability, and doc comments are all inferred from Dart syntax.
- Multi-provider encoding — one annotation, three provider shapes: OpenAI, Anthropic, and Gemini.
- Unified encode API —
encode()always returnsList<JsonObject>, making spreads, LLM calls, and per-tool access consistent. - Provider-agnostic strict mode — opt into closed, fully-required schemas with
@Tool(strict: true). - Type-safe dispatch — the generated registry validates every argument and routes calls to the right Dart closure.
- Raw JSON as a first-class citizen — hand-written
Map<String, Object?>schemas can be passed directly toToolRegistry; no wrapper required. - Runtime composition — registries can be extended at runtime with
extend(), mixing generated and ad-hoc tools. - Runtime injection — hide session parameters (user IDs, locale, etc.) from the LLM schema with
@Inject(). - Full type support —
String,int,double,bool,List<T>,Map<String, Object?>,enums, and custom nested classes. source_gencompatible — plays nicely alongsidejson_serializableand other generators.
Installation #
dependencies:
tool_schema_generator: ^1.0.0
dev_dependencies:
build_runner: ^2.4.0
Migrating From 0.4.x #
See the full migration guide for detailed before/after examples.
For most projects, the upgrade is:
- Rename
SchemaFlavortoSchemaFormat. - Rename
flavors:toformats:in@Tool(...)andToolDefinition(...). - Replace
toolRegistry.encodeAll(flavor)withtoolRegistry.encode(format: format). - Replace
toolRegistry.encode(name, flavor)withtoolRegistry.encode(name: name, format: format). - Treat every
encode(...)result asList<JsonObject>; use.firstwhen you need one schema object. - Keep
@Tool()on top-level functions. Invalid placements are now analyzer and generator errors. - Regenerate generated files:
dart run build_runner build --delete-conflicting-outputs
Strict mode is opt-in with @Tool(strict: true). In strict schemas, nullable
Dart types render as JSON Schema unions such as
{"type": ["string", "null"]}; non-strict schemas keep "nullable": true.
Quick Start #
1. Annotate your functions #
// lib/tools.dart
import 'package:tool_schema_generator/tool_schema_generator.dart';
part 'tools.g.dart';
/// Sends an email to a specific recipient.
@Tool()
Future<void> sendEmail(
@Describe('Recipient email address') String to,
@Describe('Subject line') String subject, {
@Describe('Body content') required String body,
bool isHtml = false,
}) async {
// your implementation
}
2. Run the generator #
dart run build_runner build
3. Use the generated registry #
import 'tools.dart';
// ── Encode schemas for your LLM ─────────────────────────────────────────────
final response = await llm.generate(
prompt: 'Send an email to hello@example.com saying Hi!',
tools: toolRegistry.encode(), // OpenAI Schema (default)
/// or choose your specific tool to be included!
/// the package generate the <FUNCTION_NAME>ToolSchema
// tools: [sendEmailToolSchema]
// tools: toolRegistry.encode(format: SchemaFormat.anthropic) // Anthropic
);
// ── Dispatch the model's tool call ──────────────────────────────────────────
for (final call in response.toolCalls) {
final value = await toolRegistry.call(call.name, call.arguments);
print('Tool returned: $value');
}
Core API #
toolRegistry.encode({String? name, SchemaFormat format}) #
The unified encoding method. Always returns List<JsonObject>.
| Call | Returns |
|---|---|
encode() |
All tools, OpenAI format |
encode(format: SchemaFormat.gemini) |
All tools, Gemini format |
encode(format: SchemaFormat.anthropic) |
All tools, Anthropic format |
encode(name: 'search') |
1-element list, that tool, OpenAI format |
encode(name: 'search', format: SchemaFormat.anthropic) |
1-element list, that tool, Anthropic format |
Because the return type is always List<JsonObject>, you can spread with extra tools, pass directly to any LLM client, or concatenate registries:
// Spread a hand-crafted tool alongside generated ones
final tools = [
...toolRegistry.encode(format: SchemaFormat.openAi),
thinkTool, // a raw JsonObject
];
// Single tool by name
final justSearch = toolRegistry.encode(name: 'search').first;
// or again as mentioned by using the <FUNCTION_NAME>ToolSchema syntax
const justSearch = searchToolSchema;
toolRegistry.encoded #
A convenience getter equivalent to encode() — all tools, OpenAI format.
Named getters #
The generated subclass exposes a strongly-typed getter per tool, returning a ToolDefinition (which is itself a Map<String, Object?>):
// Equivalent to toolRegistry.encode(name: 'sendEmail').first
final schema = toolRegistry.sendEmail; // ToolDefinition (OpenAI shape)
toolRegistry.call(name, args) #
Dispatches a tool call. Validates all arguments, calls the Dart function, and returns the raw result.
final value = await toolRegistry.call(call.name, call.arguments);
Throws typed exceptions on failure (see Error Handling).
toolRegistry.callOrNull(name, args) #
Like call, but returns null instead of throwing when the name is not registered. Useful in multi-registry fan-out patterns.
toolRegistry.extend(tools) #
Returns a new ToolRegistry that merges the current tools with additionalTools. On name collision, the later entry wins.
// Mix generated tools with a raw JSON schema at runtime
final extendedRegistry = toolRegistry.extend([
{'type': 'function', 'function': {'name': 'thinkTool', 'parameters': {...}}},
]);
// Compose two generated registries
final combined = registryA.extend(registryB);
Annotations #
@Tool() #
Marks a top-level function as an LLM-callable tool. The annotation is targeted to top-level functions, so invalid placements are reported by the analyzer and by the generator.
@Tool(
name: 'custom_name', // optional — defaults to Dart function name
description: 'Override ...', // optional — defaults to doc comment
strict: true, // optional — defaults to false
formats: [ // optional — defaults to all three
SchemaFormat.openAi,
SchemaFormat.anthropic,
SchemaFormat.gemini,
],
)
strict: true is additive and opt-in. Existing @Tool() declarations keep
their current non-strict behavior.
@Describe('...') #
Adds a description to a parameter. Without it, the parameter still appears in the schema but has no "description" field.
@Tool()
void search(
@Describe('The search query string') String query,
@Describe('Max number of results, 1–50') int limit,
) {}
@Inject() #
Hides a named parameter from the schema sent to the LLM. The value is still available to the handler via the args map, so you can inject session context at call time.
@Tool()
Future<void> createTask(
@Describe('Task title') String title, {
@Inject() String? userId, // hidden from schema, merged before dispatch
@Inject() String locale = 'en',
}) async { ... }
// Dispatch — merge runtime context before calling
await toolRegistry.call(call.name, {
...call.arguments,
'userId': session.userId,
'locale': request.locale,
});
Rules for @Inject():
- Only on named parameters.
- Must be optional — nullable or have a Dart default value.
- Cannot be
required.
Strict Mode #
Strict mode is useful when you want the model to follow the tool schema as closely as the provider allows. Enable it per tool:
@Tool(strict: true)
Future<void> createTask({
required String title,
String? notes,
}) async {}
For strict tools, the generated parameter schema is transformed before it is emitted:
- Every visible parameter is listed in
required, including nullable and defaulted parameters. - Nullable schemas use JSON Schema union types such as
{"type": ["string", "null"]}instead of"nullable": true. - Every object schema, including nested custom classes, gets
"additionalProperties": false. - OpenAI and Anthropic envelopes include
"strict": true. - Gemini receives the same strict-shaped parameter schema, without an extra provider-specific strict flag.
Strict mode intentionally rejects Dart types that cannot be represented as a
closed JSON Schema during generation. This includes dynamic, void,
free-form Map<...> parameters, raw lists without item types, recursive object
graphs, and nested fields with those shapes. Use concrete typed classes when a
tool needs strict behavior.
Multi-Provider Encoding #
Provider schema shapes #
SchemaFormat controls which envelope is emitted:
SchemaFormat |
Envelope shape |
|---|---|
openAi (default) |
{"type": "function", "function": {"name": ..., "description": ..., "parameters": ...}} |
anthropic |
{"name": ..., "description": ..., "input_schema": ...} |
gemini |
{"name": ..., "description": ..., "parameters": ...} |
All three share the same provider-agnostic JSON Schema for the parameters — only the outer envelope changes.
When @Tool(strict: true) is used, OpenAI and Anthropic encodings also include
their strict flag. The canonical parameter schema remains provider-neutral.
Restricting a tool to specific providers #
// This tool only appears when encoding for Anthropic
@Tool(formats: [SchemaFormat.anthropic])
Future<String> claudeOnlySearch(String query) async => '...';
// When you call encode(format: SchemaFormat.openAi), this tool is excluded
toolRegistry.encode(format: SchemaFormat.openAi); // does not include claudeOnlySearch
toolRegistry.encode(format: SchemaFormat.anthropic); // includes it
Type Support #
The generator maps Dart types to JSON Schema types:
| Dart type | JSON Schema |
|---|---|
String |
{"type": "string"} |
int |
{"type": "integer"} |
double |
{"type": "number"} |
bool |
{"type": "boolean"} |
List<T> |
{"type": "array", "items": <schema for T>} |
Map<String, Object?> |
{"type": "object"} |
T? (nullable) |
non-strict schemas add "nullable": true; strict schemas use JSON Schema unions such as {"type": ["string", "null"]} |
enum Foo { a, b } |
{"type": "string", "enum": ["a", "b"]} |
Custom class Foo |
{"type": "object", "properties": {...}, "required": [...]} |
Enums #
enum Priority { low, normal, high }
@Tool()
void setTaskPriority(Priority priority) {}
// → {"type": "string", "enum": ["low", "normal", "high"]}
The generated dispatcher also emits a _parseEnum<T> helper that validates
the raw string and throws InvalidToolArgumentException on unknown values.
Nested objects #
The generator inspects the class's primary constructor to build the nested schema. Required constructor parameters become JSON Schema required entries.
class GeoLocation {
final double latitude;
final double longitude;
GeoLocation({required this.latitude, required this.longitude});
}
@Tool()
void findNearbyPlaces(
@Describe('The center point') GeoLocation location,
double radiusKm,
) {}
// → {"type": "object", "properties": {"latitude": ..., "longitude": ...}, "required": ["latitude", "longitude"]}
Error Handling #
All dispatch errors are subtypes of ToolCallException:
| Exception | When |
|---|---|
ToolNotFoundException |
No tool registered under that name |
MissingToolArgumentException |
A required argument was absent from the args map |
InvalidToolArgumentException |
Wrong type, wrong enum value, or list item type mismatch |
ToolExecutionException |
The tool function itself threw an unhandled exception |
try {
final value = await toolRegistry.call(call.name, call.arguments);
sendToModel(value.toString());
} on ToolNotFoundException catch (e) {
print('Unknown tool: ${e.name}. Available: ${e.available}');
} on MissingToolArgumentException catch (e) {
print('Missing field: ${e.field}');
} on InvalidToolArgumentException catch (e) {
print('Bad value for ${e.field}: expected ${e.expected}, got ${e.actual}');
} on ToolExecutionException catch (e) {
print('Tool crashed: ${e.error}');
}
Raw JSON as First-Class Input #
ToolRegistry accepts raw Map<String, Object?> schemas directly — no wrapper needed. This is the primary path for integrating with external tool-calling standards (MCP, custom adapters, etc.).
// Both ToolDefinitions and raw maps are accepted
final registry = ToolRegistry([
...toolRegistry, // spread from generated registry
{'type': 'function', 'function': {'name': 'thinkTool', 'parameters': {...}}},
]);
// extend() also accepts raw maps
final extended = toolRegistry.extend([rawToolSchema]);
ToolDefinition.raw() normalises both envelope shapes:
{"type": "function", "function": {...}}(OpenAI-style) — used as-is.{"name": ..., "parameters": ...}(flat, Gemini/Anthropic-style) — wrapped automatically.
Architecture & Internals #
The generator uses a small compiler-style pipeline:
Dart elements
-> ToolParser
-> ToolSpec / ParameterSpec / SchemaSpec
-> optional strict schema transform
-> ToolSchemaGenerator / ToolDispatchEmitter
This keeps Dart analysis, schema transformation, and source rendering separate.
The public runtime API still centers on ToolRegistry, ToolDefinition, and
the generated toolRegistry.
SchemaSpec intermediate representation #
TypeMapper maps Dart types into an internal SchemaSpec tree instead of
building schema source strings directly. ToolParser then wraps those schema
nodes into ToolSpec and ParameterSpec models. The emitters are the only
layers that turn those specs into generated Dart source.
That split matters because schema changes can now be tested and transformed as
data. Strict mode, for example, recursively walks the SchemaSpec tree to close
object schemas and expand required lists before code is emitted.
ToolDefinition extends MapView #
A ToolDefinition is an OpenAI-compatible Map<String, Object?>. It stores one canonical representation internally (the OpenAI {"type": "function", ...} envelope) and derives the other provider shapes on demand in encode(SchemaFormat):
ToolDefinition
├── name, description, parametersSchema ← single source of truth
├── formats ← which providers support this tool
├── handler ← the Dart closure
└── encode(SchemaFormat) →
openAi → Map.unmodifiable(this) // re-uses the stored map
anthropic → {"name", "description", "input_schema": parametersSchema}
gemini → {"name", "description", "parameters": parametersSchema}
No pre-baked map per format. Provider shapes are derived lazily at encode time.
ToolRegistry extends IterableBase<JsonObject> #
The registry's backing store is a single Map<String, ToolDefinition>. Because ToolRegistry extends IterableBase, its iterator yields ToolDefinition values directly — meaning for (final tool in toolRegistry) and spreading [...toolRegistry] both iterate the OpenAI-shaped maps (since ToolDefinition extends MapView).
ToolRegistry
├── _tools: Map<String, ToolDefinition>
├── iterator → _tools.values.iterator (each value IS a JsonObject)
├── encode({name?, format}) → List<JsonObject> ← unified API
├── call(name, args) → Future<Object?>
├── extend(tools) → new ToolRegistry
└── static getRequiredArg / getOptionalArg / ... ← shared argument helpers
What the generator emits #
For each @Tool-annotated function, build_runner produces three things:
const <functionName>ParametersSchema— aMap<String, Object?>of the JSON Schema.const <functionName>ToolSchema— the full OpenAI envelope wrapping the parameters schema.- A
ToolDefinition(...)entry inside thetoolRegistryinitialiser, including theformatslist and a typedhandlerclosure.
The handler closure uses the static helpers on ToolRegistry for validation:
handler: (JsonObject args) async {
return getWeather(
ToolRegistry.getRequiredArg<String>(args, 'city'),
unit: _parseEnum(
TemperatureUnit.values,
ToolRegistry.getOptionalArg<String>(args, 'unit'),
'unit',
) ?? TemperatureUnit.celsius,
);
},
The generated subclass of ToolRegistry adds one named getter per tool:
final class _ToolRegistry extends ToolRegistry {
_ToolRegistry(super.tools);
ToolDefinition get getWeather => this['getWeather']!;
ToolDefinition get sendEmail => this['sendEmail']!;
}
What this architecture unlocks #
The new parser/spec/emitter split is mainly an internal refactor, but it makes future schema work much safer:
- More focused unit tests against
ToolSpecandSchemaSpec, without fragile generated-string assertions. - Provider-specific schema adapters, if future providers need them, without duplicating Dart analysis logic.
- Additional JSON Schema constraints such as string formats, numeric ranges, list length limits, and richer object validation.
- Clearer build-time diagnostics for unsupported strict-mode shapes.
- Easier evolution of provider envelopes while keeping one canonical parameter schema.
Contributing #
Contributions are welcome — please open a PR with a clear description of the problem and the change.
License #
MIT