Custom tools — untyped
ToolBase (the non-generic base) gives you the lowest-level control:
hand-author the JSON Schema, accept any JSON, parse it however you
like.
Use it when:
- The argument shape is genuinely dynamic — different fields depending on what the user asked for.
- You're wrapping a system that already has a published JSON Schema and you want to mirror it byte-for-byte.
For everything else, typed tools are simpler and generate the schema for you automatically.
The contract
public abstract class ToolBase
{
public abstract string Name { get; }
public abstract string Description { get; }
public abstract string ParametersSchema { get; } // JSON Schema, as a string
public abstract Task<string> ExecuteAsync(
string argsJson, CancellationToken ct = default);
}
Override all four. The framework hands the LLM your schema and your
Name/Description. When the LLM calls the tool, you get the args
JSON and return a string result.
Example — a free-form file-system tool
using System.Text.Json;
using LogicGrid.Core.Tools;
public sealed class FilesystemTool : ToolBase
{
public override string Name => "filesystem";
public override string Description =>
"Read, write, or list local files. " +
"Operation is given in the 'op' field.";
public override string ParametersSchema =>
"""
{
"type": "object",
"properties": {
"op": { "type": "string",
"enum": ["read", "write", "list"],
"description": "Operation to perform." },
"path": { "type": "string",
"description": "Path. Required for read, write, and list." },
"data": { "type": "string",
"description": "Content to write. Required for write." }
},
"required": ["op", "path"]
}
""";
public override async Task<string> ExecuteAsync(
string argsJson, CancellationToken ct = default)
{
using var doc = JsonDocument.Parse(argsJson);
var op = doc.RootElement.GetProperty("op").GetString();
var path = doc.RootElement.GetProperty("path").GetString()!;
return op switch
{
"read" => await File.ReadAllTextAsync(path, ct),
"write" => await Write(path, doc, ct),
"list" => string.Join("\n", Directory.GetFileSystemEntries(path)),
_ => $"Unknown op '{op}'.",
};
}
private static async Task<string> Write(
string path, JsonDocument doc, CancellationToken ct)
{
var data = doc.RootElement.GetProperty("data").GetString() ?? "";
await File.WriteAllTextAsync(path, data, ct);
return $"Wrote {data.Length} bytes to {path}.";
}
}
Schema authoring tips
- Wrap with
"type": "object"and a"properties"map. Most LLMs expect this shape. - Use
"enum"to pin string values to a known set — the LLM is much more reliable when the choice space is bounded. - Make
"required"an explicit list. Don't leave it out — some providers will treat the call as optional, and the LLM will start omitting fields. - Prose
"description"matters. The LLM reads it before calling.
Errors
Same as typed tools — throw to abort and re-fire (ToolCallFailedEvent),
or return a descriptive string to give the LLM a chance to recover.
catch (FileNotFoundException ex)
{
return $"File not found: {ex.FileName}. Check the path and try again.";
}