Adding your own capability to AI Chat means writing a ChatExtension. It's the same API the built-in extensions use - routes, tools, filters, providers, UI and background workers all register through the ExtensionContext passed to Install.
services.AddPlugin(new ChatFeature {
Extensions = {
new BookingToolsExtension(),
},
});
A minimal extension​
public class BookingToolsExtension() : ChatExtension("booking_tools")
{
IDbConnectionFactory dbFactory = null!;
public override void Install(ExtensionContext ctx)
{
// Install() runs after the App's services are available
dbFactory = ctx.Feature.Services.GetRequiredService<IDbConnectionFactory>();
// routes are prefixed with /ext/booking_tools automatically
ctx.AddGet("summary", async req => await GetSummaryAsync(req));
// register a tool the Chat UI's tool loop can call
ctx.RegisterTool<BookingSummaryCommand>("bookings");
}
}
The constructor argument is the extension name, which becomes:
- its
/ext/{name}route prefix - the default group for tools it registers
- the key used in
DisableExtensions - the folder its static UI assets are served from
Any public properties you add are its configuration, set where the extension is added and readable from Install - see Configuring your own extensions.
Registering tools​
From a ServiceStack Command​
The most concise option: the Command's request type generates the tool's JSON Schema, and calls run through CommandsFeature so they're executed, validated, retried and logged like any other command.
[Description("Summarise this hotel's room bookings: how many, what they're worth, "
+ "and a breakdown by room type")]
[Tool("the user asks about occupancy or booking revenue",
Name = "booking_summary", Safety = ToolSafety.ReadOnly)]
public class BookingSummaryCommand(IDbConnectionFactory dbFactory)
: AsyncCommandWithResult<BookingSummary, BookingSummaryResponse>
{
protected override async Task<BookingSummaryResponse> RunAsync(
BookingSummary request, CancellationToken token)
{
using var db = await dbFactory.OpenAsync(token: token);
// ...
}
}
ctx.RegisterTool<BookingSummaryCommand>("bookings");
ctx.RegisterTool(typeof(BookingSummaryCommand), "bookings"); // runtime type
Document the request type for the Model, not for a developer - [Description] on each property becomes that argument's schema description, and an enum property becomes its allowed values:
public class BookingSummary
{
[Description("Only count bookings starting on or after this date")]
public DateTime? From { get; set; }
[Description("Only count bookings of this room type")]
public RoomType? Type { get; set; }
}
From a raw function definition​
When you need full control of the OpenAI function schema:
ctx.RegisterTool(new JsonObject {
["type"] = "function",
["function"] = new JsonObject {
["name"] = "booking_summary",
["description"] = "Summarise this hotel's room bookings: how many, what they're "
+ "worth, and a breakdown by room type. Use for questions about occupancy or "
+ "revenue instead of querying bookings one by one.",
["parameters"] = new JsonObject {
["type"] = "object",
["properties"] = new JsonObject {
["from"] = new JsonObject {
["type"] = "string",
["description"] = "Only count bookings starting on or after this "
+ "ISO-8601 date, e.g. '2026-01-01'. Omit for all bookings.",
},
["room_type"] = new JsonObject {
["type"] = "string",
["enum"] = new JsonArray("Single", "Double", "Queen", "Twin", "Suite"),
["description"] = "Only count bookings of this room type",
},
},
},
},
}, BookingSummaryAsync, group: "bookings", safety: ToolSafety.ReadOnly);
Tool handlers receive the arguments the Model chose plus the context it's running in:
async Task<object?> BookingSummaryAsync(JsonObject args, ChatContext context)
{
// context.User is the signed-in user this tool acts for, and context.Request the
// request it's running under - pass it to a Service Gateway to call APIs as that user
using var db = await dbFactory.OpenAsync();
var q = db.From<Booking>().Where(x => x.Cancelled == null || x.Cancelled == false);
if (DateTime.TryParse(args.GetString("from"), out var from))
q.And(x => x.BookingStartDate >= from);
var bookings = await db.SelectAsync(q);
// keep results small and pre-digested: an Agent pays for every row in its context window
return new {
Total = bookings.Count,
TotalCost = bookings.Sum(x => x.Cost),
Bookings = bookings.Map(x => new { x.Id, x.Name, x.RoomNumber, x.Cost }),
};
}
Return a string to pass it through verbatim, or any object to send the Model its JSON.
RegisterTool also accepts an approvalHandler for tools that should pause for user confirmation, and an outputSchema describing the result.
Routes​
Route paths are automatically prefixed with /ext/{name}; a leading / escapes the prefix:
ctx.AddGet("summary", handler); // /chat/ext/booking_tools/summary
ctx.AddPost("book", handler); // /chat/ext/booking_tools/book
ctx.AddGet("/transcribe", handler); // /chat/transcribe
ctx.AddGet("public", handler, allowAnon: true);
AddGet, AddPost, AddPut, AddDelete and AddPatch are available. Routes require an authenticated request satisfying RequireAuth + RequiredRole unless they opt out with allowAnon - which should only be used for what the UI genuinely needs before sign-in.
Handlers receive a ChatRequestContext:
async Task<object?> GetSummaryAsync(ChatRequestContext req)
{
var user = req.UserName; // signed-in user
var id = req.GetPathParam("id"); // route parameter
var body = await req.GetJsonBodyAsync(); // POST body
var file = req.Request.Files.FirstOrDefault(); // multipart upload
return new { ok = true };
}
Return a JsonObject, any serializable object, or a ChatResult for explicit status codes.
Pipeline filters​
Filters let an extension observe or modify a completion as it runs:
public override void Install(ExtensionContext ctx)
{
ctx.RegisterChatRequestFilter(async (chat, context) => {
// mutate the outgoing OpenAI request, e.g. inject a system prompt
});
ctx.RegisterChatResponseFilter(async (response, context) => {
// observe the completed response
});
ctx.RegisterChatErrorFilter(async (ex, context) => {
Log.LogError(ex, "Chat failed for {User}", context.User);
});
}
| Filter | Signature | Fires |
|---|---|---|
RegisterChatRequestFilter |
(JsonObject chat, ChatContext) |
Before the provider request |
RegisterChatToolFilter |
(JsonObject toolCall, ChatContext) |
Around each tool call |
RegisterChatApprovalFilter |
(JsonObject, ChatContext) |
When a call needs approval |
RegisterChatStatusFilter |
(string status, ChatContext) |
On status updates |
RegisterChatResponseFilter |
(JsonObject response, ChatContext) |
After a completion |
RegisterChatErrorFilter |
(Exception, ChatContext) |
On failure |
RegisterCacheSavedFilter |
(CacheSavedContext) |
When bytes are written to the cache |
RegisterSetupUserHandler |
(IRequest) |
First request from each user |
RegisterShutdownHandler |
() |
AppHost disposal |
The gallery extension is a good example: it records every cache write as a ChatMedia row using nothing but a RegisterCacheSavedFilter.
Files, prefs and the cache​
All paths live under App_Data/chat:
ctx.GetHomePath("my-config.json"); // App_Data/chat/my-config.json
ctx.GetUserPath(user); // App_Data/chat/user/{user}
ctx.GetCachePath(); // App_Data/chat/cache
var prefs = ctx.GetUserPrefs(user);
ctx.SetUserPref("myFeature.enabled", true, user);
var value = ctx.GetUserPref("myFeature.enabled", user);
// write bytes to the content-addressed cache, firing the cache_saved filters
var info = ctx.SaveToCache(bytes, "chart.png", "image/png", user);
Auth helpers​
ctx.IsAuthEnabled; // whether RequireAuth is on
ctx.GetUserName(request); // null when anonymous
ctx.AssertUserName(request); // throws when anonymous
ctx.IsAdmin(request); // true for everyone when auth is disabled
ctx.CheckAuth(request); // (IsAuthenticated, Session)
Calling the Model from an extension​
var response = await ctx.ChatCompletionAsync(chat, context);
Plus helpers for working with the OpenAI message shape:
ctx.LastUserPrompt(chat);
ctx.ChatToSystemPrompt(chat);
ctx.ChatToAspectRatio(chat);
ctx.ChatResponseToMessage(response);
ctx.NextLoadingMessage();
Cross-extension APIs​
Extensions publish pluggable interfaces so others don't need a direct reference:
ctx.Threads; // IThreadApi - provided by the app extension
ctx.Media; // IMediaApi - provided by the gallery extension
ctx.Projects; // IProjectsApi - provided by the projects extension
Assigning to them installs your own implementation, which is exactly how GalleryExtension registers itself as IMediaApi.
Adding UI​
Two options:
1. Embedded resources. Ship a chat/ext/{name}/ folder of embedded files. AI Chat serves them at /{RoutePrefix}/ext/{name}/{path} automatically, and if the folder contains an index.mjs it's registered in /ext so the Chat UI imports it on load.
2. The custom folder. chat/custom/** is served at /{RoutePrefix}/custom/** and is never touched by an upstream UI sync - the intended home for an App's own Chat UI code.
ctx.RegisterUiExtension(); // imports /ext/{name}/index.mjs
ctx.RegisterUiExtension("/custom/index.mjs"); // leading '/' escapes the prefix
ctx.AddImportMaps(new() {
["my-lib"] = "/custom/lib/my-lib.mjs",
});
ctx.AddIndexHeader("""<link rel="stylesheet" href="/chat/custom/app.css">""");
ctx.AddIndexFooter("<!-- analytics -->");
Because the UI is assembled from registered Vue components, registering a component under an existing name replaces that building block - which is how an App rebrands or specializes the experience without forking.
Disabling from inside Install​
Set ctx.Disabled when a prerequisite is missing. The extension is skipped and nothing it registered is kept:
public override void Install(ExtensionContext ctx)
{
if (ProcessUtils.FindExePath("mytool") == null)
{
Log.LogInformation("mytool not found, extension disabled");
ctx.Disabled = true;
return;
}
// ...
}
Async initialization​
LoadAsync runs after every extension has installed, concurrently:
public override async Task LoadAsync(ExtensionContext ctx, CancellationToken token = default)
{
await warmupCache.LoadAsync(token);
}
Exposing your tools over MCP​
Once registered, a tool group can be published to external AI Assistants by naming it:
services.AddPlugin(new ChatFeature {
Extensions = { new BookingToolsExtension() },
Mcp = {
ToolGroups = ["api_tools", "bookings"],
},
});
See MCP Server.