Custom Tools
Implement and register app-specific Ansight tools with ITool, schemas, and the shared read/write/critical policy.
Custom tools let a paired agent call app-specific operations that the built-in suites cannot know about. Use them for narrow diagnostic and development workflows such as exposing a feature-flag snapshot, clearing a local test queue, replaying a known fixture, or reading a domain-specific health report.
Custom tools use the same remote-tool pipeline as the packaged suites:
- implement
Ansight.Tools.ITool - register the tool with
OptionsBuilder.AddTool(...)orAddTools(...) - choose a guard with
WithReadOnlyToolAccess(),WithReadWriteToolAccess(),WithAllToolAccess(), orWithToolGuard(...) - keep the implementation and build-time remote-tool opt-in limited to local development builds
Recommended Shape
For more than one tool, keep the code organized like a first-party suite:
MyAppToolIds: stable tool ids such asmyapp.diagnostics_snapshotMyAppToolSchemas: sharedToolSchemaobjects for arguments and results- one small
IToolimplementation per operation MyAppAnsightOptionsBuilderExtensions: aWithMyAppTools(...)registration method- optional runtime wrappers for user confirmation or product-specific access checks
This keeps metadata consistent and makes it harder to accidentally ship a broad, hidden command surface.
Build-Time Opt-In
The Ansight build target scans for concrete ITool implementations in your app output, including custom tools defined inside your app assembly.
Use a project-local flag to include custom tools only in local development builds:
<PropertyGroup Condition="'$(Configuration)' == 'Debug'">
<MyAppAnsightRemoteToolsEnabled>true</MyAppAnsightRemoteToolsEnabled>
</PropertyGroup>
<PropertyGroup>
<MyAppAnsightRemoteToolsEnabled Condition="'$(MyAppAnsightRemoteToolsEnabled)' == ''">false</MyAppAnsightRemoteToolsEnabled>
<AnsightRemoteToolsPolicy Condition="'$(AnsightRemoteToolsPolicy)' == '' and '$(MyAppAnsightRemoteToolsEnabled)' == 'true'">AllowedWithWarnings</AnsightRemoteToolsPolicy>
<AnsightRemoteToolsPolicy Condition="'$(AnsightRemoteToolsPolicy)' == ''">Disallowed</AnsightRemoteToolsPolicy>
</PropertyGroup>
<PropertyGroup Condition="'$(MyAppAnsightRemoteToolsEnabled)' == 'true'">
<DefineConstants>$(DefineConstants);MYAPP_ANSIGHT_TOOLS</DefineConstants>
</PropertyGroup>
Then wrap the app-specific tool files in the same symbol:
#if MYAPP_ANSIGHT_TOOLS
// Custom Ansight tools live here.
#endif
Protected Release, CI, TestFlight, App Store, Play Store, and other distributable builds should omit custom tool implementations or use AnsightRemoteToolsPolicy=Disallowed so the build fails if any concrete ITool remains.
Shared Metadata
Centralize ids and schemas before writing individual tools.
#if MYAPP_ANSIGHT_TOOLS
using Ansight.Tools;
internal static class MyAppToolIds
{
public const string DiagnosticsSnapshot = "myapp.diagnostics_snapshot";
}
internal static class MyAppToolSchemas
{
public static ToolSchema DiagnosticsSnapshotArguments { get; } = ToolSchema.Object(
description: "Arguments for reading a small app diagnostic summary.",
properties: new Dictionary<string, ToolSchema>
{
["includeQueues"] = ToolSchema.Boolean("Include queue counters in the result.")
});
public static ToolSchema DiagnosticsSnapshotResult { get; } = ToolSchema.Object(
description: "App diagnostic summary payload.",
properties: new Dictionary<string, ToolSchema>
{
["connectionState"] = ToolSchema.String("Current backend connection state."),
["pendingQueueCount"] = ToolSchema.Integer("Pending queue count when includeQueues is true.")
},
required: new[] { "connectionState" });
}
#endif
Implement a Tool
Each tool declares catalog metadata, one ordered policy, argument and result schemas, and an execution method. ITool.Definition is supplied by the interface from those properties, so most tools do not need to implement it manually.
#if MYAPP_ANSIGHT_TOOLS
using System.Text.Json.Nodes;
using Ansight.Tools;
internal sealed record DiagnosticsSnapshot(
string ConnectionState,
int PendingQueueCount);
internal sealed class DiagnosticsSnapshotTool(
Func<DiagnosticsSnapshot> snapshotProvider) : ITool
{
public string Category => "myapp";
public ToolPolicy Policy => ToolPolicy.Read;
public string Id => MyAppToolIds.DiagnosticsSnapshot;
public string Name => "Diagnostics Snapshot";
public string Description => "Returns a small app-specific diagnostic summary.";
public string Keywords => "diagnostics health queues";
public ToolSchema ArgumentsSchema => MyAppToolSchemas.DiagnosticsSnapshotArguments;
public ToolSchema ResultSchema => MyAppToolSchemas.DiagnosticsSnapshotResult;
public ValueTask<ToolAvailability> GetAvailabilityAsync(
ToolAvailabilityContext context)
{
var snapshot = snapshotProvider();
return ValueTask.FromResult(
snapshot.ConnectionState != "uninitialized"
? ToolAvailability.Available
: ToolAvailability.Unavailable(
reasonCode: "diagnostics_not_initialized",
reason: "The app diagnostics service is not initialized.",
requiredState: "Initialized diagnostics service",
remediation: "Open the debug home screen and retry.",
retryable: true));
}
public Task<ToolResult> Execute(IReadOnlyDictionary<string, string> arguments)
{
ArgumentNullException.ThrowIfNull(arguments);
var includeQueues = arguments.TryGetValue("includeQueues", out var includeQueuesText)
&& bool.TryParse(includeQueuesText, out var parsedIncludeQueues)
&& parsedIncludeQueues;
var snapshot = snapshotProvider();
var payload = new JsonObject
{
["connectionState"] = snapshot.ConnectionState
};
if (includeQueues)
{
payload["pendingQueueCount"] = snapshot.PendingQueueCount;
}
return Task.FromResult(ToolResult.Success(payload));
}
}
#endif
Arguments arrive as flattened string values. Parse and validate them inside Execute(...), and return ToolResult.Failure(...) with a stable errorCode when the request is invalid or the app cannot perform the operation.
GetAvailabilityAsync(...) is optional; the interface returns ToolAvailability.Available by default. Implement it when current app state determines whether the tool can run. tool.catalog includes the result under runtime, sets executable, and the runtime re-evaluates it immediately before execution. Unavailable results can provide ReasonCode, Reason, RequiredState, Remediation, and Retryable so the local player and agents can resolve the prerequisite without making a speculative call.
For MAUI tools that touch UI state, marshal onto the main thread inside the tool or a shared helper before reading or mutating controls.
Register the Tools
Expose one extension method that registers your app tools as a group:
#if MYAPP_ANSIGHT_TOOLS
using Ansight;
using Ansight.Tools;
internal static class MyAppAnsightOptionsBuilderExtensions
{
public static Options.OptionsBuilder WithMyAppTools(
this Options.OptionsBuilder builder,
Func<DiagnosticsSnapshot> snapshotProvider)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(snapshotProvider);
return builder.AddTools(new ITool[]
{
new DiagnosticsSnapshotTool(snapshotProvider)
});
}
}
#endif
For a core-only setup:
var optionsBuilder = Options.CreateBuilder();
#if MYAPP_ANSIGHT_TOOLS
optionsBuilder = optionsBuilder.WithMyAppTools(diagnostics.CreateSnapshot);
#endif
var options = optionsBuilder
.WithReadOnlyToolAccess()
.Build();
For the all-in-one package:
var options = Options.CreateBuilder()
.WithAnsightSdk(ansight =>
{
#if MYAPP_ANSIGHT_TOOLS
ansight.WithMyAppTools(diagnostics.CreateSnapshot);
#endif
ansight.WithReadOnlyToolAccess();
})
.Build();
For MAUI:
builder.UseAnsight<App>(ansight =>
{
#if MYAPP_ANSIGHT_TOOLS
ansight.WithMyAppTools(diagnostics.CreateSnapshot);
#endif
ansight.WithReadOnlyToolAccess();
});
Registered tools stay hidden and unusable until the guard allows their ToolPolicy.
Runtime Confirmation
For sensitive tools, add a runtime consent or policy wrapper in addition to build-time and guard controls:
internal sealed class UserConfirmedTool(
ITool innerTool,
Func<string, Task<bool>> confirmAsync) : ITool
{
public string Category => innerTool.Category;
public ToolPolicy Policy => innerTool.Policy;
public string Id => innerTool.Id;
public string Name => innerTool.Name;
public string Description => innerTool.Description;
public string Keywords => innerTool.Keywords;
public ToolSchema ArgumentsSchema => innerTool.ArgumentsSchema;
public ToolSchema ResultSchema => innerTool.ResultSchema;
public ValueTask<ToolAvailability> GetAvailabilityAsync(
ToolAvailabilityContext context) => innerTool.GetAvailabilityAsync(context);
public async Task<ToolResult> Execute(IReadOnlyDictionary<string, string> arguments)
{
if (!await confirmAsync(Name))
{
return ToolResult.Failure(
"Remote tool access was not allowed by the user.",
errorCode: "remote_tools_not_allowed");
}
return await innerTool.Execute(arguments);
}
}
To wrap every registered tool, build once, replace the tool collection, then choose the guard:
var configured = optionsBuilder.Build();
var confirmedTools = configured.Tools.Select(tool =>
new UserConfirmedTool(tool, ConfirmRemoteToolAsync));
var options = Options.CreateBuilder(configured)
.WithTools(confirmedTools)
.WithReadOnlyToolAccess()
.Build();
Runtime confirmation is useful for internal dogfood or staff builds, but it is not a reason to ship remote tools in public distribution builds.
Policies and Guards
Choose the narrowest policy that matches the operation. Grants are ordered, so
a Critical maximum includes Read and Write:
| Policy | Use for | Guard that enables it |
|---|---|---|
ToolPolicy.Read | Inspecting non-secret app state without mutation. | WithReadOnlyToolAccess() |
ToolPolicy.Write | Ordinary UI and app-state changes. | WithReadWriteToolAccess() |
ToolPolicy.Critical | Destructive actions, secret access, broad runtime inspection, or arbitrary app-code invocation. | WithAllToolAccess() |
A custom ToolGuard can be used when a workflow needs a narrower policy than the built-in presets.
Schemas
Use ToolSchema so the local player and CLI clients can show useful argument templates:
ToolSchema.Object(...)ToolSchema.Array(...)ToolSchema.String(...)ToolSchema.Integer(...)ToolSchema.Number(...)ToolSchema.Boolean(...)
Keep schemas small and explicit. Prefer stable argument names, mark required fields on object schemas, and avoid accepting arbitrary JSON unless the tool genuinely needs it.
The single policy replaces the former scope, security level, and implication metadata. Do not expose secrets, account data, production endpoints, destructive app actions, or broad command dispatchers through custom tools. Build the smallest operation that satisfies the local debugging workflow.
Calling From the CLI
After the app is paired and connected, discover the exact live tool catalog before invoking anything:
ansight session list --connected --json
ansight app tools <session-id> --tool-id myapp.diagnostics_snapshot --detail full --include-unavailable --json
ansight app call <session-id> myapp.diagnostics_snapshot \
--arguments '{"includeQueues":true}' --json
For larger arguments, place the JSON object in a file and pass --arguments-file <path>.
Call the tool only when the exact catalog entry reports executable: true.
If the tool does not appear in the catalog, check that the app is connected, the tool is registered, the custom tool code is included only in a local development build, and the active guard allows the tool policy.