Table of Contents

πŸ‡«πŸ‡· Version franΓ§aise

See also: Tool inventory Β· Back to index

Pattern β€” Implementing a new tool

This guide details the complete process of creating an Orkeon tool, based on the classes and interfaces existing in the source code.

Step 1 β€” Choose the base class

Orkeon provides three base classes in Orkeon.Tools.Abstractions.Base:

Base class Usage Built-in protection
ToolBase<TRequest, TResponse> Generic tool No specialization
FileToolBase<TRequest, TResponse> File operations IFileSystemService (the VFS β€” mandatory first ctor argument) + IPathValidator (path traversal protection)
HttpToolBase<TRequest, TResponse> HTTP/API operations IUrlValidator (SSRF protection), HttpHeaderSanitizer

The ToolBase<TRequest, TResponse> base class inherits from ToolBase (non-generic), which implements IBaseTool and ITool (Orkeon.Domain.Tools).

Step 2 β€” Define the Request and Response types

Each typed tool requires a TRequest record (input parameters) and a TResponse record (result). The [FieldSchema] and [ReturnSchema] attributes (Orkeon.Domain.Attributes) are used to automatically generate the JSON schema exposed to the LLM.

[FieldSchema] attribute (on TRequest)

Available properties:

[FieldSchema(
    Description = "...",       // Human-readable description for the LLM
    Type = "string",           // JSON Schema type (inferred when omitted)
    Format = "uri",            // OpenAPI format (inferred when omitted)
    IsRequired = true,         // Required (inferred from C# nullability when omitted)
    Default = "value",         // YAML default value
    Enum = new[] { "a", "b" }, // Allowed values
    Example = "example",       // Example for the documentation
    ItemsType = "string",      // Element type when array
    ItemsFormat = "...",       // Element format when array
    TypeDefinitionRef = "..."  // Reference to a defined type
)]

[ReturnSchema] attribute (on TResponse)

[ReturnSchema(
    Description = "...",       // Description of the returned field
    Type = "boolean",          // JSON Schema type (inferred when omitted)
    Format = "...",            // JSON Schema format hint
    ItemsType = "...",         // Element type when array
    ItemsFormat = "...",       // Element format when array
    TypeDefinitionRef = "...", // Reference to a defined type
    Example = true             // Example
)]

Concrete example β€” Request and Response

using Orkeon.Domain.Attributes;

public sealed record TranslateRequest
{
    [FieldSchema(Description = "Text to translate", IsRequired = true,
                 Example = "Hello, world!")]
    public string Text { get; init; } = "";

    [FieldSchema(Description = "Target language ISO code", IsRequired = true,
                 Example = "fr", Enum = new[] { "fr", "de", "es", "it", "pt", "ja", "zh" })]
    public string TargetLanguage { get; init; } = "";

    [FieldSchema(Description = "Source language (auto-detected if omitted)",
                 IsRequired = false, Default = "auto")]
    public string SourceLanguage { get; init; } = "auto";
}

public sealed record TranslateResponse
{
    [ReturnSchema(Description = "Whether the translation succeeded")]
    public bool Success { get; init; }

    [ReturnSchema(Description = "Translated text")]
    public string TranslatedText { get; init; } = "";

    [ReturnSchema(Description = "Detected source language")]
    public string DetectedLanguage { get; init; } = "";

    [ReturnSchema(Description = "Error message if translation failed")]
    public string? Error { get; init; }
}

Step 3 β€” Implement the tool class

Inherit from the chosen base class, put a [ToolContract] attribute on the class and implement ExecuteTypedAsync. The contract's first positional argument is the agent-visible name (UniqueName β€” the exact string YAML tools: lists use, and the one the doc-claims CI gate checks against docs/tools/inventory.md); Name, Description and Category ride on the same attribute (ToolBase reads them: Name => contract?.UniqueName ?? contract?.Name ?? GetType().Name). A tool without a contract falls back to a Name/Description property override β€” every shipped tool uses the attribute.

[ToolContract("weather_lookup",
    Description = "Current weather for a city via the provider API.")]
public class WeatherTool : HttpToolBase<WeatherRequest, WeatherResponse> { … }

Automatic pipeline

