tool_schema_generator 1.1.0 copy "tool_schema_generator: ^1.1.0" to clipboard
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.

pub package coverage license: MIT


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:

  1. Annotate your Dart functions with @Tool().
  2. Generatebuild_runner produces a toolRegistry with provider-shaped schemas and a validated dispatcher.
  3. Use — pass toolRegistry.encode() to your LLM and call toolRegistry.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 APIencode() always returns List<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 to ToolRegistry; 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 supportString, int, double, bool, List<T>, Map<String, Object?>, enums, and custom nested classes.
  • source_gen compatible — plays nicely alongside json_serializable and 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 SchemaFlavor to SchemaFormat.
  • Rename flavors: to formats: in @Tool(...) and ToolDefinition(...).
  • Replace toolRegistry.encodeAll(flavor) with toolRegistry.encode(format: format).
  • Replace toolRegistry.encode(name, flavor) with toolRegistry.encode(name: name, format: format).
  • Treat every encode(...) result as List<JsonObject>; use .first when 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:

  1. const <functionName>ParametersSchema — a Map<String, Object?> of the JSON Schema.
  2. const <functionName>ToolSchema — the full OpenAI envelope wrapping the parameters schema.
  3. A ToolDefinition(...) entry inside the toolRegistry initialiser, including the formats list and a typed handler closure.

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 ToolSpec and SchemaSpec, 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

0
likes
160
points
45
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Generate provider-compatible LLM tool schemas and dispatchers from annotated Dart functions.

Repository (GitHub)
View/report issues

License

MIT (license)

Dependencies

analyzer, build, meta, source_gen

More

Packages that depend on tool_schema_generator