tool_schema_generator 1.0.0-dev0
tool_schema_generator: ^1.0.0-dev0 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. - 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-dev0
dev_dependencies:
build_runner: ^2.4.0
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 (default)
// tools: toolRegistry.encode(format: SchemaFormat.gemini) // Gemini
// 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;
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.
@Tool(
name: 'custom_name', // optional — defaults to Dart function name
description: 'Override ...', // optional — defaults to doc comment
formats: [ // optional — defaults to all three
SchemaFormat.openAi,
SchemaFormat.anthropic,
SchemaFormat.gemini,
],
)
@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.
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.
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) |
adds "nullable": true to the schema |
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 #
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']!;
}
Contributing #
Contributions are welcome — please open a PR with a clear description of the problem and the change.
License #
MIT