Optimizely ships a C# SDK for building custom tools that plug directly into Opal (now rebranded as Agent Platform, personified as Mark). If you already build on .NET for Optimizely CMS, this is the fastest way to expose your own logic, your own packages, or your own data to a conversational interface without writing a separate service in Python or Node.
A quick naming note before the code: as of September 1, 2026, Optimizely is rebranding Opal to Agent Platform, with the assistant personified as Mark. The product documentation has already moved to the new names, but the SDK surface still uses Opal terminology: the NuGet package, the OpalTool attributes, and the AddOpalToolService and MapOpalTools methods. This post uses "Opal" for anything code-related and for the registry steps, because that is what the SDK calls it. Menu names in your instance may differ slightly depending on when it was updated.
This post was written against Optimizely.Opal.Tools version 0.7.8. The package is still pre-1.0, so APIs may change between releases.
There is also a package naming trap worth calling out early: OptimizelyOpal.OpalToolsSDK is the old package and is now unlisted on NuGet. The current, actively maintained package is Optimizely.Opal.Tools. Some older documentation pages still link to the old name, so if you land on it, your tutorial will not match what ships today.
Install the SDK
dotnet add package Optimizely.Opal.Tools
This targets .NET 8.0 or later. If your CMS project already runs on .NET 8 or later, it can be added directly. Projects on an older runtime need to be upgraded first.
Register the Tool Service
In your existing Program.cs (the one that already contains your CMS setup), add the Opal tools service and register each tool class individually:
using Optimizely.Opal.Tools;
var builder = WebApplication.CreateBuilder(args);
// ... your existing CMS registrations ...
// Add the Opal Tools service
builder.Services.AddOpalToolService();
// Register each tool class
builder.Services.AddOpalTool<ContentStatusTool>();
var app = builder.Build();
// Protect the tool endpoints (see "Secure the Endpoints" below)
app.UseMiddleware<OpalBearerTokenMiddleware>();
// Map the discovery and execution endpoints
app.MapOpalTools();
app.Run();
app.MapOpalTools() wires up everything Opal needs to find and call your tool:
GET /discovery, listing every registered tool and its parameter schemaPOST /tools/{tool-name}, the execution endpoint Opal calls when a user triggers the toolPOST /tools/callbacks/{ToolClassName}/{method-name}, used for interactive tool callbacksPOST /interactions/execute, used for app-only interactions if you declare any with[OpalInteraction]
You can pass a prefix to MapOpalTools("opal") if you want to namespace these routes alongside existing CMS or API endpoints. Note that this changes the URLs: with that prefix the discovery endpoint becomes /opal/tools/discovery, so use that URL when you register the tool.
Secure the Endpoints
This step is easy to miss and it matters. Registering a tool means exposing /discovery and the execution endpoints on a public HTTPS URL. The token you enter in the registry only tells Opal what to send in the Authorization header. Nothing validates it on your side unless you add that check. Without it, anyone who finds the URL can call your tools.
Here is a small middleware that rejects any request to the Opal routes unless it carries the expected bearer token. The token comes from configuration (user secrets locally, Azure Key Vault or an app setting in production), never from source code:
using System.Security.Cryptography;
using System.Text;
public class OpalBearerTokenMiddleware
{
private static readonly string[] ProtectedPrefixes = { "/discovery", "/tools", "/interactions" };
private readonly RequestDelegate _next;
private readonly byte[] _expectedToken;
public OpalBearerTokenMiddleware(RequestDelegate next, IConfiguration configuration)
{
_next = next;
var token = configuration["Opal:BearerToken"];
if (string.IsNullOrWhiteSpace(token) || token.Length < 32)
{
// Fail at startup rather than silently running unprotected
throw new InvalidOperationException("Opal:BearerToken must be configured with at least 32 characters.");
}
_expectedToken = Encoding.UTF8.GetBytes(token);
}
public async Task InvokeAsync(HttpContext context)
{
if (ProtectedPrefixes.Any(p => context.Request.Path.StartsWithSegments(p)))
{
var header = context.Request.Headers.Authorization.ToString();
const string scheme = "Bearer ";
var provided = header.StartsWith(scheme, StringComparison.OrdinalIgnoreCase)
? Encoding.UTF8.GetBytes(header[scheme.Length..].Trim())
: Array.Empty<byte>();
// Constant-time comparison to avoid leaking the token through timing
if (!CryptographicOperations.FixedTimeEquals(provided, _expectedToken))
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return;
}
}
await _next(context);
}
}
If you used a prefix such as MapOpalTools("opal"), add that prefix to ProtectedPrefixes. Put the same token in the registry's Bearer Token field when you register the tool. Generate it with a cryptographically secure random generator and rotate it if it is ever exposed.
One more thing to know: Optimizely's documentation currently notes that Opti ID is the only provider supported for tool authentication, while the registry also has a bearer token field. Check the current "Create custom tools" page for the latest guidance, and move to Opti ID as soon as it suits your setup.
Build Your First Tool
A tool is just a class with a method decorated with [OpalTool]. Here is a practical example for anyone running Optimizely CMS: a tool that counts how many pages are currently sitting in draft under a given root, something editors ask about constantly and usually have to check manually in the CMS UI.
using EPiServer;
using EPiServer.Core;
using EPiServer.DataAbstraction;
using EPiServer.Web;
using Optimizely.Opal.Tools;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
public class DraftPageCountParameters
{
[Description("Content reference ID of the root page to scan, for example 5 or 5_123. Defaults to the site start page if omitted.")]
[StringLength(20)]
[RegularExpression(@"^\d+(_\d+)?$", ErrorMessage = "RootContentLink must look like 5 or 5_123.")]
public string? RootContentLink { get; set; }
}
public class ContentStatusTool
{
private readonly IContentLoader _contentLoader;
private readonly IContentVersionRepository _versionRepository;
public ContentStatusTool(IContentLoader contentLoader, IContentVersionRepository versionRepository)
{
_contentLoader = contentLoader;
_versionRepository = versionRepository;
}
[OpalTool(Name = "count-draft-pages")]
[Description("Counts how many pages currently have an unpublished draft version under a given root page in Optimizely CMS")]
public object CountDraftPages(DraftPageCountParameters parameters)
{
// Parameters come from an LLM, so treat them as untrusted input
ContentReference root = ContentReference.StartPage;
if (!string.IsNullOrEmpty(parameters.RootContentLink)
&& !ContentReference.TryParse(parameters.RootContentLink, out root))
{
return new { error = "RootContentLink is not a valid content reference." };
}
if (!_contentLoader.TryGet<PageData>(root, out _))
{
return new { error = "The root page could not be found." };
}
var descendants = _contentLoader.GetDescendents(root);
// IContentLoader returns the published version, so look up the latest
// common draft for each page to find pages with unpublished changes.
var draftCount = _contentLoader
.GetItems(descendants, LanguageSelector.MasterLanguage())
.OfType<PageData>()
.Count(p => _versionRepository.LoadCommonDraft(p.ContentLink, p.LanguageBranch)?.Status == VersionStatus.CheckedOut);
return new { draftCount, scannedRoot = root.ToString() };
}
}
Register it alongside your other tools:
builder.Services.AddOpalTool<ContentStatusTool>();
That is the whole tool. No manual JSON schema, no separate discovery file to maintain. The [Description] attributes on the parameter class and the method tell Opal what the tool does and what it expects, and the SDK generates the schema for you from the C# types. The validation attributes keep the input to a strict allow-list format.
Three things to keep in mind with this example:
- It runs as the service, not as the Opal user.
IContentLoaderdoes not apply per-user access rights, so the tool can count pages the calling user may not be able to see. That is acceptable for a count of drafts on a site your whole editorial team works on. For anything that returns page names, URLs or content, filter results by the caller's access rights or limit the tool to specific roots. - It is not cheap on large sites. Counting loads every descendant page. For a big tree, cache the result briefly or scope the root tightly.
- Test it against a real CMS instance. "Draft" depends on your content workflow (for example scheduled or awaiting approval pages), so check the status values match what your editors call a draft.
Dependency Injection Works As Expected
Tool classes are resolved through the standard ASP.NET Core container, so constructor injection works exactly as it does in a controller. The example above injects IContentLoader and IContentVersionRepository directly. If your tool needs configuration, a logger, or a service from one of your own NuGet packages, just add it to the constructor.
The following sketch uses a hypothetical IVisitorGroupUsageService and parameter class from your own package, to show the pattern:
public class VisitorGroupUsageParameters
{
[Required]
[StringLength(100)]
[Description("Name of the Visitor Group to look up")]
public string VisitorGroupName { get; set; } = string.Empty;
}
public class VisitorGroupUsageTool
{
private readonly IVisitorGroupUsageService _usageService;
private readonly ILogger<VisitorGroupUsageTool> _logger;
public VisitorGroupUsageTool(IVisitorGroupUsageService usageService, ILogger<VisitorGroupUsageTool> logger)
{
_usageService = usageService;
_logger = logger;
}
[OpalTool(Name = "visitor-group-usage")]
[Description("Returns which pages and blocks reference a given Visitor Group")]
public async Task<object> GetUsage(VisitorGroupUsageParameters parameters)
{
_logger.LogInformation("Checking usage for visitor group {GroupName}", parameters.VisitorGroupName);
var usage = await _usageService.GetUsageAsync(parameters.VisitorGroupName);
return new { parameters.VisitorGroupName, usage };
}
}
This is the pattern worth noticing if you already maintain Optimizely add-ons: any existing service in your package can be wrapped in an Opal tool with a handful of lines, without touching the package's existing logic at all. Remember to register this class with AddOpalTool<VisitorGroupUsageTool>() too.
Authentication to Third-Party Providers
If a tool needs to call out to a third-party provider on behalf of the user, decorate it with [OpalAuthorization] and accept an OpalToolContext parameter. This is an illustrative sketch, and the calendar events returned here are placeholders:
[OpalTool(Name = "get-calendar")]
[Description("Gets the user's calendar events")]
[OpalAuthorization("google", "calendar")]
public object GetCalendar(CalendarParameters parameters, OpalToolContext context)
{
var authData = context.AuthorizationData;
var credentials = authData?.Credentials;
// use credentials to call the provider's API
// never log or return the credentials themselves
return new { events = new[] { "Event 1", "Event 2" } };
}
This is separate from protecting your own endpoints. For tools that only talk to your own CMS instance, like the draft page counter above, you do not need [OpalAuthorization], but you do need the bearer token check from the previous section.
Register the Tool in Opal
- Deploy your project so the discovery endpoint is reachable over HTTPS, and make sure the bearer token is set in the deployed environment's configuration.
- In your Opal instance, go to Connectors > Registries.
- Click Add tool registry and enter a registry name.
- Enter your discovery endpoint URL in the Discovery URL field, for example
https://yoursite.com/discovery(orhttps://yoursite.com/opal/tools/discoveryif you used a prefix). - Enter the same token in the Bearer Token field. The field is labelled optional in the UI, but your endpoints should not be left open, so treat it as required.
- Click Save.
Once saved, your tool appears under Tools > Tools, where you can toggle it active or inactive, and enable or disable it in chat.
Important
- Do not install
OptimizelyOpal.OpalToolsSDK. It is unlisted and no longer maintained. UseOptimizely.Opal.Tools. - Never leave the tool endpoints unauthenticated, and never commit the bearer token to source control.
- Treat every tool parameter as untrusted input. The model decides what to send, and it can be influenced by whatever content it has read. Validate in your own code rather than relying on the description text.
- Keep tool method names short and specific. The LLM selects which tool to call based on the name and description, so vague names like
get-datalead to the wrong tool being picked in a busy registry. - Avoid putting secrets or connection strings directly in a tool class. Use configuration and the standard ASP.NET Core options pattern, the same as you would for any other service.
- Tool names must be unique within a registry. If you are wrapping several packages, keep related tools together and name them clearly so they do not collide.