The ToolBase<TRequest, TResponse> pipeline is sealed (sealed override ExecuteCoreAsync) and automatically performs:

  1. YAML defaults injection: missing optional parameters are filled in with their Default values
  2. Deserialization: Dictionary<string, object?> β†’ TRequest via ComponentBase<TRequest, TResponse>
  3. Validation: optional call to ValidateTypedRequest(TRequest) β€” return null if valid, an error message otherwise
  4. Execution: call to ExecuteTypedAsync(TRequest, CancellationToken) β€” your business logic
  5. Serialization: TResponse β†’ Dictionary<string, object?>
  6. Filtering: only the fields declared in [ReturnSchema] are included in the response

Complete example β€” Simple tool (no external dependency)

using Microsoft.Extensions.Logging;
using Orkeon.Tools.Abstractions.Base;

namespace Orkeon.Tools.Data;

public sealed class TranslateTool : ToolBase<TranslateRequest, TranslateResponse>
{
    public TranslateTool(ILogger<TranslateTool>? logger = null) : base(logger) { }

    public override string Name => "translate";

    public override string Description =>
        "Translate text from one language to another. " +
        "Source language is auto-detected if not specified.";

    protected override string? ValidateTypedRequest(TranslateRequest request)
    {
        if (string.IsNullOrWhiteSpace(request.Text))
            return "Text to translate cannot be empty.";

        if (string.IsNullOrWhiteSpace(request.TargetLanguage))
            return "Target language must be specified.";

        return null; // Valide
    }

    protected override async Task<TranslateResponse> ExecuteTypedAsync(
        TranslateRequest request,
        CancellationToken cancellationToken)
    {
        try
        {
            // Business logic β€” a simplified translation here
            var translated = $"[{request.TargetLanguage}] {request.Text}";

            return new TranslateResponse
            {
                Success = true,
                TranslatedText = translated,
                DetectedLanguage = request.SourceLanguage == "auto" ? "en" : request.SourceLanguage
            };
        }
        catch (Exception ex)
        {
            return new TranslateResponse
            {
                Success = false,
                Error = ex.Message
            };
        }
    }
}

Step 4 β€” Complete example β€” Tool with an external dependency (HTTP)

For a tool calling an external API, use HttpToolBase<TRequest, TResponse>, which provides a shared HttpClient and URL validation.

using Microsoft.Extensions.Logging;
using Orkeon.Tools.Abstractions.Base;
using Orkeon.Domain.Attributes;
using System.Net.Http.Json;
using System.Text.Json;

namespace Orkeon.Tools.Web;

// --- Request ---
public sealed record WeatherRequest
{
    [FieldSchema(Description = "City name to get weather for",
                 IsRequired = true, Example = "Paris")]
    public string City { get; init; } = "";

    [FieldSchema(Description = "Temperature unit",
                 IsRequired = false, Default = "celsius",
                 Enum = new[] { "celsius", "fahrenheit" })]
    public string Unit { get; init; } = "celsius";
}

// --- Response ---
public sealed record WeatherResponse
{
    [ReturnSchema(Description = "Whether the request succeeded")]
    public bool Success { get; init; }

    [ReturnSchema(Description = "Current temperature")]
    public double Temperature { get; init; }

    [ReturnSchema(Description = "Weather description")]
    public string Description { get; init; } = "";

    [ReturnSchema(Description = "Error message if request failed")]
    public string? Error { get; init; }
}

// --- Tool ---
public sealed class WeatherTool : HttpToolBase<WeatherRequest, WeatherResponse>
{
    private const string BaseUrl = "https://api.weatherapi.com/v1";
    private readonly string _apiKey;

    // Simple ctor (shared static HttpClient). For SSRF protection, use the
    // base(IUrlValidator, HttpHeaderSanitizer, HttpClient?, ILogger?) ctor.
    public WeatherTool(
        string apiKey,
        HttpClient? httpClient = null,
        ILogger<WeatherTool>? logger = null)
        : base(httpClient, logger)
    {
        _apiKey = apiKey;
    }

    public override string Name => "weather";

    public override string Description =>
        "Get current weather for a city. Returns temperature and conditions.";

