15 KiB
Tool Development
This document explains how local model-driven tools are added to AI Studio. Tool calling lets a model request a small, well-defined action during a chat or assistant run, such as searching the web or reading a web page.
Tools are currently part of the .NET app. They are currently not Lua plugins and they are currently not loaded dynamically from user folders. Adding a tool currently requires code changes.
Architecture
A tool has two parts:
- A JSON definition in
app/MindWork AI Studio/wwwroot/tool_definitions/ - A C# implementation of
IToolImplementationinapp/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/
At startup, ToolRegistry reads all JSON definitions and matches each definition to a registered implementation by implementationKey. ToolExecutor runs the implementation when a provider returns a matching function call.
The provider only sees local tools that are
- available for the current component and
- selected by the user or defaults and
- supported by the model and
- configured correctly and
- allowed by the provider confidence rules.
Local tool-call loops share two limits from ToolSelectionRules: MAX_TOOL_CALLS limits the number of calls, while MAX_TOOL_RESULT_CHARACTERS limits the cumulative size of results returned to the model. All local provider tool-call paths enforce both limits and ask the model for a final response after either limit is reached.
Provider API Shapes
The JSON definition in wwwroot/tool_definitions is the single source of truth for a local function tool. There are no separate local tool definition files for different provider APIs. Provider-specific request shapes are generated in code from the same ToolDefinition.
Chat Completions compatible APIs use a nested function shape:
{
"type": "function",
"function": {
"name": "get_current_weather",
"description": "Get the current weather in a given location.",
"parameters": {},
"strict": true
}
}
The OpenAI Responses API uses a flat function shape:
{
"type": "function",
"name": "get_current_weather",
"description": "Get the current weather in a given location.",
"parameters": {},
"strict": true
}
Keep this difference contained in provider adapter code. ProviderToolAdapters maps a canonical ToolDefinition to the Chat Completions or Responses wire shape. Tool implementations should not know which provider API shape was used.
Tool result handling also differs by API. Chat Completions returns tool calls in message.tool_calls and receives results as role: "tool" messages. Responses returns function_call output items and receives results as function_call_output input items correlated by call_id. Both paths still execute local tools through ToolExecutor, so validation, provider confidence checks, trace formatting, and blocked-call behavior stay shared.
AI Studio currently executes local tool calls sequentially. Therefore, Chat Completions requests with tools always set parallel_tool_calls to false, limiting each model response to at most one tool call. Requests without tools omit the parameter, and additional API parameters cannot override this behavior. Models can still request additional tools across subsequent responses.
The OpenAI Responses API may continue to return multiple function calls in one response. AI Studio processes those calls sequentially as well; concurrent execution of separate local tool calls is not currently implemented. This does not restrict concurrency used internally by an individual tool.
Provider-native tools are separate from local function tools and do not have a ToolDefinition or an IToolImplementation. The local tool calling implementation does not influence the provider-native tool selection at all.
If a tool throws ToolExecutionBlockedException, ToolExecutor returns the exception message as plain text to the model and records the trace as BLOCKED. Other exceptions are logged with details and returned to the model as plain text in the form Tool execution failed: ..., with the trace recorded as ERROR.
Definition File
Create one JSON file per tool under wwwroot/tool_definitions. The file describes component visibility, optional settings, the function schema sent to the model, and optional per-tool policy guidance injected centrally into the system prompt. User-visible names and icons come from the registered IToolImplementation, not the JSON definition.
Example:
{
"schemaVersion": 1,
"id": "get_current_weather",
"implementationKey": "get_current_weather",
"visibleIn": {
"chat": true,
"assistants": true,
"allowedComponents": [
"chat",
"translation_assistant"
],
"deniedComponents": [
"legal_check_assistant"
]
},
"settingsSchema": {
"type": "object",
"properties": {
"demoLabel": {
"type": "string",
"secret": false
}
},
"required": [
"demoLabel"
]
},
"systemPromptInstructions": "Use this tool only when the user asks for current weather conditions.",
"function": {
"name": "get_current_weather",
"descriptionForLLM": "Get the current weather in a given location.",
"strict": true,
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "The city to find the weather for, e.g. 'San Francisco'."
},
"state": {
"type": "string",
"description": "The two-letter abbreviation for the state, e.g. 'CA'."
},
"unit": {
"type": "string",
"description": "The unit to fetch the temperature in.",
"enum": [
"celsius",
"fahrenheit"
]
}
},
"required": [
"city",
"state",
"unit"
],
"additionalProperties": false
}
}
}
Use stable lower-case IDs with underscores. Keep id, implementationKey, and function.name identical unless there is a clear compatibility reason not to.
visibleIn.allowedComponents and visibleIn.deniedComponents are optional lists of Components enum values written in snake_case. Unknown values make the definition invalid. When both lists are empty, the legacy chat and assistants flags apply. As soon as either list contains an entry, the lists replace those flags: an empty allow list starts by allowing every component, a non-empty allow list allows only its entries, and the deny list is applied last and always wins.
Keep function.descriptionForLLM focused on what the tool does. This value is mapped to the provider's function description field and is only shown to the LLM. Put sequencing rules, answer-format guidance, or other behavior instructions in systemPromptInstructions. When runnable tools are selected, their non-empty policy text is combined centrally and appended to the effective system prompt.
Implementation
Implement IToolImplementation and register the class in Program.cs as an IToolImplementation.
Example:
using System.Text.Json;
using AIStudio.Tools.PluginSystem;
namespace AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations;
public sealed class GetCurrentWeatherTool : IToolImplementation
{
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(GetCurrentWeatherTool).Namespace, nameof(GetCurrentWeatherTool));
public string ImplementationKey => "get_current_weather";
public string Icon => Icons.Material.Filled.Cloud;
public IReadOnlySet<string> SensitiveTraceArgumentNames => new HashSet<string>(StringComparer.Ordinal);
public string GetDisplayName() => TB("Current Weather");
public string GetDescription() => TB("Use this demo tool to retrieve the current weather for a given city and state."); // this Description is shown to the user
public string GetSettingsFieldLabel(string fieldName, ToolSettingsFieldDefinition fieldDefinition) => fieldName switch
{
"demoLabel" => TB("Demo Label"),
_ => TB(fieldDefinition.Title),
};
public string GetSettingsFieldDescription(string fieldName, ToolSettingsFieldDefinition fieldDefinition) => fieldName switch
{
"demoLabel" => TB("Required demo setting for validating tool settings."),
_ => TB(fieldDefinition.Description),
};
public Task<ToolExecutionResult> ExecuteAsync(JsonElement arguments, ToolExecutionContext context, CancellationToken token = default)
{
var city = arguments.TryGetProperty("city", out var cityValue) ? cityValue.GetString() ?? string.Empty : string.Empty;
var state = arguments.TryGetProperty("state", out var stateValue) ? stateValue.GetString() ?? string.Empty : string.Empty;
var unit = arguments.TryGetProperty("unit", out var unitValue) ? unitValue.GetString() ?? string.Empty : string.Empty;
if (unit is not ("celsius" or "fahrenheit"))
throw new ArgumentException($"Invalid unit '{unit}'.");
return Task.FromResult(new ToolExecutionResult
{
TextContent = $"The weather in {city}, {state} is 85 degrees {unit}.",
});
}
}
Register it:
builder.Services.AddSingleton<IToolImplementation, GetCurrentWeatherTool>();
The example above is documentation-only. Do not keep demo tools in the production tool catalog.
Settings And Secrets
Tool settings are stored through ToolSettingsService. Plain settings are stored in the regular configuration data. Settings marked with "secret": true are stored in the OS keyring through the Rust service.
Use ValidateConfigurationAsync when a setting needs more than "required field is present" validation, such as URL syntax, numeric limits, mutually exclusive options, or allowlist parsing.
Use SensitiveTraceArgumentNames for model-provided arguments that must not be shown in tool traces. Do not return secrets in TextContent, JsonContent, exception messages, logs, or trace formatting.
When a tool returns data that future messages must only send to providers at or above a specific confidence level, set ToolExecutionResult.RequiredProviderConfidence. AI Studio persists the highest requirement reached by the chat and applies it to later provider checks. Provider instances listed in DataSourceSecuritySettings.TrustedProviderIds may also continue chats containing data protected this way.
Security
Treat model-provided tool arguments as untrusted input.
For tools that perform network requests:
- Accept only the schemes and hosts that are required for the feature.
- Validate redirects before following them.
- Do not allow model-supplied URLs to access localhost, loopback, link-local, multicast, or private network targets unless the feature has an explicit policy for that.
- Check
ToolExecutionContext.ProviderConfidencebefore returning sensitive data to the model. - Throw
ToolExecutionBlockedExceptionfor intentional policy blocks so the UI can show the call as blocked instead of failed.
Web Search And Page Retrieval
web_search is a combined search-and-retrieve tool. It asks the configured SearXNG instance for ranked candidates, applies the requested result limit, deduplicates equivalent URLs, and then loads the remaining public HTTP or HTTPS pages. Up to four pages are retrieved concurrently. Failed, blocked, unsupported, and empty pages are omitted, while an overall retrieval timeout returns any pages that completed successfully before cancellation.
Web Search does not send category or engine parameters. The SearXNG instance selects them using its own configuration.
Page loading and readable Markdown extraction are shared with read_web_page through WebPageRetrievalService. The service validates DNS results and every redirect target before connecting. web_search always uses the public-only policy and never reads private, loopback, link-local, or otherwise non-public targets.
read_web_page remains the independent single-URL tool and may use its configured private-host allowlist and operating-system sign-in behavior for allowed HTTPS targets. An allowed private host can only be read by a High-confidence provider or a provider instance listed in DataSourceSecuritySettings.TrustedProviderIds.
The web_search result separates each hit into search_metadata and page. Its top-level execution metadata contains candidate_count, result_count, and retrieval_timed_out. Search-result URLs and final redirect URLs are deduplicated separately so metadata from merged candidates is retained with the best rank.
Every successfully retrieved page with readable content is also returned as a structured tool source. The source uses the final URL after redirects and prefers the extracted page title, followed by the search-result title and URL as fallbacks. The provider collects these sources across local tool calls and attaches them to the final response under the separate “Sources used by tools” heading. Failed, blocked, empty, and duplicate retrievals do not add sources.
Retrieved Markdown shares a configurable total character budget. Every successful result first receives its configured minimum allocation; the remaining budget is then assigned in ranking order. Short pages leave their unused allocation available to later results. A page whose content is truncated, or whose original extracted content contains fewer than 500 characters, reports the status partial or truncated. Truncated content uses the shared truncation marker.
Every non-secret tool field that administrators should be able to manage centrally must have an explicit enterprise mapping in ToolSettingsService. Add its backing setting to the appropriate Settings/DataModel class, register it with ManagedConfiguration.Register(...), process it in PluginConfiguration, clean leftovers in PluginFactory.Loading, and document its purpose, data type, and an example assignment in Plugins/configuration/plugin.lua. Locked enterprise values override the local field, while editable enterprise defaults apply only until a user saves a local value. Secret fields require the existing OS-keyring path and must not be routed through plain enterprise settings.
Checklist
- Add the JSON definition in
wwwroot/tool_definitions. - Add the
IToolImplementationclass. - Register the implementation in
Program.cs. - Validate settings and model arguments.
- Add the enterprise mapping for each administratively configurable non-secret setting.
- Protect secrets and sensitive trace arguments.
- Add provider-confidence checks when tool output may contain sensitive data.
- Update configuration plugin documentation when admins can manage the setting.
- Add a changelog entry when users or administrators are affected.