π«π· 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:
- YAML defaults injection: missing optional parameters are filled in with their
Defaultvalues - Deserialization:
Dictionary<string, object?>βTRequestviaComponentBase<TRequest, TResponse> - Validation: optional call to
ValidateTypedRequest(TRequest)β returnnullif valid, an error message otherwise - Execution: call to
ExecuteTypedAsync(TRequest, CancellationToken)β your business logic - Serialization:
TResponseβDictionary<string, object?> - 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"βtrueIntTolerantConverter:"42"β42LongTolerantConverter:"123456789"β123456789LDecimalInvariantConverter:"3.14"β3.14m(culture-invariant)DoubleInvariantConverter:"3.14"β3.14dRawObjectConverter: unwrapsJsonElementinto 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.