    protected override async Task<WeatherResponse> ExecuteTypedAsync(
        WeatherRequest request,
        CancellationToken cancellationToken)
    {
        try
        {
            var url = $"{BaseUrl}/current.json?key={_apiKey}&q={Uri.EscapeDataString(request.City)}";

            // HttpClient is available through the protected _httpClient field (inherited from HttpToolBase)
            var response = await _httpClient.GetAsync(url, cancellationToken)
                .ConfigureAwait(false);

            response.EnsureSuccessStatusCode();

            var json = await response.Content.ReadFromJsonAsync<JsonElement>(
                cancellationToken: cancellationToken).ConfigureAwait(false);

            var tempC = json.GetProperty("current").GetProperty("temp_c").GetDouble();
            var condition = json.GetProperty("current")
                              .GetProperty("condition")
                              .GetProperty("text")
                              .GetString() ?? "Unknown";

            var temp = request.Unit == "fahrenheit" ? tempC * 9.0 / 5.0 + 32 : tempC;

            return new WeatherResponse
            {
                Success = true,
                Temperature = Math.Round(temp, 1),
                Description = condition
            };
        }
        catch (HttpRequestException ex)
        {
            return new WeatherResponse
            {
                Success = false,
                Error = $"HTTP error: {ex.Message}"
            };
        }
    }
}

Step 5 β€” Registration and usage

Option A β€” Direct injection via the builder

The simplest approach: instantiate the tool and pass it to the agent builder.

var weatherTool = new WeatherTool(apiKey: "my-api-key");

var agent = new AgentBuilder()
    .Role("Weather Reporter")
    .Goal("Provide accurate weather forecasts")
    .WithTool(weatherTool)
    .Build();

Option B β€” DI registration via IServiceCollection

For full integration into the dependency injection pipeline:

// Dans une extension method ou Startup
services.AddSingleton<IBaseTool>(sp =>
{
    var config = sp.GetRequiredService<IConfiguration>();
    var logger = sp.GetService<ILogger<WeatherTool>>();
    return new WeatherTool(
        apiKey: config["Weather:ApiKey"]!,
        logger: logger);
});

The tool is then available via IEnumerable<IBaseTool>. Name resolution from YAML needs a DI-backed registry: the default AddOrkeonInfrastructure() registers the empty InMemoryToolRegistry stub, which never sees your IBaseTool registrations β€” the runner host swaps in ServiceProviderToolRegistry (Orkeon.Hosting), and an embedding host must do the same:

services.AddSingleton<IToolRegistry, ServiceProviderToolRegistry>();

Option C β€” Via IToolRegistry

IToolRegistry (Orkeon.Domain.Tools, implementation InMemoryToolRegistry) enables dynamic registration and resolution of tools by name:

var toolRegistry = serviceProvider.GetRequiredService<IToolRegistry>();
var tool = await toolRegistry.GetToolByNameAsync("weather");

Step 6 β€” Internal composition pattern (FileToolBase / HttpToolBase)

The FileToolBase<TRequest, TResponse> and HttpToolBase<TRequest, TResponse> classes use a composition pattern via a private inner class ComponentPipeline that inherits from ComponentBase<TRequest, TResponse> (Domain layer).

This pattern lets the tool class access the typed pipeline's serialization/deserialization methods without inheriting directly from ComponentBase (which lives in the Domain layer and knows nothing about tool concepts).

Pipeline architecture

ToolBase<TRequest, TResponse> (ou FileToolBase<> / HttpToolBase<>)
    β”‚
    β”œβ”€β”€ Contains: private ComponentPipeline _pipeline
    β”‚                  └── inherits ComponentBase<TRequest, TResponse>
    β”‚                       └── delegates to IComponentSerializer (static)
    β”‚                            └── JsonComponentSerializer (singleton)
    β”‚
    └── sealed override ExecuteCoreAsync()
         1. MergeWithYamlDefaults(parameters)     β†’ enriched Dict
         2. _pipeline.Deserialize(merged)          β†’ TRequest
         3. ValidateTypedRequest(typedRequest)     β†’ null | error
         4. ExecuteTypedAsync(request, ct)         β†’ TResponse  ← YOUR CODE
         5. _pipeline.Serialize(typedResponse)     β†’ Dict
         6. FilterOutput(resultDict)               β†’ filtered Dict
         7. return ToolCallResponse(success, result, error)

Source code excerpt (FileToolBaseGeneric.cs)

