diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AICompletionContext.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AICompletionContext.cs
index 34474e4e..af316ff2 100644
--- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AICompletionContext.cs
+++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AICompletionContext.cs
@@ -63,6 +63,13 @@ public sealed class AICompletionContext
///
public string[] AgentNames { get; set; }
+ ///
+ /// Gets or sets the configured tool instance names available to this request. Each name refers to an
+ /// AIToolInstance that binds a developer-defined tool source to user-provided settings and is
+ /// surfaced to the model as a distinct function.
+ ///
+ public string[] ToolInstanceNames { get; set; }
+
///
/// Gets or sets the MCP (Model Context Protocol) connection identifiers available to this request.
///
diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstance.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstance.cs
new file mode 100644
index 00000000..f7f1bfad
--- /dev/null
+++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstance.cs
@@ -0,0 +1,74 @@
+using CrestApps.Core.Models;
+using CrestApps.Core.Services;
+
+namespace CrestApps.Core.AI.Tooling;
+
+///
+/// Represents a user-configured, model-invokable tool instance created from a registered
+/// blueprint. Unlike a plain AITool whose arguments are
+/// always supplied by the model, a tool instance binds developer-defined behavior to user-provided
+/// settings (endpoints, credentials, headers, etc.) captured up front. The AI model still decides when
+/// to invoke the resulting function, but the user-provided settings are applied at invocation time.
+///
+///
+/// The property holds the registered name of the owning tool
+/// instance source, while is a unique technical name used to derive the function name
+/// exposed to the AI model. Multiple instances may be created from the same source, each with different
+/// settings and a distinct so the model can tell them apart.
+///
+public sealed class AIToolInstance : SourceCatalogEntry, INameAwareModel, IModifiedUtcAwareModel, ICloneable
+{
+ ///
+ /// Gets or sets the unique technical name for this tool instance. This value is the basis for the
+ /// function name exposed to the AI model, so it must be unique across all configured instances.
+ ///
+ public string Name { get; set; }
+
+ ///
+ /// Gets or sets the natural-language description presented to the AI model. This is the primary
+ /// signal the model uses to distinguish between multiple instances built from the same source, so it
+ /// should clearly explain what this specific instance does (for example, which API it calls).
+ ///
+ public string Description { get; set; }
+
+ ///
+ /// Gets or sets the UTC timestamp when this instance was created.
+ ///
+ public DateTime CreatedUtc { get; set; }
+
+ ///
+ /// Gets or sets the UTC timestamp when this instance was last modified.
+ ///
+ public DateTime? ModifiedUtc { get; set; }
+
+ ///
+ /// Gets or sets the display name of the user that authored this instance.
+ ///
+ public string Author { get; set; }
+
+ ///
+ /// Gets or sets the identifier of the user that owns this instance.
+ ///
+ public string OwnerId { get; set; }
+
+ ///
+ /// Creates a deep copy of this instance. The dictionary is
+ /// cloned so mutations on the copy (for example caching an OAuth token) never leak back to the original.
+ ///
+ /// A new with the same values.
+ public AIToolInstance Clone()
+ {
+ return new AIToolInstance
+ {
+ ItemId = ItemId,
+ Source = Source,
+ Name = Name,
+ Description = Description,
+ CreatedUtc = CreatedUtc,
+ ModifiedUtc = ModifiedUtc,
+ Author = Author,
+ OwnerId = OwnerId,
+ Properties = Properties?.Clone() ?? new Dictionary(StringComparer.OrdinalIgnoreCase),
+ };
+ }
+}
diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceExtensions.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceExtensions.cs
new file mode 100644
index 00000000..7d054fcb
--- /dev/null
+++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceExtensions.cs
@@ -0,0 +1,96 @@
+using System.Security.Cryptography;
+using System.Text;
+
+namespace CrestApps.Core.AI.Tooling;
+
+///
+/// Extension methods for , including production of stable, model-safe
+/// function names so that multiple instances built from the same source are exposed to the AI model as
+/// distinct callable functions.
+///
+public static class AIToolInstanceExtensions
+{
+ private const int MaxFunctionNameLength = 64;
+ private const int HashSuffixLength = 8;
+
+ ///
+ /// The namespace prefix applied to every tool-instance function name. User-configured instances are
+ /// named from arbitrary user input, whereas tools registered in code via AddCoreAITool are
+ /// surfaced to the model under their bare registered name. Prefixing every instance function name
+ /// guarantees a user-chosen instance name can never collide with a code-registered tool name in the
+ /// single function namespace the AI model sees.
+ ///
+ public const string FunctionNamePrefix = "tool_instance_";
+
+ ///
+ /// Builds the unique function name presented to the AI model for the supplied instance. The name is
+ /// derived from the instance's unique (falling back to its
+ /// identifier), sanitized to the characters allowed by chat-completion providers (letters, digits,
+ /// underscores, and hyphens), prefixed with so it can never collide
+ /// with a code-registered tool name, and capped at 64 characters. When sanitizing or truncating would
+ /// change the value, a short deterministic hash of the original unique name is appended so two distinct
+ /// instance names can never collapse to the same function name.
+ ///
+ /// The configured tool instance.
+ /// A deterministic, provider-safe, collision-resistant function name.
+ public static string GetFunctionName(this AIToolInstance instance)
+ {
+ ArgumentNullException.ThrowIfNull(instance);
+
+ var original = !string.IsNullOrEmpty(instance.Name)
+ ? instance.Name
+ : instance.ItemId;
+
+ var name = Sanitize(original);
+
+ if (string.IsNullOrEmpty(name))
+ {
+ return FunctionNamePrefix + ComputeShortHash(original ?? string.Empty);
+ }
+
+ var maxBaseLength = MaxFunctionNameLength - FunctionNamePrefix.Length;
+ var isLossy = !string.Equals(name, original, StringComparison.Ordinal);
+
+ if (isLossy || name.Length > maxBaseLength)
+ {
+ var suffix = "_" + ComputeShortHash(original);
+ var maxTruncatedLength = maxBaseLength - suffix.Length;
+
+ if (name.Length > maxTruncatedLength)
+ {
+ name = name[..maxTruncatedLength];
+ }
+
+ name += suffix;
+ }
+
+ return FunctionNamePrefix + name;
+ }
+
+ private static string Sanitize(string value)
+ {
+ if (string.IsNullOrEmpty(value))
+ {
+ return string.Empty;
+ }
+
+ var buffer = new char[value.Length];
+ var length = 0;
+
+ foreach (var c in value)
+ {
+ buffer[length++] = char.IsAsciiLetterOrDigit(c) || c == '_' || c == '-'
+ ? c
+ : '_';
+ }
+
+ return new string(buffer, 0, length);
+ }
+
+ private static string ComputeShortHash(string value)
+ {
+ var hash = SHA256.HashData(Encoding.UTF8.GetBytes(value));
+
+ return Convert.ToHexStringLower(hash)[..HashSuffixLength];
+ }
+}
diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceMetadata.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceMetadata.cs
new file mode 100644
index 00000000..f0fe9eb5
--- /dev/null
+++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceMetadata.cs
@@ -0,0 +1,15 @@
+namespace CrestApps.Core.AI.Tooling;
+
+///
+/// Metadata that records which configured entries are attached to a
+/// tool-bearing resource (for example an AI profile or a chat interaction). Instances are referenced by
+/// their unique so the reference stays stable and human-readable.
+/// Stored in the resource's properties bag.
+///
+public sealed class AIToolInstanceMetadata
+{
+ ///
+ /// Gets or sets the unique names of the configured tool instances available to the resource.
+ ///
+ public string[] ToolInstanceNames { get; set; }
+}
diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/IAIToolInstanceSource.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/IAIToolInstanceSource.cs
new file mode 100644
index 00000000..ba8ad1d1
--- /dev/null
+++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/IAIToolInstanceSource.cs
@@ -0,0 +1,35 @@
+using Microsoft.Extensions.AI;
+
+namespace CrestApps.Core.AI.Tooling;
+
+///
+/// A developer-authored, parameterized tool blueprint that end users configure one or more times as
+/// catalog entries. A source is registered under a unique name (stored as
+/// the of every
+/// created from it) and is responsible for turning a configured instance into a concrete
+/// whose behavior is bound to the user's settings.
+///
+///
+/// Sources are registered with AddSource<TSource>(name, configure) on the tool instances
+/// builder, which records the source's display metadata (display name, description, category) in
+/// AIOptions.ToolInstanceSources and registers the behavior as a keyed service. The registration
+/// key is the source name, so there is no need for the source to carry its own name — the same key is
+/// used to resolve the source for an instance via its value. A source
+/// typically ships a settings model that it persists in (via
+/// .Put()/.TryGet()) and reads back inside the produced tool. The classic example is a
+/// generic "call any HTTP API" source where the user provides the endpoint, authentication, and headers,
+/// while the model only supplies the remaining open arguments (if any).
+///
+public interface IAIToolInstanceSource
+{
+ ///
+ /// Creates the concrete that the AI model can invoke for the supplied configured
+ /// instance. Implementations must apply the instance's user-provided settings and derive the
+ /// model-facing function name and description from the instance (via
+ /// and )
+ /// so multiple instances of the same source surface as distinct callable functions.
+ ///
+ /// The configured tool instance whose settings should be bound to the produced tool.
+ /// The tool to expose to the AI model, or to skip this instance.
+ AITool CreateTool(AIToolInstance instance);
+}
diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/IToolRegistryProvider.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/IToolRegistryProvider.cs
index 2be073cb..9aa0a195 100644
--- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/IToolRegistryProvider.cs
+++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/IToolRegistryProvider.cs
@@ -12,7 +12,7 @@ public interface IToolRegistryProvider
/// Retrieves all tool entries available from this provider, scoped to the given context.
///
/// The completion context containing configured tool names,
- /// instance IDs, and MCP connection IDs that scope the returned tools.
+ /// tool instance names, and MCP connection IDs that scope the returned tools.
/// A token to cancel the operation.
/// A read-only list of tool registry entries from this provider.
Task> GetToolsAsync(
diff --git a/src/Abstractions/CrestApps.Core.Abstractions/Builders/CrestAppsBuilder.cs b/src/Abstractions/CrestApps.Core.Abstractions/Builders/CrestAppsBuilder.cs
index 06014730..71d9175a 100644
--- a/src/Abstractions/CrestApps.Core.Abstractions/Builders/CrestAppsBuilder.cs
+++ b/src/Abstractions/CrestApps.Core.Abstractions/Builders/CrestAppsBuilder.cs
@@ -199,3 +199,25 @@ public CrestAppsAIMemoryBuilder(IServiceCollection services)
///
public IServiceCollection Services { get; }
}
+
+///
+/// Builder returned by AddToolInstances that provides access to the
+/// for registering AI tool instance sources.
+///
+public sealed class CrestAppsAIToolInstancesBuilder
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The service collection.
+ public CrestAppsAIToolInstancesBuilder(IServiceCollection services)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+ Services = services;
+ }
+
+ ///
+ /// Gets the used to register AI tool instance sources.
+ ///
+ public IServiceCollection Services { get; }
+}
diff --git a/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md b/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md
index 33687dbc..8d91fcc0 100644
--- a/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md
+++ b/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md
@@ -111,3 +111,8 @@ description: Initial standalone release notes for the CrestApps.Core repository.
- adds a dedicated hidden `fill_empty_tabular_cells` tool for the Tabular Data Agent so “replace every empty cell with X” requests run as one set-based update instead of the model composing hundreds of per-column statements, and broadens `generate_file` tabular misuse detection so conversational/question text like “Would you like me to generate…” cannot be written into `.xlsx` downloads
- keeps hidden tools private to their owning profiles and agents across the shared MCP server handlers, so agent-only helpers such as the Tabular Data Agent SQL tools are no longer listed or callable as direct MCP tools
- moves tabular workspace storage from in-memory SQLite to a file-based SQLite database stored alongside uploaded documents in a `data` folder, so workspace state persists across process restarts without artifact-store round-trips and reduces peak memory usage under high traffic; removes the singleton workspace cache, invalidation publisher interfaces, and cleanup background service in favor of creating a disposable workspace per tool call that opens and closes its own connection, with the document cleanup service and document event handler deleting the database file directly on session or document removal
+- adds parameterized AI tool instances so developers can author a tool blueprint once in code via the `IAIToolInstanceSource` interface (registered under a unique name with `AddAIToolInstanceSource()`) and let users create multiple configured `AIToolInstance` entries of it, each supplying its own settings (endpoint, authentication, headers, …), a unique name, and a natural-language description up front instead of relying on the AI model to provide them; the model still decides when to invoke each instance, `ToolInstanceRegistryProvider` (a pluggable `IToolRegistryProvider`) surfaces every referenced instance as a distinctly named `AITool` so multiple instances built from the same source appear as separate functions to every client (OpenAI, Azure OpenAI, …), projects can register their own `IToolRegistryProvider` to add logic such as permission checks, ships a built-in `http-api-request` source that calls arbitrary HTTP APIs with data-protected credentials, persists instances through the `AIToolInstance` catalog on both YesSql and EntityCore, and includes full management UI plus AI profile attachment in the MVC and Blazor sample hosts
+- refines the parameterized AI tool instances so profiles and chat interactions reference instances by their stable unique **name** (via the renamed, feature-agnostic `AIToolInstanceMetadata` and `AICompletionContext.ToolInstanceNames`) resolved through `INamedCatalog.FindByNameAsync` instead of by generated id; the name is immutable after creation and `AIToolInstance.GetFunctionName()` appends a short deterministic hash when sanitizing would be lossy so distinct names can never collapse to the same function; decouples the default registry so the opt-out lives on `AddToolInstances(..., useDefaultRegistry: false)` and exposes `AddDefaultAIToolInstanceRegistryProvider()`, letting hosts opt out and register their own `IToolRegistryProvider` without ever calling `RemoveAll()`; makes `ToolInstanceRegistryProvider` public with a `ShouldIncludeInstanceAsync` hook for simple permission-gated subclasses; and adds OAuth 2.0 support to the built-in `http-api-request` source that acquires, data-protects, caches, and refreshes access/refresh tokens on the instance itself so it authenticates once and reuses the token across requests and restarts
+- promotes the parameterized AI tool instances into a first-class opt-in feature registered on the AI suite builder with `AddToolInstances(toolInstances => toolInstances.AddSource(...))` (with an `AddHttpApiRequestSource()` convenience for the built-in source), instead of being wired into the core AI services automatically; persistence is registered on the tool-instances builder via `AddYesSqlStores()`/`AddEntityCoreStores()` rather than on the AI suite; drops the redundant per-instance `DisplayText` in favor of the unique `Name`, localizes the source `DisplayName`/`Description`/`Category`, simplifies `IAIToolInstanceSource.CreateTool(AIToolInstance instance)` to take the instance directly, generalizes the completion-context handler to honor tool instances on any resource so both AI profiles and chat interactions can select them, renames the built-in HTTP source's basic/OAuth credentials to `Username`/`Password` and adds the OAuth 2.0 resource-owner password grant, hardens model-provided paths so they cannot redirect a request off the configured host, and updates the MVC and Blazor sample hosts to let users attach instances to both AI profiles and chat interactions
+- namespaces every tool-instance function name with the `AIToolInstanceExtensions.FunctionNamePrefix` (`tool_instance_`) prefix so a user-chosen instance name can never collide with a tool registered in code via `AddCoreAITool` (which the model sees under its bare registered name); both kinds of tool now coexist safely in the single function namespace exposed to OpenAI, Azure OpenAI, and every other client
+- adds a source dropdown to the AI tool instance create form in the MVC and Blazor sample hosts that reveals only the selected source's fields (the source is fixed and shown read-only on edit), and relocates the "AI Tool Instances" admin menu item next to "AI Profiles" in both samples
diff --git a/src/CrestApps.Core.Docs/docs/core/tool-instances.md b/src/CrestApps.Core.Docs/docs/core/tool-instances.md
new file mode 100644
index 00000000..b4efd603
--- /dev/null
+++ b/src/CrestApps.Core.Docs/docs/core/tool-instances.md
@@ -0,0 +1,436 @@
+---
+sidebar_label: Tool Instances
+sidebar_position: 9
+title: Parameterized Tool Instances
+description: Let users configure reusable tool instances with their own endpoints, credentials, and settings that the AI model invokes on demand.
+---
+
+# Parameterized Tool Instances
+
+> Author a tool **source** once in code, then let users create multiple configured **instances** of it from the UI. Each instance supplies the parameters (endpoint, authentication, headers, …) and a natural-language description up front; the AI model only decides *when* to invoke each instance.
+
+## Quick Start
+
+Enable the feature on the AI suite builder with `AddToolInstances(...)`, then register one or more **sources** (reusable blueprints) inside it. Each source is a class implementing `IAIToolInstanceSource`, registered under a unique name with `AddSource()`:
+
+```csharp
+builder.Services.AddCrestAppsCore(crestApps => crestApps
+ .AddAISuite(ai => ai
+ .AddYesSqlStores()
+ // Enable the tool instances feature and register your sources.
+ .AddToolInstances(toolInstances => toolInstances
+ // Register a persistence store for the instances users create (required).
+ .AddYesSqlStores()
+ .AddSource("my-source", options =>
+ {
+ options.DisplayName = new LocalizedString("my-source", "My Source");
+ options.Description = new LocalizedString("my-source", "What this source does.");
+ options.Category = new LocalizedString("Integrations", "Integrations");
+ })
+ )
+ )
+);
+```
+
+The framework also ships a built-in source — the [HTTP API Request tool](#built-in-http-api-request-tool) — as a ready-made example. Add it with the `AddHttpApiRequestSource()` convenience, which registers its named `HttpClient` and the source with sensible default display metadata:
+
+```csharp
+.AddToolInstances(toolInstances => toolInstances
+ .AddYesSqlStores()
+ .AddHttpApiRequestSource()
+)
+```
+
+Either way, once a source is registered, users create instances in the management UI, give each a unique **name** and a clear **description**, and attach one or more instances to an AI profile or a chat interaction. Each instance appears to the model as a distinct callable function.
+
+:::note
+The examples above use YesSql, but the feature works with Entity Framework Core too — swap `AddYesSqlStores()` for `AddEntityCoreStores()`. See [Persistence](#persistence) for complete examples of both providers.
+:::
+
+## Problem & Solution
+
+A [custom AI tool](tools) exposes a function whose arguments are always supplied by the model. That is perfect for stateless helpers (calculator, weather), but it does not fit tools that must be *configured before use*, such as:
+
+- calling a specific external HTTP API with a fixed endpoint, auth, and headers;
+- talking to an internal service that requires a secret the model must never see;
+- the same capability pointed at several different targets (staging vs. production, two vendors, …).
+
+**Tool instances** solve this by splitting the concern in two:
+
+| Concept | Authored by | Responsibility |
+|---------|-------------|----------------|
+| **Source** (`IAIToolInstanceSource`) | Developer (code) | Describes *how* the tool works and how to build it from stored settings. |
+| **Instance** (`AIToolInstance`) | End user (UI) | Supplies *the parameters* (endpoint, credentials, headers), a unique name, and a natural-language description. |
+
+The AI model still decides *when* to call, but it calls the user-configured instance, using the user's predefined settings. Because every instance carries its own unique name and user-written description, the model can distinguish multiple instances built from the same source.
+
+`AIToolInstance` is a sealed [`SourceCatalogEntry`](extensible-entity): its `Source` property records which source produced it, and the management UI adapts to that source. This mirrors the framework's other source-aware catalogs (AI connections, AI deployments, AI data sources).
+
+## How It Works
+
+```
+IAIToolInstanceSource ──► AIToolInstance (user settings) ──► AITool ──► ChatOptions.Tools
+ (code) (catalog entry) (per instance) (model)
+```
+
+1. A developer registers the feature with `AddToolInstances(...)` and one or more **sources** with `AddSource(name, configure)`.
+2. A user creates one or more **instances** from that source, each with a unique name, a description, and its own stored settings. The name is the stable lookup key and is fixed once the instance is created — the management UI keeps it editable only on create.
+3. The user attaches instances to an AI profile or a chat interaction (via `AIToolInstanceMetadata`).
+4. During completion, `ToolInstanceRegistryProvider` materializes each referenced instance into a distinct `AITool` whose function name and description are unique per instance.
+5. The resulting `AITool` flows into `ChatOptions.Tools` through `Microsoft.Extensions.AI`, so **every client (OpenAI, Azure OpenAI, …) works with no client-specific code**.
+
+Distinct per-instance function names are produced by `AIToolInstance.GetFunctionName()`, which sanitizes the instance's unique `Name` to the characters chat-completion providers allow and prefixes it with `AIToolInstanceExtensions.FunctionNamePrefix` (`tool_instance_`). The prefix guarantees a user-chosen instance name can never collide with a tool registered in code via `AddCoreAITool` — those are surfaced to the model under their bare registered name, so both kinds of tool coexist in the single function namespace the model sees without clashing. When sanitizing or truncating to 64 characters would change the value, a short deterministic hash of the original name is appended so two distinct names can never collapse to the same function name.
+
+## Authoring a Source
+
+Authoring your own source is the core extension point — the built-in HTTP tool below is authored exactly this way. Implement `IAIToolInstanceSource` and its single `CreateTool` method. `CreateTool` receives the configured `AIToolInstance`, reads its stored settings, and returns an `AITool` bound to them. Derive the model-facing function name from `instance.GetFunctionName()` and the description from `instance.Description` so the instance surfaces distinctly:
+
+```csharp
+using CrestApps.Core;
+using CrestApps.Core.AI.Tooling;
+using Microsoft.Extensions.AI;
+
+public sealed class HttpApiRequestToolInstanceSource : IAIToolInstanceSource
+{
+ public AITool CreateTool(AIToolInstance instance)
+ {
+ ArgumentNullException.ThrowIfNull(instance);
+
+ // Read the settings the user stored on the instance.
+ var settings = instance.TryGet(out var stored)
+ ? stored
+ : new HttpApiRequestToolSettings();
+
+ // The function name and description are unique per instance so the model can tell them apart.
+ var functionName = instance.GetFunctionName();
+ var description = string.IsNullOrWhiteSpace(instance.Description)
+ ? functionName
+ : instance.Description;
+
+ // Pass the instance so the tool can cache state (for example OAuth 2.0 tokens) on it.
+ return new HttpApiRequestToolFunction(functionName, description, settings, instance);
+ }
+}
+```
+
+The returned tool is an ordinary `Microsoft.Extensions.AI.AIFunction`. Read the configuration the user captured, resolve any services you need from `AIFunctionArguments.Services`, and only accept the open arguments you allow the model to supply. The source's *display* metadata (display name, description, category) is provided at registration time — not on the class — so no separate options or builder types are required.
+
+### Persisting metadata on the instance
+
+`AIToolInstance` extends the framework's [extensible entity](extensible-entity), so a source persists its own strongly typed configuration as metadata on the instance:
+
+```csharp
+// When saving (UI/controller):
+instance.Put(new HttpApiRequestToolSettings { BaseUrl = "https://api.example.com", ... });
+
+// When building the tool (source):
+instance.TryGet(out var settings);
+```
+
+### Protecting secrets
+
+Never store credentials in plain text. Protect them with ASP.NET Core Data Protection when saving and unprotect them at invocation time:
+
+```csharp
+var protector = provider.CreateProtector("MySource.Secrets");
+var stored = protector.Protect(userSuppliedSecret); // when saving
+var secret = protector.Unprotect(stored); // inside the tool
+```
+
+The built-in HTTP tool follows this pattern: on edit, a blank secret reuses the previously stored value, and `Unprotect` tolerates config-seeded plain values.
+
+## Registering a Source
+
+Register sources through the `AddToolInstances(...)` feature builder on the AI suite. Call `AddSource(name, configure)` for each source:
+
+```csharp
+.AddToolInstances(toolInstances => toolInstances
+ .AddYesSqlStores()
+ .AddSource(
+ HttpApiRequestToolConstants.SourceName,
+ options =>
+ {
+ options.DisplayName = new LocalizedString(HttpApiRequestToolConstants.SourceName, "HTTP API Request");
+ options.Description = new LocalizedString(HttpApiRequestToolConstants.SourceName, "Calls an external HTTP API.");
+ options.Category = new LocalizedString("Integrations", "Integrations");
+ })
+)
+```
+
+`AddToolInstances(configure, useDefaultRegistry = true)` does the following:
+
+- registers the core services (the catalog handler and the completion-context builder handler);
+- when `useDefaultRegistry` is `true` (the default), registers the built-in `ToolInstanceRegistryProvider`. Pass `useDefaultRegistry: false` to opt out and supply [your own registry provider](#custom-tool-registry-providers) instead;
+- invokes the `configure` delegate so you can register sources and a persistence store on the builder.
+
+The feature does **not** register a persistence store for you. Call `AddYesSqlStores()` or `AddEntityCoreStores()` on the tool-instances builder so the instances users create are saved.
+
+Each `AddSource(name, configure)`:
+
+- registers the source as a **keyed** scoped `IAIToolInstanceSource`, keyed by `name` (the value stored as each instance's `Source`);
+- records the source's display metadata in `AIOptions.ToolInstanceSources[name]` via the `configure` delegate.
+
+| Configure property | Use |
+|---|---|
+| `DisplayName` | Friendly name shown when choosing a source (`LocalizedString`). |
+| `Description` | Explains what the source does (`LocalizedString`). |
+| `Category` | UI grouping (`LocalizedString`). |
+
+If `DisplayName` is left empty it defaults to the registered `name`.
+
+## Built-In HTTP API Request Tool
+
+The framework ships a ready-to-use source that calls arbitrary HTTP APIs. Add it with the `AddHttpApiRequestSource()` convenience, which registers its named `HttpClient` and the source:
+
+```csharp
+.AddToolInstances(toolInstances => toolInstances
+ .AddYesSqlStores()
+ .AddHttpApiRequestSource()
+)
+```
+
+`AddHttpApiRequestSource(configure)` applies default display metadata; pass an optional `configure` delegate to override the display name, description, or category.
+
+Each instance captures:
+
+| Setting | Purpose |
+|---|---|
+| `BaseUrl` | The endpoint the request targets. |
+| `HttpMethod` | `GET`, `POST`, `PUT`, `PATCH`, or `DELETE`. |
+| `AuthenticationType` | `None`, `ApiKey`, `Bearer`, `Basic`, or `OAuth2`. |
+| `ApiKey` / `ApiKeyHeaderName` | API-key auth (header defaults to `X-Api-Key`). |
+| `BearerToken` | Bearer auth (`Authorization: Bearer …`). |
+| `Username` / `Password` | HTTP basic auth, or the resource-owner credentials for the OAuth 2.0 password grant. |
+| `TokenEndpoint` / `ClientId` / `ClientSecret` / `Scope` | OAuth 2.0 settings used to obtain (and refresh) a token automatically. |
+| `DefaultHeaders` | Static headers always added. |
+| `AllowModelProvidedPath` / `…Query` / `…Body` | Which open arguments the model may supply. |
+| `TimeoutSeconds` | Optional per-request timeout. |
+
+Credentials (`ApiKey`, `BearerToken`, `Password`, `ClientSecret`) are data-protected at rest with the `HttpApiRequestToolConstants.DataProtectionPurpose` purpose.
+
+The tool exposes only the open arguments you enable (`path`, `query`, `body`) and returns a JSON envelope:
+
+```json
+{
+ "success": true,
+ "statusCode": 200,
+ "reasonPhrase": "OK",
+ "contentType": "application/json",
+ "truncated": false,
+ "body": "…"
+}
+```
+
+### OAuth 2.0 token caching
+
+When an instance uses `AuthenticationType = OAuth2`, the tool obtains an access token from the configured `TokenEndpoint` the first time it runs and **caches it on the instance** so subsequent calls do not re-authenticate:
+
+1. Before each request the tool reads the cached `HttpApiRequestTokenState` from the instance (via `TryGet`). If a non-expired access token is present, it is reused.
+2. Otherwise it requests a new token. If a refresh token was previously stored, it first tries `grant_type=refresh_token`. Otherwise, when a `Username` is configured it uses the resource-owner `grant_type=password` (sending `Username` / `Password`); when no username is configured it falls back to `grant_type=client_credentials` using `ClientId` / `ClientSecret` / `Scope`.
+3. The returned access token, refresh token, token type, and expiry are data-protected and persisted back onto the instance with `Put(...)`, then saved through the catalog so the cache survives across requests and restarts.
+
+The access and refresh tokens are protected at rest with the same `HttpApiRequestToolConstants.DataProtectionPurpose` purpose as the other credentials. Because the cache lives on the `AIToolInstance` itself, each instance maintains its own independent token, and no token is ever exposed to the model.
+
+:::note
+Token and credential protection depends on a registered `IDataProtectionProvider` (ASP.NET Core apps have one by default). If no provider is resolvable, the source degrades gracefully and stores the values unprotected, so make sure data protection is configured in production.
+:::
+
+## Creating Instances in the Sample Hosts
+
+In the sample hosts, open **AI Tool Instances**, then:
+
+1. Choose a **source** (for example, *HTTP API Request*).
+2. Enter a unique **technical name** and a **description**. The name becomes the function name exposed to the model, and the description is the primary signal the model uses to tell instances apart, so make it specific — e.g. *"Looks up order status from the Orders API."*
+3. Fill in the source-specific settings (endpoint, auth, headers, …).
+4. Save.
+
+Repeat to add **multiple instances from the same source** — each with a different name, settings, and description. For example, one instance calls the Orders API and another calls the Weather API; both use the same `http-api-request` source but appear to the model as two separate functions.
+
+## Attaching Instances to a Profile or Chat Interaction
+
+Instances only reach the model when a profile or chat interaction references them. The sample hosts add a checkbox section on both the AI profile and chat interaction Create/Edit pages; the selected instance **names** are stored via `AIToolInstanceMetadata` — a generic metadata type usable by any resource, including both AI profiles and chat interactions:
+
+```csharp
+// The same call works for an AIProfile or a ChatInteraction.
+resource.Alter(metadata =>
+{
+ metadata.ToolInstanceNames = selectedInstanceNames;
+});
+```
+
+At completion time, `AIToolInstanceCompletionContextBuilderHandler` reads `AIToolInstanceMetadata` from the resource (any extensible entity) and copies those names onto `AICompletionContext.ToolInstanceNames`. The registry provider then looks each one up by name and surfaces it as a distinct tool. Because the handler works off the shared metadata rather than a specific resource type, the same instances are honored across every orchestrator.
+
+## Custom Tool Registry Providers
+
+The default `ToolInstanceRegistryProvider` surfaces every referenced instance to the model unconditionally. Real applications often need extra logic — most commonly a **permission check** so an instance is only exposed to users who are allowed to use it.
+
+Tools reach the model through the aggregated `IToolRegistryProvider` abstraction. Any number of providers can be registered; the registry concatenates the tools returned by each.
+
+For simple per-instance gating you do not have to write a provider from scratch. `ToolInstanceRegistryProvider` is `public` and exposes a `protected virtual ShouldIncludeInstanceAsync(instance, context, cancellationToken)` hook that runs for every referenced instance before it is surfaced. Subclass it and return `false` to hide an instance:
+
+```csharp
+public sealed class PermissionAwareToolInstanceRegistryProvider : ToolInstanceRegistryProvider
+{
+ private readonly IAuthorizationService _authorization;
+
+ public PermissionAwareToolInstanceRegistryProvider(
+ INamedCatalog catalog,
+ IServiceProvider services,
+ IAuthorizationService authorization)
+ : base(catalog, services)
+ {
+ _authorization = authorization;
+ }
+
+ protected override async ValueTask ShouldIncludeInstanceAsync(
+ AIToolInstance instance,
+ AICompletionContext context,
+ CancellationToken cancellationToken)
+ => await IsAuthorizedAsync(instance, cancellationToken);
+}
+```
+
+Register your subclass in place of the default (see [opting out](#opting-out-of-the-default-provider) below).
+
+If you need full control over how entries are built, implement `IToolRegistryProvider` directly instead:
+
+```csharp
+using CrestApps.Core.AI.Models;
+using CrestApps.Core.AI.Tooling;
+using CrestApps.Core.Services;
+
+public sealed class PermissionAwareToolRegistryProvider : IToolRegistryProvider
+{
+ private readonly INamedCatalog _catalog;
+ private readonly IAuthorizationService _authorization;
+ // ... resolve the current user, sources, etc.
+
+ public async Task> GetToolsAsync(
+ AICompletionContext context,
+ CancellationToken cancellationToken = default)
+ {
+ var names = context?.ToolInstanceNames;
+
+ if (names is null || names.Length == 0)
+ {
+ return [];
+ }
+
+ var entries = new List();
+
+ foreach (var name in names)
+ {
+ var instance = await _catalog.FindByNameAsync(name, cancellationToken);
+
+ if (instance is null)
+ {
+ continue;
+ }
+
+ // Only expose instances the current user is permitted to use.
+ if (!await IsAuthorizedAsync(instance, cancellationToken))
+ {
+ continue;
+ }
+
+ entries.Add(/* build a ToolRegistryEntry from the instance's source */);
+ }
+
+ return entries;
+ }
+}
+```
+
+### Opting Out of the Default Provider
+
+Register your provider and **opt out of the default one** so the built-in provider does not also surface ungated tools. Do this by passing `useDefaultRegistry: false` to `AddToolInstances`, then registering your provider explicitly:
+
+```csharp
+// Enable the feature WITHOUT the default registry provider, and register your store and sources.
+.AddToolInstances(toolInstances => toolInstances
+ .AddYesSqlStores()
+ .AddHttpApiRequestSource(),
+ useDefaultRegistry: false)
+
+// ...then register only your gated provider.
+builder.Services.AddScoped();
+```
+
+This is exactly how downstream products layer their own authorization on top of the same abstractions — for example, the Orchard Core CMS integration ships a `LocalToolRegistryProvider` that checks per-instance permissions before exposing each tool.
+
+## Persistence
+
+Tool instances are stored through the `AIToolInstance` catalog. Because the feature does not register a store on its own, register one on the **tool-instances** builder — not on the AI suite — so it matches the provider your app already uses. Register the same provider you use for the rest of the AI suite.
+
+### YesSql
+
+```csharp
+builder.Services.AddCrestAppsCore(crestApps => crestApps
+ .AddAISuite(ai => ai
+ .AddYesSqlStores()
+ .AddToolInstances(toolInstances => toolInstances
+ .AddYesSqlStores()
+ .AddHttpApiRequestSource()
+ )
+ )
+);
+```
+
+`AddYesSqlStores()` registers the catalog and the `AIToolInstanceIndex`. Create the index table during startup with `CreateAIToolInstanceIndexSchemaAsync()`.
+
+### Entity Framework Core
+
+```csharp
+builder.Services.AddCrestAppsCore(crestApps => crestApps
+ .AddAISuite(ai => ai
+ .AddEntityCoreStores()
+ .AddToolInstances(toolInstances => toolInstances
+ .AddEntityCoreStores()
+ .AddHttpApiRequestSource()
+ )
+ )
+ .AddEntityCoreSqliteDataStore("Data Source=App_Data\\crestapps.db")
+);
+```
+
+`AddEntityCoreStores()` registers the source-document catalog for `AIToolInstance`; the schema is created and migrated by the Entity Framework Core data store, so no extra index step is required.
+
+Only the store, the source (`AddSource(...)`), and the management UI are app-specific. The `CrestApps.Core.Mvc.Web` sample uses YesSql and `CrestApps.Core.Blazor.Web` uses Entity Framework Core, so each provider has a working reference host.
+
+## Testing
+
+Because a source produces an ordinary `AIFunction`, you can test it in isolation. Supply services (such as `IHttpClientFactory`) through `AIFunctionArguments.Services`:
+
+```csharp
+var settings = new HttpApiRequestToolSettings
+{
+ BaseUrl = "https://api.example.com/v1",
+ HttpMethod = "POST",
+ AuthenticationType = HttpApiRequestAuthenticationType.Bearer,
+ BearerToken = "secret-token",
+ AllowModelProvidedPath = true,
+};
+
+var function = new HttpApiRequestToolFunction("weather", "Gets the weather.", settings);
+
+var arguments = new AIFunctionArguments
+{
+ ["path"] = "forecast",
+ ["query"] = new Dictionary { ["city"] = "Seattle" },
+ Services = serviceProvider, // provides a stubbed IHttpClientFactory
+};
+
+var result = await function.InvokeAsync(arguments);
+```
+
+To verify that multiple instances surface distinctly, build a service provider with the source registered as a keyed `IAIToolInstanceSource` plus an `INamedCatalog` whose `FindByNameAsync` returns two instances, then assert `ToolInstanceRegistryProvider.GetToolsAsync` (with `context.ToolInstanceNames` set to the two names) returns two entries with distinct `Name` and `Description`.
+
+:::tip
+The sample projects (`CrestApps.Core.Mvc.Web` and `CrestApps.Core.Blazor.Web`) register the `http-api-request` source and include the full management UI. Run the Aspire host and add two HTTP API instances to see them appear to the model as separate functions.
+:::
+
+## Related
+
+- [Custom AI Tools](tools) — tools whose arguments are always supplied by the model.
+- [Extensible Entity](extensible-entity) — how instance settings are persisted.
+- The Orchard Core CMS integration builds on the same abstractions; see the downstream product docs at [orchardcore.crestapps.com](https://orchardcore.crestapps.com).
diff --git a/src/CrestApps.Core.Docs/sidebars.js b/src/CrestApps.Core.Docs/sidebars.js
index 0c124461..b31a6fc7 100644
--- a/src/CrestApps.Core.Docs/sidebars.js
+++ b/src/CrestApps.Core.Docs/sidebars.js
@@ -87,6 +87,7 @@ const sidebars = {
'core/response-handlers',
'core/signalr',
'core/tools',
+ 'core/tool-instances',
'core/use-cases',
],
},
diff --git a/src/Primitives/CrestApps.Core.AI/AIOptions.cs b/src/Primitives/CrestApps.Core.AI/AIOptions.cs
index 8473c08f..e0346f39 100644
--- a/src/Primitives/CrestApps.Core.AI/AIOptions.cs
+++ b/src/Primitives/CrestApps.Core.AI/AIOptions.cs
@@ -13,6 +13,7 @@ public sealed class AIOptions
private readonly Dictionary _deployments = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary _connectionSources = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary _templateSources = new(StringComparer.OrdinalIgnoreCase);
+ private readonly Dictionary _toolInstanceSources = new(StringComparer.OrdinalIgnoreCase);
///
/// Gets the clients.
@@ -69,6 +70,17 @@ public IReadOnlyDictionary TemplateSources
}
}
+ ///
+ /// Gets the registered tool instance sources, keyed by source name.
+ ///
+ public IReadOnlyDictionary ToolInstanceSources
+ {
+ get
+ {
+ return _toolInstanceSources;
+ }
+ }
+
internal void AddClient(string name)
where TClient : class, IAICompletionClient
{
@@ -180,4 +192,31 @@ public void AddTemplateSource(string name, Action configu
_templateSources[name] = entry;
}
+
+ ///
+ /// Registers or updates the display metadata for a tool instance source.
+ ///
+ /// The unique registered name of the tool instance source.
+ /// An optional delegate used to configure the source entry.
+ public void AddToolInstanceSource(string name, Action configure = null)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(name);
+
+ if (!_toolInstanceSources.TryGetValue(name, out var entry))
+ {
+ entry = new AIToolInstanceSourceEntry(name);
+ }
+
+ if (configure != null)
+ {
+ configure(entry);
+ }
+
+ if (string.IsNullOrEmpty(entry.DisplayName))
+ {
+ entry.DisplayName = new LocalizedString(name, name);
+ }
+
+ _toolInstanceSources[name] = entry;
+ }
}
diff --git a/src/Primitives/CrestApps.Core.AI/AIToolInstanceServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI/AIToolInstanceServiceCollectionExtensions.cs
new file mode 100644
index 00000000..8a9dfc57
--- /dev/null
+++ b/src/Primitives/CrestApps.Core.AI/AIToolInstanceServiceCollectionExtensions.cs
@@ -0,0 +1,118 @@
+using CrestApps.Core.AI.Completions;
+using CrestApps.Core.AI.Handlers;
+using CrestApps.Core.AI.Orchestration;
+using CrestApps.Core.AI.Tooling;
+using CrestApps.Core.Builders;
+using CrestApps.Core.Services;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+
+namespace CrestApps.Core.AI;
+
+///
+/// Service-collection extensions for registering the AI tool instance feature: parameterized,
+/// user-configured tools built from developer-defined blueprints.
+///
+public static class AIToolInstanceServiceCollectionExtensions
+{
+ ///
+ /// Registers the core services required to configure and run AI tool instances: the catalog handler
+ /// and the completion-context builder handler. This does not register a tool registry provider;
+ /// call for the built-in provider, or register
+ /// your own to control which instances are surfaced to the model.
+ ///
+ /// The service collection.
+ /// The service collection, for chaining.
+ public static IServiceCollection AddCoreAIToolInstances(this IServiceCollection services)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+
+ services.TryAddScoped>(sp => sp.GetRequiredService>());
+ services.TryAddScoped>();
+
+ services.TryAddEnumerable(ServiceDescriptor.Scoped, AIToolInstanceCatalogHandler>());
+ services.TryAddEnumerable(ServiceDescriptor.Scoped());
+
+ return services;
+ }
+
+ ///
+ /// Registers the built-in that surfaces the configured
+ /// entries named on the completion context to the orchestrator. Registered
+ /// additively, so it is safe to call more than once. To take full control of which instances are
+ /// exposed (for example to enforce per-user permissions), skip this and register your own
+ /// instead — see the documentation for an example.
+ ///
+ /// The service collection.
+ /// The service collection, for chaining.
+ public static IServiceCollection AddDefaultAIToolInstanceRegistryProvider(this IServiceCollection services)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+
+ services.TryAddEnumerable(ServiceDescriptor.Scoped());
+
+ return services;
+ }
+
+ ///
+ /// Registers a developer-defined blueprint so users can create one
+ /// or more configured entries from it and attach them to AI profiles or
+ /// chat interactions. The source's display metadata (display name, description, category) is recorded
+ /// in , while the behavior is registered as a keyed service
+ /// resolved by the source name.
+ ///
+ ///
+ /// Source registration never decides registry policy. Register the built-in registry provider with
+ /// (done for you by AddToolInstances) or
+ /// supply your own to control which instances reach the model.
+ ///
+ /// The source type.
+ /// The service collection.
+ /// The unique registered name of the source. Stored as the source of every instance created from it.
+ /// An optional delegate used to configure the source display metadata.
+ /// The service collection, for chaining.
+ public static IServiceCollection AddAIToolInstanceSource(
+ this IServiceCollection services,
+ string name,
+ Action configure = null)
+ where TSource : class, IAIToolInstanceSource
+ {
+ ArgumentNullException.ThrowIfNull(services);
+ ArgumentException.ThrowIfNullOrEmpty(name);
+
+ services.AddCoreAIToolInstances();
+
+ services.TryAddKeyedScoped(name);
+
+ services.Configure(options =>
+ {
+ options.AddToolInstanceSource(name, configure);
+ });
+
+ return services;
+ }
+
+ ///
+ /// Registers a developer-defined blueprint on the tool instances
+ /// builder. The registry provider is owned by AddToolInstances, so this method never registers
+ /// it; use AddToolInstances(useDefaultRegistry: false) to supply your own
+ /// .
+ ///
+ /// The source type.
+ /// The tool instances builder.
+ /// The unique registered name of the source. Stored as the source of every instance created from it.
+ /// An optional delegate used to configure the source display metadata.
+ /// The tool instances builder, for chaining.
+ public static CrestAppsAIToolInstancesBuilder AddSource(
+ this CrestAppsAIToolInstancesBuilder builder,
+ string name,
+ Action configure = null)
+ where TSource : class, IAIToolInstanceSource
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+
+ builder.Services.AddAIToolInstanceSource(name, configure);
+
+ return builder;
+ }
+}
diff --git a/src/Primitives/CrestApps.Core.AI/AIToolInstanceSourceEntry.cs b/src/Primitives/CrestApps.Core.AI/AIToolInstanceSourceEntry.cs
new file mode 100644
index 00000000..daed63eb
--- /dev/null
+++ b/src/Primitives/CrestApps.Core.AI/AIToolInstanceSourceEntry.cs
@@ -0,0 +1,41 @@
+using Microsoft.Extensions.Localization;
+
+namespace CrestApps.Core.AI;
+
+///
+/// Describes the display metadata for a registered AI tool instance source. Instances of this entry are
+/// stored in keyed by the source name and drive how the
+/// source is presented when users create configured AIToolInstance entries.
+///
+public sealed class AIToolInstanceSourceEntry
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The unique registered name of the tool instance source.
+ public AIToolInstanceSourceEntry(string source)
+ {
+ Source = source;
+ }
+
+ ///
+ /// Gets the unique registered name of the tool instance source. This value is stored as the
+ /// of every instance created from it.
+ ///
+ public string Source { get; }
+
+ ///
+ /// Gets or sets the friendly display name shown when choosing this source to configure a new instance.
+ ///
+ public LocalizedString DisplayName { get; set; }
+
+ ///
+ /// Gets or sets the description that explains what kinds of instances this source produces.
+ ///
+ public LocalizedString Description { get; set; }
+
+ ///
+ /// Gets or sets an optional category used to group sources in the management UI.
+ ///
+ public LocalizedString Category { get; set; }
+}
diff --git a/src/Primitives/CrestApps.Core.AI/Handlers/AIToolInstanceCatalogHandler.cs b/src/Primitives/CrestApps.Core.AI/Handlers/AIToolInstanceCatalogHandler.cs
new file mode 100644
index 00000000..68c93318
--- /dev/null
+++ b/src/Primitives/CrestApps.Core.AI/Handlers/AIToolInstanceCatalogHandler.cs
@@ -0,0 +1,218 @@
+using System.ComponentModel.DataAnnotations;
+using System.Security.Claims;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using CrestApps.Core.AI.Tooling;
+using CrestApps.Core.Handlers;
+using CrestApps.Core.Models;
+using CrestApps.Core.Services;
+using CrestApps.Core.Support;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Localization;
+using Microsoft.Extensions.Options;
+
+namespace CrestApps.Core.AI.Handlers;
+
+///
+/// The authoritative catalog handler for entries. It maps incoming JSON onto
+/// the model, applies create-time defaults, and validates the shared model concerns (unique name, tool
+/// source, and the description used to disambiguate instances). Source-specific settings validation is
+/// intentionally left to the presentation layer.
+///
+internal sealed class AIToolInstanceCatalogHandler : CatalogEntryHandlerBase
+{
+ private readonly IHttpContextAccessor _httpContextAccessor;
+ private readonly TimeProvider _timeProvider;
+ private readonly AIOptions _aiOptions;
+ private readonly IServiceProvider _serviceProvider;
+
+ internal readonly IStringLocalizer S;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The HTTP context accessor used to stamp owner details.
+ /// The time provider used for create/modify timestamps.
+ /// The AI options used to validate the selected tool source.
+ /// The service provider used to resolve the instance catalog for duplicate-name checks.
+ /// The string localizer used for validation messages.
+ public AIToolInstanceCatalogHandler(
+ IHttpContextAccessor httpContextAccessor,
+ TimeProvider timeProvider,
+ IOptions aiOptions,
+ IServiceProvider serviceProvider,
+ IStringLocalizer stringLocalizer)
+ {
+ _httpContextAccessor = httpContextAccessor;
+ _timeProvider = timeProvider;
+ _aiOptions = aiOptions.Value;
+ _serviceProvider = serviceProvider;
+ S = stringLocalizer;
+ }
+
+ ///
+ /// Populates a new instance from the supplied JSON data.
+ ///
+ /// The initializing context.
+ /// The cancellation token.
+ public override Task InitializingAsync(InitializingContext context, CancellationToken cancellationToken = default)
+ => PopulateAsync(context.Model, context.Data, true);
+
+ ///
+ /// Populates an existing instance from the supplied JSON data and updates the modified timestamp.
+ ///
+ /// The updating context.
+ /// The cancellation token.
+ public override async Task UpdatingAsync(UpdatingContext context, CancellationToken cancellationToken = default)
+ {
+ await PopulateAsync(context.Model, context.Data, false);
+
+ context.Model.ModifiedUtc = _timeProvider.GetUtcNow().UtcDateTime;
+ }
+
+ ///
+ /// Applies create-time defaults after initialization.
+ ///
+ /// The initialized context.
+ /// The cancellation token.
+ public override Task InitializedAsync(InitializedContext context, CancellationToken cancellationToken = default)
+ {
+ EnsureCreatedDefaults(context.Model);
+
+ return Task.CompletedTask;
+ }
+
+ ///
+ /// Applies create-time defaults before the instance is persisted.
+ ///
+ /// The creating context.
+ /// The cancellation token.
+ public override Task CreatingAsync(CreatingContext context, CancellationToken cancellationToken = default)
+ {
+ EnsureCreatedDefaults(context.Model);
+
+ return Task.CompletedTask;
+ }
+
+ ///
+ /// Validates the shared model concerns for the instance.
+ ///
+ /// The validating context.
+ /// The cancellation token.
+ public override async Task ValidatingAsync(ValidatingContext context, CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(context.Model.Name))
+ {
+ context.Result.Fail(new ValidationResult(
+ S["A unique name is required."], [nameof(AIToolInstance.Name)]));
+ }
+
+ if (string.IsNullOrWhiteSpace(context.Model.Description))
+ {
+ context.Result.Fail(new ValidationResult(
+ S["A description is required so the AI model can tell instances apart."], [nameof(AIToolInstance.Description)]));
+ }
+
+ if (string.IsNullOrWhiteSpace(context.Model.Source))
+ {
+ context.Result.Fail(new ValidationResult(
+ S["A tool source is required."], [nameof(AIToolInstance.Source)]));
+ }
+ else if (!_aiOptions.ToolInstanceSources.ContainsKey(context.Model.Source))
+ {
+ context.Result.Fail(new ValidationResult(
+ S["The selected tool source is not registered."], [nameof(AIToolInstance.Source)]));
+ }
+
+ await ValidateUniqueNameAsync(context, cancellationToken);
+ }
+
+ private async Task ValidateUniqueNameAsync(ValidatingContext context, CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(context.Model.Name))
+ {
+ return;
+ }
+
+ var catalog = _serviceProvider.GetService>();
+
+ if (catalog is null)
+ {
+ return;
+ }
+
+ var existing = await catalog.GetAllAsync(cancellationToken);
+
+ var duplicate = existing.Any(entry =>
+ !string.Equals(entry.ItemId, context.Model.ItemId, StringComparison.Ordinal) &&
+ string.Equals(entry.Name, context.Model.Name, StringComparison.OrdinalIgnoreCase));
+
+ if (duplicate)
+ {
+ context.Result.Fail(new ValidationResult(
+ S["A tool instance with this name already exists. The name must be unique."], [nameof(AIToolInstance.Name)]));
+ }
+ }
+
+ private void EnsureCreatedDefaults(AIToolInstance instance)
+ {
+ if (instance.CreatedUtc == default)
+ {
+ instance.CreatedUtc = _timeProvider.GetUtcNow().UtcDateTime;
+ }
+
+ var user = _httpContextAccessor.HttpContext?.User;
+
+ if (user == null)
+ {
+ return;
+ }
+
+ instance.OwnerId ??= user.FindFirstValue(ClaimTypes.NameIdentifier);
+ instance.Author ??= user.Identity?.Name;
+ }
+
+ private static Task PopulateAsync(AIToolInstance instance, JsonNode data, bool isNew)
+ {
+ if (data is not JsonObject json)
+ {
+ return Task.CompletedTask;
+ }
+
+ if (isNew)
+ {
+ json.TryUpdateTrimmedStringValue(nameof(AIToolInstance.Source), value => instance.Source = value);
+ json.TryUpdateTrimmedStringValue(nameof(AIToolInstance.Name), value => instance.Name = value);
+ }
+
+ json.TryUpdateTrimmedStringValue(nameof(AIToolInstance.Description), value => instance.Description = value);
+ json.TryUpdateTrimmedStringValue(nameof(AIToolInstance.OwnerId), value => instance.OwnerId = value);
+ json.TryUpdateTrimmedStringValue(nameof(AIToolInstance.Author), value => instance.Author = value);
+
+ if (json.TryGetDateTimeValue(nameof(AIToolInstance.CreatedUtc), out var createdUtc))
+ {
+ instance.CreatedUtc = createdUtc;
+ }
+
+ MergeProperties(instance, json);
+
+ return Task.CompletedTask;
+ }
+
+ private static void MergeProperties(AIToolInstance instance, JsonObject json)
+ {
+ if (!json.TryGetObjectValue(nameof(AIToolInstance.Properties), out var properties) || properties == null)
+ {
+ return;
+ }
+
+ var currentJson = JsonExtensions.FromObject(instance.Properties ?? new Dictionary(), ExtensibleEntityExtensions.JsonSerializerOptions);
+ var existingPropertiesSnapshot = currentJson.Clone();
+
+ AIPropertiesMergeHelper.Merge(currentJson, properties);
+ AIPropertiesMergeHelper.MergeNamedEntries(currentJson, existingPropertiesSnapshot);
+
+ instance.Properties = JsonSerializer.Deserialize>(currentJson, ExtensibleEntityExtensions.JsonSerializerOptions) ?? [];
+ }
+}
diff --git a/src/Primitives/CrestApps.Core.AI/Handlers/AIToolInstanceCompletionContextBuilderHandler.cs b/src/Primitives/CrestApps.Core.AI/Handlers/AIToolInstanceCompletionContextBuilderHandler.cs
new file mode 100644
index 00000000..d2c11e06
--- /dev/null
+++ b/src/Primitives/CrestApps.Core.AI/Handlers/AIToolInstanceCompletionContextBuilderHandler.cs
@@ -0,0 +1,40 @@
+using CrestApps.Core.AI.Completions;
+using CrestApps.Core.AI.Models;
+using CrestApps.Core.AI.Tooling;
+
+namespace CrestApps.Core.AI.Handlers;
+
+///
+/// Populates from the
+/// stored on the resource driving the completion. Because the
+/// metadata lives in the resource's bag, this handler works for
+/// any resource that carries it — for example an or a ChatInteraction — so
+/// configured tool instances are honored across every orchestrator, not just a single feature.
+///
+internal sealed class AIToolInstanceCompletionContextBuilderHandler : IAICompletionContextBuilderHandler
+{
+ ///
+ /// Copies the resource's configured tool instance names onto the completion context.
+ ///
+ /// The building context.
+ public Task BuildingAsync(AICompletionContextBuildingContext context)
+ {
+ if (context.Resource is ExtensibleEntity entity &&
+ entity.TryGet(out var metadata) &&
+ metadata.ToolInstanceNames is { Length: > 0 })
+ {
+ context.Context.ToolInstanceNames = metadata.ToolInstanceNames;
+ }
+
+ return Task.CompletedTask;
+ }
+
+ ///
+ /// No-op once the context has been built.
+ ///
+ /// The built context.
+ public Task BuiltAsync(AICompletionContextBuiltContext context)
+ {
+ return Task.CompletedTask;
+ }
+}
diff --git a/src/Primitives/CrestApps.Core.AI/Orchestration/ToolInstanceRegistryProvider.cs b/src/Primitives/CrestApps.Core.AI/Orchestration/ToolInstanceRegistryProvider.cs
new file mode 100644
index 00000000..dd28c7eb
--- /dev/null
+++ b/src/Primitives/CrestApps.Core.AI/Orchestration/ToolInstanceRegistryProvider.cs
@@ -0,0 +1,150 @@
+using CrestApps.Core.AI.Models;
+using CrestApps.Core.AI.Tooling;
+using CrestApps.Core.Services;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace CrestApps.Core.AI.Orchestration;
+
+///
+/// The default that surfaces the configured
+/// entries referenced by the completion context to the tool registry. Each
+/// instance is materialized into a distinct via its owning
+/// (resolved as a keyed service by ),
+/// so multiple instances built from the same source appear to the AI model as separate functions with
+/// their own descriptions.
+///
+///
+/// Projects that need custom logic (for example, permission checks before exposing an instance) have two
+/// options: register an additional alongside this one, or subclass this
+/// provider and override to filter instances while reusing the
+/// entry-building logic. Register the subclass instead of the default (call
+/// AddToolInstances(useDefaultRegistry: false) and register your provider) to fully control which
+/// instances reach the model.
+///
+public class ToolInstanceRegistryProvider : IToolRegistryProvider
+{
+ private readonly IServiceProvider _serviceProvider;
+ private readonly ILogger _logger;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The service provider used to resolve the instance catalog and sources.
+ /// The logger.
+ public ToolInstanceRegistryProvider(
+ IServiceProvider serviceProvider,
+ ILogger logger)
+ {
+ _serviceProvider = serviceProvider;
+ _logger = logger;
+ }
+
+ ///
+ /// Gets the tool entries for the configured instance names on the completion context.
+ ///
+ /// The completion context that scopes available tools.
+ /// A token to cancel the operation.
+ public async Task> GetToolsAsync(
+ AICompletionContext context,
+ CancellationToken cancellationToken = default)
+ {
+ var instanceNames = context?.ToolInstanceNames;
+
+ if (instanceNames is null || instanceNames.Length == 0)
+ {
+ return [];
+ }
+
+ var catalog = _serviceProvider.GetService>();
+
+ if (catalog is null)
+ {
+ return [];
+ }
+
+ var entries = new List();
+ var seenNames = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var instanceName in instanceNames)
+ {
+ if (string.IsNullOrEmpty(instanceName) || !seenNames.Add(instanceName))
+ {
+ continue;
+ }
+
+ var instance = await catalog.FindByNameAsync(instanceName, cancellationToken);
+
+ if (instance is null || string.IsNullOrEmpty(instance.Source))
+ {
+ continue;
+ }
+
+ if (!await ShouldIncludeInstanceAsync(instance, context, cancellationToken))
+ {
+ continue;
+ }
+
+ var source = _serviceProvider.GetKeyedService(instance.Source);
+
+ if (source is null)
+ {
+ _logger.LogWarning(
+ "AI tool instance '{InstanceName}' references unknown source '{Source}'. Skipping.",
+ instance.Name, instance.Source);
+
+ continue;
+ }
+
+ var functionName = instance.GetFunctionName();
+ var description = !string.IsNullOrWhiteSpace(instance.Description)
+ ? instance.Description
+ : functionName;
+
+ entries.Add(new ToolRegistryEntry
+ {
+ Id = $"tool-instance:{instance.Name}",
+ Name = functionName,
+ Description = description,
+ Source = ToolRegistryEntrySource.Local,
+ SourceId = instance.Source,
+ CreateAsync = _ => ValueTask.FromResult(SafeCreate(source, instance)),
+ });
+ }
+
+ return entries;
+ }
+
+ ///
+ /// Determines whether the resolved tool instance should be surfaced to the model for the current
+ /// completion context. The default implementation includes every instance. Override this to apply
+ /// custom rules such as per-user permission checks while reusing the built-in entry-building logic.
+ ///
+ /// The resolved tool instance.
+ /// The completion context that scopes available tools.
+ /// A token to cancel the operation.
+ /// to include the instance; otherwise .
+ protected virtual ValueTask ShouldIncludeInstanceAsync(
+ AIToolInstance instance,
+ AICompletionContext context,
+ CancellationToken cancellationToken)
+ => ValueTask.FromResult(true);
+
+ private AITool SafeCreate(IAIToolInstanceSource source, AIToolInstance instance)
+ {
+ try
+ {
+ return source.CreateTool(instance);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(
+ ex,
+ "Failed to create tool for instance '{InstanceName}' from source '{Source}'.",
+ instance.Name, instance.Source);
+
+ return null;
+ }
+ }
+}
diff --git a/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs
index d286e5f9..4ba53e01 100644
--- a/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs
+++ b/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs
@@ -411,6 +411,44 @@ public static CrestAppsAISuiteBuilder AddAIMemory(this CrestAppsAISuiteBuilder b
return builder;
}
+ ///
+ /// Adds the AI tool instances feature: parameterized, user-configured tools built from
+ /// developer-defined blueprints. Registers the core services and,
+ /// by default, the built-in registry provider that surfaces configured instances to every orchestrator.
+ /// Use the supplied builder to register one or more sources with
+ /// AddSource<TSource>(name, configure) or the built-in HTTP source with
+ /// AddHttpApiRequestSource(), and to register the persistence stores with
+ /// AddYesSqlStores() or AddEntityCoreStores().
+ ///
+ /// The AI suite builder.
+ /// An optional delegate used to register tool instance sources and stores.
+ ///
+ /// When (the default), registers the built-in
+ /// . Pass to supply your own
+ /// instead — for example to enforce per-user permissions.
+ ///
+ public static CrestAppsAISuiteBuilder AddToolInstances(
+ this CrestAppsAISuiteBuilder builder,
+ Action configure = null,
+ bool useDefaultRegistry = true)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+
+ builder.Services.AddCoreAIToolInstances();
+
+ if (useDefaultRegistry)
+ {
+ builder.Services.AddDefaultAIToolInstanceRegistryProvider();
+ }
+
+ if (configure is not null)
+ {
+ configure(new CrestAppsAIToolInstancesBuilder(builder.Services));
+ }
+
+ return builder;
+ }
+
///
/// Adds the orchestration services including the default progressive tool orchestrator,
/// tool registry, orchestration context builder, and orchestrator resolver.
diff --git a/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestAuthenticationType.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestAuthenticationType.cs
new file mode 100644
index 00000000..ca90ee09
--- /dev/null
+++ b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestAuthenticationType.cs
@@ -0,0 +1,35 @@
+namespace CrestApps.Core.AI.Tooling.Instances;
+
+///
+/// Enumerates the authentication strategies supported by the HTTP API request tool.
+///
+public enum HttpApiRequestAuthenticationType
+{
+ ///
+ /// No authentication is applied to the request.
+ ///
+ None = 0,
+
+ ///
+ /// A static API key is sent in a configurable request header.
+ ///
+ ApiKey = 1,
+
+ ///
+ /// A bearer token is sent in the Authorization header.
+ ///
+ Bearer = 2,
+
+ ///
+ /// HTTP basic authentication (username and password) is applied.
+ ///
+ Basic = 3,
+
+ ///
+ /// OAuth 2.0. When a username is configured the resource owner password grant is used; otherwise the
+ /// client credentials grant is used. In both cases the tool requests an access token from the
+ /// configured token endpoint, caches it on the instance, and reuses a stored refresh token when
+ /// available before requesting a new token.
+ ///
+ OAuth2 = 4,
+}
diff --git a/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestTokenState.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestTokenState.cs
new file mode 100644
index 00000000..0e524993
--- /dev/null
+++ b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestTokenState.cs
@@ -0,0 +1,29 @@
+namespace CrestApps.Core.AI.Tooling.Instances;
+
+///
+/// Cached OAuth 2.0 token state persisted on an so the HTTP tool can reuse a
+/// valid access token across requests and refresh it without re-authenticating on every call. The access
+/// and refresh tokens are data-protected at rest.
+///
+public sealed class HttpApiRequestTokenState
+{
+ ///
+ /// Gets or sets the data-protected access token.
+ ///
+ public string AccessToken { get; set; }
+
+ ///
+ /// Gets or sets the data-protected refresh token, when the provider returned one.
+ ///
+ public string RefreshToken { get; set; }
+
+ ///
+ /// Gets or sets the token type returned by the provider (for example, Bearer).
+ ///
+ public string TokenType { get; set; }
+
+ ///
+ /// Gets or sets the UTC time at which the cached access token expires.
+ ///
+ public DateTimeOffset? ExpiresAtUtc { get; set; }
+}
diff --git a/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolConstants.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolConstants.cs
new file mode 100644
index 00000000..34fe314e
--- /dev/null
+++ b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolConstants.cs
@@ -0,0 +1,22 @@
+namespace CrestApps.Core.AI.Tooling.Instances;
+
+///
+/// Well-known identifiers for the built-in HTTP API request tool instance source.
+///
+public static class HttpApiRequestToolConstants
+{
+ ///
+ /// The registered source name. Instances created from this source store this value as their source.
+ ///
+ public const string SourceName = "http-api-request";
+
+ ///
+ /// The data-protection purpose used to protect and unprotect stored credentials.
+ ///
+ public const string DataProtectionPurpose = "CrestApps.Core.AI.Tooling.HttpApiRequest";
+
+ ///
+ /// The named used to issue requests.
+ ///
+ public const string HttpClientName = "CrestApps.Core.AI.HttpApiRequest";
+}
diff --git a/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolFunction.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolFunction.cs
new file mode 100644
index 00000000..ccc12b1c
--- /dev/null
+++ b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolFunction.cs
@@ -0,0 +1,754 @@
+using System.Net.Http.Headers;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using CrestApps.Core.AI.Tooling;
+using CrestApps.Core.Services;
+using Microsoft.AspNetCore.DataProtection;
+using Microsoft.AspNetCore.WebUtilities;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace CrestApps.Core.AI.Tooling.Instances;
+
+///
+/// An that issues an HTTP request to a user-configured endpoint. The endpoint,
+/// HTTP method, authentication, and static headers are captured up front in
+/// ; the AI model only supplies the open arguments (relative
+/// path, query parameters, and request body) that the settings allow.
+///
+public sealed class HttpApiRequestToolFunction : AIFunction
+{
+ private const int _maxResponseLength = 16000;
+ private static readonly HashSet _bodyMethods = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "POST",
+ "PUT",
+ "PATCH",
+ "DELETE",
+ };
+
+ private readonly string _name;
+ private readonly string _description;
+ private readonly HttpApiRequestToolSettings _settings;
+ private readonly AIToolInstance _instance;
+ private readonly JsonElement _jsonSchema;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The function name exposed to the AI model.
+ /// The description exposed to the AI model.
+ /// The user-provided settings that configure the request.
+ ///
+ /// The configured tool instance the function belongs to. Used to cache OAuth 2.0 token state across
+ /// requests. May be (for example in tests), in which case token caching is
+ /// performed in-memory for the lifetime of the function only.
+ ///
+ public HttpApiRequestToolFunction(
+ string name,
+ string description,
+ HttpApiRequestToolSettings settings,
+ AIToolInstance instance = null)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(name);
+ ArgumentNullException.ThrowIfNull(settings);
+
+ _name = name;
+ _description = string.IsNullOrWhiteSpace(description)
+ ? name
+ : description;
+ _settings = settings;
+ _instance = instance;
+ _jsonSchema = BuildSchema(settings);
+ }
+
+ ///
+ /// Gets the function name exposed to the AI model.
+ ///
+ public override string Name => _name;
+
+ ///
+ /// Gets the description exposed to the AI model.
+ ///
+ public override string Description => _description;
+
+ ///
+ /// Gets the JSON schema describing the open arguments the model may supply.
+ ///
+ public override JsonElement JsonSchema => _jsonSchema;
+
+ ///
+ /// Gets additional metadata applied to the function. Strict mode is disabled because the request
+ /// body is an open-ended object.
+ ///
+ public override IReadOnlyDictionary AdditionalProperties { get; } = new Dictionary
+ {
+ ["Strict"] = false,
+ };
+
+ ///
+ /// Issues the configured HTTP request, merging the model-provided arguments with the stored settings.
+ ///
+ /// The arguments supplied by the AI model.
+ /// The cancellation token.
+ protected override async ValueTask