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

pub package


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.
  • 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-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:

  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']!;
}

Contributing #

Contributions are welcome — please open a PR with a clear description of the problem and the change.

License #

MIT

0
likes
0
points
148
downloads

Publisher

unverified uploader

Weekly Downloads

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

Repository (GitHub)
View/report issues

License

unknown (license)

Dependencies

analyzer, build, source_gen

More

Packages that depend on tool_schema_generator