public abstract partial class FileToolBase<TRequest, TResponse> : FileToolBase
    where TRequest : class, new()
    where TResponse : class
{
    private readonly ComponentPipeline _pipeline = new();

    // The sealed pipeline prevents subclasses from bypassing
    // serialization or validation
    protected sealed override async Task<ProtocolToolCallResponse> ExecuteCoreAsync(
        ProtocolToolCallRequest request, CancellationToken cancellationToken)
    {
        var mergedParams = MergeWithYamlDefaults(request.Parameters);
        var typedRequest = _pipeline.Deserialize(mergedParams);

        var validationError = ValidateTypedRequest(typedRequest);
        if (validationError is not null)
            return new ProtocolToolCallResponse(Success: false, Result: null, Error: validationError);

        var typedResponse = await ExecuteTypedAsync(typedRequest, cancellationToken);
        var resultDict = _pipeline.Serialize(typedResponse);
        var filteredResult = FilterOutput(resultDict);
        // ... propagation success/error ...
        return new ProtocolToolCallResponse(Success: success, Result: filteredResult, Error: error);
    }

    // Your extension point β€” pure business logic
    protected abstract Task<TResponse> ExecuteTypedAsync(
        TRequest request, CancellationToken cancellationToken);

    // Classe interne de composition
    private sealed class ComponentPipeline : ComponentBase<TRequest, TResponse>
    {
        public TRequest Deserialize(Dictionary<string, object> parameters)
            => DeserializeRequest(parameters);
        public Dictionary<string, object> Serialize(TResponse response)
            => SerializeResponse(response);
        protected override Task<TResponse> ExecuteTypedAsync(
            TRequest request, CancellationToken ct)
            => throw new NotSupportedException("Pipeline helper does not execute.");
    }
}

Serialization: snake_case and type coercion

The JsonComponentSerializer (Orkeon.Infrastructure.Serialization) uses JsonNamingPolicy.SnakeCaseLower and includes 10 tolerant converters to handle YAML values arriving as strings β€” the six below plus TolerantEnumConverterFactory, UriTolerantConverter, ImmutableArrayEnumTolerantConverter<EdgeKind> and ImmutableArrayStringTolerantConverter:

  • BoolTolerantConverter: "true", "1", "yes" β†’ true
  • IntTolerantConverter: "42" β†’ 42
  • LongTolerantConverter: "123456789" β†’ 123456789L
  • DecimalInvariantConverter: "3.14" β†’ 3.14m (culture-invariant)
  • DoubleInvariantConverter: "3.14" β†’ 3.14d
  • RawObjectConverter: unwraps JsonElement into native .NET types

This guarantees that YAML parameters (which are all strings at deserialization time) are correctly converted to the C# types expected by TRequest.

Step 7 β€” Registering a tool in a suite (NuGet package)

If the tool is meant to be distributed in a package, create a DI extension in the same project:

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;

namespace Orkeon.Tools.MyPackage;

public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddOrkeonMyPackageTools(
        this IServiceCollection services)
    {
        // One IBaseTool registration per tool. NOT TryAddSingleton<IBaseTool, …>
        // twice: TryAdd keys on the SERVICE type, so the second call would be a
        // silent no-op. Tools with non-DI ctor args (WeatherTool's apiKey) need
        // the factory form.
        services.AddSingleton<IBaseTool>(sp => new WeatherTool(
            apiKey: sp.GetRequiredService<IConfiguration>()["Weather:ApiKey"]!,
            logger: sp.GetService<ILogger<WeatherTool>>()));
        services.TryAddEnumerable(ServiceDescriptor.Singleton<IBaseTool, TranslateTool>());

        // Usable by name in YAML once a DI-backed IToolRegistry is registered
        // (ServiceProviderToolRegistry β€” see Option A above).
        return services;
    }
}

Usage in Program.cs:

services.AddOrkeonApplication();
services.AddOrkeonInfrastructure();
services.AddOrkeonMyPackageTools();  // Vos outils custom

Pattern recap

1. Pick the base class       β†’  ToolBase<> / FileToolBase<> / HttpToolBase<>
2. Define TRequest           β†’  Record with [FieldSchema] on each property
3. Define TResponse          β†’  Record with [ReturnSchema] on each property
4. Implement the class       β†’  override Name, Description, ExecuteTypedAsync
5. (Optional) Validation     β†’  override ValidateTypedRequest
6. Enregistrer               β†’  Builder / DI / ToolFactory

The JSON schema is auto-generated by ToolSchemaGenerator from the attributes β€” no manual maintenance required.