Skip to main content

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.";
}