<?xml version="1.0" encoding="utf-8"?><rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/"><channel><language>en</language><title>Blog posts by Mark Stott</title> <link>https://world.optimizely.com/blogs/mark-stott/</link><description></description><ttl>60</ttl><generator>Optimizely World</generator><item> <title>Using Caching and Async to Improve Performance</title>            <link>https://world.optimizely.com/blogs/mark-stott/dates/2026/4/using-caching-and-async-to-improve-performance/</link>            <description>&lt;p&gt;Almost every CMS served website has some concept of dynamic content resolution. A means to serve up related or latest content links while minimising the burden on the content editors. This style of functionality is commonly delivered through functionality such as Search &amp;amp; Navigation, Content Graph, Algolia or some other indexing functionality. Another common use of dynamic content resolution are mega menus where the structure is based on the IA of the site and uses some form of recursive logic. For an&amp;nbsp;&lt;strong&gt;In-Process&lt;/strong&gt;&amp;nbsp;or&amp;nbsp;&lt;strong&gt;Traditional CMS&lt;/strong&gt;, these can have a significant impact on the server response time for a page.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p style=&quot;padding-left: 20pt;&quot;&gt;&#128214;&lt;strong&gt;Note:&lt;/strong&gt;&amp;nbsp;The server response time is the time between the server receiving a request and delivering it&amp;rsquo;s response, disregarding any transmission time between the server and the client.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;In the following sections I&amp;rsquo;m going to be talking about techniques I have used in practice on a high traffic website that was observed to be having performance issues such as Thread Pool Starvation.&lt;/p&gt;
&lt;h2&gt;What is Thread Pool Starvation?&lt;/h2&gt;
&lt;p&gt;Think of your application like a restaurant with a fixed number of chefs. Each chef (thread) can handle one order (request) at a time. Most of the time, orders are completed quickly and everything flows. But sometimes a chef needs extra ingredients, so they stop and wait.&lt;/p&gt;
&lt;p&gt;Now imagine multiple chefs all waiting at the same time. They&amp;rsquo;re not cooking, but they&amp;rsquo;re also not free to take new orders. If enough chefs are waiting, no new meals get made. Orders pile up. The kitchen slows to a crawl.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s Thread Pool Starvation. In a .NET application, threads handle incoming requests. When those threads are blocked, typically waiting on I/O like database calls or search queries, they can&amp;rsquo;t be reused. Under load, this leads to:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Requests queuing up&lt;/li&gt;
&lt;li&gt;Slower response times&lt;/li&gt;
&lt;li&gt;An unresponsive application&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Importantly, this isn&amp;rsquo;t a resource problem. CPU and memory might be fine, but your threads are tied up doing nothing. This is why synchronous calls are dangerous at scale. Each one blocks a thread until it completes. Asynchronous code avoids this by freeing the thread while waiting. Instead of standing idle, the &amp;ldquo;chef&amp;rdquo; moves on to the next order, keeping the system responsive and throughput high.&lt;/p&gt;
&lt;h2&gt;How does asynchronous code improve performance?&lt;/h2&gt;
&lt;p&gt;The number one culprit in Thread Pool Starvation was the use of the legacy synchronous methods for Search &amp;amp; Navigation. The asynchronous replacement for GetContentResult was introduced in EPiServer.Find 16.3.0 as GetContentResultAsync.&lt;/p&gt;
&lt;p&gt;In my case, I had a listing block that used Optimizely Search &amp;amp; Navigation to return the 4 most recent news articles. On the surface, this seems like a lightweight query, but under load, even small synchronous calls add up quickly and contribute to thread pool starvation. The obvious first step was to switch from&amp;nbsp;&lt;strong&gt;GetContentResult&lt;/strong&gt;&amp;nbsp;to&amp;nbsp;&lt;strong&gt;GetContentResultAsync&lt;/strong&gt;. However, simply calling the async method isn&amp;rsquo;t enough to realise the benefits. If you block on it further up the call stack (for example with .Result or .GetAwaiter().GetResult()), you&amp;rsquo;re back to the same problem, tying up threads while waiting.&lt;/p&gt;
&lt;p&gt;To make this change effective, the async pattern needs to flow all the way back to the rendering layer. In Optimizely CMS, that meant pushing the change up to the ViewComponent level.&lt;/p&gt;
&lt;p&gt;Instead of using:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;public class ListingBlockComponent : BlockComponent&amp;lt;ListingBlock&amp;gt;
{
    protected override IViewComponentResult InvokeComponent(ListingBlock currentContent)
    {
        var results = await _searchClient
            .Search&amp;lt;ArticlePage&amp;gt;()
            .OrderByDescending(x =&amp;gt; x.StartPublish)
            .Take(4)
            .GetContentResult();

        return View(results);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The component was refactored to:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;public class ListingBlockComponent : AsyncBlockComponent&amp;lt;ListingBlock&amp;gt;
{
    protected override async Task&amp;lt;IViewComponentResult&amp;gt; InvokeComponentAsync(ListingBlock currentContent)
    {
        var results = await _searchClient
            .Search&amp;lt;ArticlePage&amp;gt;()
            .OrderByDescending(x =&amp;gt; x.StartPublish)
            .Take(4)
            .GetContentResultAsync();

        return View(results);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There are two key changes here:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Inheriting from AsyncBlockComponent&amp;lt;T&amp;gt; instead of BlockComponent&amp;lt;T&amp;gt;&lt;/li&gt;
&lt;li&gt;Switching to InvokeComponentAsync and fully embracing async/await&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This allows the thread handling the request to be released while waiting for the search query to complete. Once the results are ready, execution resumes and the response is rendered.&lt;/p&gt;
&lt;p&gt;The important point is that async isn&amp;rsquo;t just about swapping one method for another, it&amp;rsquo;s about ensuring the entire execution path is non-blocking. By pushing async all the way up to the ViewComponent, the application can handle significantly more concurrent requests without exhausting the thread pool.&lt;/p&gt;
&lt;h2&gt;How does adding caching improve performance?&lt;/h2&gt;
&lt;p&gt;Once asynchronous code removes unnecessary thread blocking, the next step is reducing how often you need to do the work in the first place. That&amp;rsquo;s where caching comes in.&lt;/p&gt;
&lt;p&gt;In the case of a listing block, building the model can involve multiple operations, querying an index, resolving content references, applying enrichment such as category information, and shaping the final view model. While Search &amp;amp; Navigation code includes a level of caching already, the default duration is short and the cache only affects the results of the query and not the actions you perform on that data. Even when each individual operation is relatively fast, the combined cost adds up quickly under load. By caching the final model, you avoid repeating all of that work on every request.&lt;/p&gt;
&lt;p&gt;The following code is an evolution of the previous example of a listing block, but this time following a standard pattern for adding caching into View Components which can be described as such:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Attempt to retrieve the data from the cache&lt;/li&gt;
&lt;li&gt;Build model if it does not exist in the cache&lt;/li&gt;
&lt;li&gt;Render the model&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;public class ListingBlockComponent(
  ISynchronizedObjectInstanceCache cache,
  IPageRouteHelper pageRouteHelper) : AsyncBlockComponent&amp;lt;ListingBlock&amp;gt;
{
    protected override async Task&amp;lt;IViewComponentResult&amp;gt; InvokeComponentAsync(ListingBlock currentContent)
    {
        var cacheKey = GetCacheKey(currentContent);
        if (!cache.TryGet&amp;lt;ListingBlockModel&amp;gt;(cacheKey, ReadStrategy.Immediate, out model))
        {
            model = BuildModel(currentContent);

            // The use of a master key here allows us to remove all our custom caches based on a constant known value.
            var evictionPolicy = new CacheEvictionPolicy(
              AppConstants.CacheDuration,
              CacheTimeoutType.Absolute, 
              [], [AppConstants.MasterKey]);
            cache.Insert(cacheKey, model, evictionPolicy)
        }

        return View(results);
    }

    private async ListingBlockModel BuildModel(ListingBlock currentContent)
    {
        // Model building logic (e.g. async Search &amp;amp; Navigation call)
    }

    private string GetCacheKey(ListingBlock currentContent)
    {
        var contentId = pageRouteHelper.ContentLink?.ID ?? 0;
        var language = pageRouteHelper.LanguageID;
        var blockId = (currentContent as IContent)?.ContentLink?.ID ?? contentId;

        // Create a new cache key unique to the block, page and language
        // e.g. MB:123:456:EN
        return $&quot;MB:{contentId}:{blockId}:{language}&quot;;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p style=&quot;padding-left: 20pt;&quot;&gt;&#128161;&lt;strong&gt;Top Tip:&lt;/strong&gt;&amp;nbsp;Short cache key names can result in faster cache lookups, but make sure you maintain the right level of uniqueness for your own usage.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;Why Asynchronous Code and Caching work well together&lt;/h2&gt;
&lt;p&gt;This approach is effective because it combines both strategies:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Async ensures threads aren&amp;rsquo;t blocked while building the model&lt;/li&gt;
&lt;li&gt;Caching ensures the model rarely needs to be rebuilt at all&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The result is a dramatic reduction in both thread usage and external calls, exactly what you need to avoid thread pool starvation and improve response times under load.&lt;/p&gt;
&lt;p&gt;In my scenario, caching wasn&amp;rsquo;t just applied in one place, it was introduced across several high-impact components that were rebuilt on every request. These included:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A mega menu driven by content hierarchy&lt;/li&gt;
&lt;li&gt;Listing and Search pages&lt;/li&gt;
&lt;li&gt;Listing Blocks (as described above)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The mega menu was the biggest offender. Each request triggered a large number of content lookups as the hierarchy was recursively resolved using IContentLoader. By caching the final menu model, those lookups were eliminated, reducing the number of cache and data operations by 500+ per request.&lt;/p&gt;
&lt;p&gt;For the Listing and Search pages, the controllers were updated to use asynchronous controllers and to use the asynchronous Search and Navigation queries.&lt;/p&gt;
&lt;p&gt;For listing blocks, caching removed repeated calls to the index and avoided rebuilding the model entirely. This ensured that common queries like &amp;ldquo;latest articles&amp;rdquo; were served near instantly while remaining unique to the page and language.&lt;/p&gt;
&lt;h2&gt;The Result&lt;/h2&gt;
&lt;p&gt;The combined use of caching and asynchronous coding resulted in a significant reduction in content lookups, index calls and less work per request. The Thread Pool Starvation issue was entirely eliminated and the average server response time for content pages was reduced to 0.045s per page over a 7 day period. Ultimately leading to a more stable environment that performed better with no loss of functionality.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;I am an OMVP and the author and maintainer of&amp;nbsp;&lt;a title=&quot;Stott Security&quot; href=&quot;https://github.com/GeekInTheNorth/Stott.Security.Optimizely&quot;&gt;Stott Security&lt;/a&gt;&amp;nbsp;and&amp;nbsp;&lt;a title=&quot;Stott Robots Handler&quot; href=&quot;https://github.com/GeekInTheNorth/Stott.Optimizely.RobotsHandler&quot;&gt;Stott Robots Handler&lt;/a&gt; for Optimizely CMS 12 &amp;amp; 13. You can find all of my content collated on&amp;nbsp;&lt;a title=&quot;The blog website for Mark Stott.&quot; href=&quot;https://www.stott.pro/&quot;&gt;https://www.stott.pro/&lt;/a&gt;&amp;nbsp;&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/mark-stott/dates/2026/4/using-caching-and-async-to-improve-performance/</guid>            <pubDate>Tue, 14 Apr 2026 06:51:16 GMT</pubDate>           <category>Blog post</category></item><item> <title>Stott Security v7 for Optimizely CMS 13</title>            <link>https://world.optimizely.com/blogs/mark-stott/dates/2026/4/stott-security-v7-for-optimizely-cms-13/</link>            <description>&lt;p&gt;Stott Security version 7 is now available for&amp;nbsp;&lt;strong&gt;Optimizely PaaS CMS 13&lt;/strong&gt;, introducing support for configuring security headers by both&amp;nbsp;&lt;strong&gt;application&lt;/strong&gt;&amp;nbsp;and&amp;nbsp;&lt;strong&gt;host&lt;/strong&gt;. This aligns the add-on more closely with how Optimizely CMS structures content and delivery, enabling far more precise and maintainable security policies.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://www.stott.pro/assets/stott-security-7a.png&quot; alt=&quot;Stott Security Version 7, Content Security Policy Sources tab&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Multiple Application and Host Support&lt;/h2&gt;
&lt;p&gt;In version 5 and below, Stott Security supported a global Content Security Policy with optional page-level extensions. In practice, this often meant combining requirements for both front-end delivery and the CMS editor experience into a single policy, leading to more permissive and larger configurations.&lt;/p&gt;
&lt;p&gt;Version 7 introduces support for Global, Application, and Host-level configuration, allowing the Content Security Policy, Permissions Policy and Response Headers to be scoped more naturally to multi-channel and multi-site platforms.&lt;/p&gt;
&lt;p&gt;To support this, a new Context Switcher has been introduced across the add-on UI. This allows users to quickly switch between Global, Application, and Host contexts when managing headers, making it clear exactly where a configuration is being applied.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://www.stott.pro/assets/stott-security-7b.png&quot; alt=&quot;Context Switcher modal that allows you to select a global, application or host level context.&quot; /&gt;&lt;/p&gt;
&lt;h3&gt;Content Security Policy&lt;/h3&gt;
&lt;p&gt;Content Security Policy management in Stott Security is designed to minimise the need for developer intervention. The Settings and Sandbox sections can be configured globally and then overridden at Application or Host level, allowing different environments such as the frontend and CMS to operate under different modes (for example, enforcing vs report-only).&lt;/p&gt;
&lt;p&gt;The real magic happens around the Sources. A flexible Content Security Policy (CSP) model isn&amp;rsquo;t just about defining what&amp;rsquo;s allowed, it&amp;rsquo;s about where those rules apply. By structuring CSP across multiple levels, you gain the ability to enforce strong defaults while still accommodating the specific needs of different parts of your platform.&lt;/p&gt;
&lt;p&gt;In this model, the sources for the Content Security Policy are applied across four levels of scope and merged to create the final output specific to the page being served. Each level becomes progressively more specific, allowing you to refine policies without weakening your overall security posture.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://www.stott.pro/assets/stott-security-7c.png&quot; alt=&quot;Hierarchial Inheritance of the sources for a Content Security Policy, layering from Global, Application, Host and Page level.&quot; /&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Global (All Applications)&lt;/strong&gt;: Sources configured here define the minimum standard applied everywhere, ensuring unsafe patterns are blocked and trusted sources are consistently enforced.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Application&lt;/strong&gt;: Sources defined here are applied to all hosts within the specific application.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Host&lt;/strong&gt;: Sources defined here apply to a specific website domain. It&amp;rsquo;s at this level that the most specific configuration should be made. If you configure a specific&amp;nbsp;&lt;strong&gt;Edit&lt;/strong&gt;&amp;nbsp;host, then you can have separate Content Security Policies for both the front-end and the CMS.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Page&lt;/strong&gt;: Sources can be defined at this level by designing Content Pages and Experiences that implement IContentSecurityPolicyPage. This allows editors to include an iFrame on a specific page without opening the entire site for that domain.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;So how would this work in practice? In my primary test instance I configured three separate applications, with each application having a&amp;nbsp;&lt;strong&gt;Primary&lt;/strong&gt;&amp;nbsp;host and an&amp;nbsp;&lt;strong&gt;Edit&lt;/strong&gt;&amp;nbsp;host. For my Global level, I implemented the &amp;lsquo;self&amp;rsquo; source across most directives. For my&amp;nbsp;&lt;strong&gt;Edit&lt;/strong&gt;&amp;nbsp;host I added all of the additional permissions required by the CMS backend, such as &amp;lsquo;unsafe-inline&amp;rsquo; and https://*.optimizely.com. For my&amp;nbsp;&lt;strong&gt;Primary&lt;/strong&gt;&amp;nbsp;host, I added required functionality such as nonce attributes. The end result was a much more consise and secure Content Security Policy tailored to specific journeys.&lt;/p&gt;
&lt;h3&gt;Permissions Policy and Response Headers&lt;/h3&gt;
&lt;p&gt;Permissions Policy and Response Headers follow a simpler model. These are configured at Global, Application, or Host level using an override approach where more specific levels replace broader ones. A configuration at Application level replaces the configuration at a Global level; a configuration at Host level replaces any configuration at the Application and Global configurations.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://www.stott.pro/assets/stott-security-7d.png&quot; alt=&quot;Example of the Response Headers being overridden for a specific appication&quot; /&gt;&lt;/p&gt;
&lt;h3&gt;Cross Origin Resource Sharing&lt;/h3&gt;
&lt;p&gt;The Cross Origin Resource Sharing (CORS) configuration is applied at a webserver level by hooking into .NET Core built-in CORS functionality that addresses incoming traffic to the webserver. The implementation of this functionality does not yet support the Global, Application or Host level variations.&lt;/p&gt;
&lt;h2&gt;What does this mean for Optimizely CMS 12?&lt;/h2&gt;
&lt;p&gt;Support for CMS 12 is not going away. The new functionality introduced in version 7 will be backported into a version 6 release for CMS 12, where feasible. Ongoing development will continue to target CMS 13 first, with CMS 12 updates following based on demand and compatibility.&lt;/p&gt;
&lt;p&gt;I can review the recent download stats on a version by version basis on nuget and I will use this as an indicator of the current market need. I expect that it will take a few years for a significant portion of customers to move from CMS 12 to CMS 13. Therefore, I expect to maintain the CMS 12 version for the short to mid term.&lt;/p&gt;
&lt;h2&gt;How does the change from Sites to Applications impact the user?&lt;/h2&gt;
&lt;p&gt;Optimizely CMS 13 introduces Applications in place of Sites, with a shift from GUID-based identifiers to string-based keys. This change breaks the direct relationship with existing configurations (such as security.txt) and requires structural updates to how data is stored.&lt;/p&gt;
&lt;p&gt;To simplify migration, version 7 uses a new set of database tables rather than attempting to deal with conflicts between CMS 12 and CMS 13 versions. As a result, users will need to reconfigure headers and security.txt when upgrading, it is recommended that you use the built-in Import/Export functionality to carry settings across.&lt;/p&gt;
&lt;h2&gt;Summary&lt;/h2&gt;
&lt;p&gt;Stott Security is a free, open-source add-on for Optimizely CMS 12 and CMS 13, designed from the ground up to be accessible to both technical and non-technical users, with built-in audit functionality to provide clear accountability. The add-on supports both PaaS-based traditional headed and headless solutions.&lt;/p&gt;
&lt;p&gt;Version 7 delivers a major update targeting Optimizely CMS 13 and .NET 10. Headers can now be defined at a Global, Application or Host level, providing a more flexible solution.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;I am an OMVP and the author and maintainer of&amp;nbsp;&lt;a title=&quot;Stott Security&quot; href=&quot;https://github.com/GeekInTheNorth/Stott.Security.Optimizely&quot;&gt;Stott Security&lt;/a&gt;&amp;nbsp;and&amp;nbsp;&lt;a title=&quot;Stott Robots Handler&quot; href=&quot;https://github.com/GeekInTheNorth/Stott.Optimizely.RobotsHandler&quot;&gt;Stott Robots Handler&lt;/a&gt; for Optimizely CMS 12 &amp;amp; 13. You can find all of my content collated on&amp;nbsp;&lt;a title=&quot;The blog website for Mark Stott.&quot; href=&quot;https://www.stott.pro/&quot;&gt;https://www.stott.pro/&lt;/a&gt;&amp;nbsp;&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/mark-stott/dates/2026/4/stott-security-v7-for-optimizely-cms-13/</guid>            <pubDate>Thu, 09 Apr 2026 08:17:03 GMT</pubDate>           <category>Blog post</category></item><item> <title>Stott Robots Handler v7 for Optimizely CMS 13</title>            <link>https://world.optimizely.com/blogs/mark-stott/dates/2026/4/stott-robots-handler-v7-for-optimizely-cms-13/</link>            <description>&lt;p&gt;Stott Robots Handler version 7 is now available for&amp;nbsp;&lt;strong&gt;Optimizely PaaS CMS 13&lt;/strong&gt;. This is includes updates to support the switch from a Site based architecture to an Application based architecture.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://www.stott.pro/assets/robots-handler-v7.png&quot; alt=&quot;Robots Handler V7 initial interace&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;How does the change from Sites to Applications impact the user?&lt;/h2&gt;
&lt;p&gt;The data structure of Applications is subtly different to that of Sites. In CMS 12, sites were uniquely identified using a GUID. In CMS 13, Applications are uniquely identified using a string identifier. This breaks the relationship between existing robots.txt and llms.txt content and the sites they were configured to serve.&lt;/p&gt;
&lt;p&gt;Users will have to reconfigure their robots.txt and llms.txt content when moving from CMS 12 to CMS 13. The content for both files is stored in the Dynamic Data Store and is matched to an active application before being displayed to the CMS Administrator or being served to end users.&lt;/p&gt;
&lt;p&gt;The Environment Robots and the API Tokens are global configurations and are not bound to a specific site or application. When upgrading to version 7 and CMS 13, these features will retain their existing configuration.&lt;/p&gt;
&lt;h3&gt;What features does Stott Robots Handler have?&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&#129302; Manage robots.txt content by Application and specific Host URL&lt;/li&gt;
&lt;li&gt;&#128196; Manage llms.txt content by Application and specific Host URL&lt;/li&gt;
&lt;li&gt;&#128274; Override robots meta tags and response headers at an environment level to prevent indexing lower environments in search engines.&lt;/li&gt;
&lt;li&gt;&#127760; APIs to support headless solutions&lt;/li&gt;
&lt;li&gt;&#127760; APIs to support modification of robots.txt and llms.txt content directly within Optimizely Opal&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;What does this mean for Optimizely CMS 12?&lt;/h2&gt;
&lt;p&gt;Support for version 6 of the Stott Robots Handler is not going away. New functionality will be developed directly for version 7 and then backported to version 6 where appropriate. I can review the recent download stats on a version by version basis on nuget and I will use this as an indicator of the current market need.&lt;/p&gt;
&lt;p&gt;I expect that it will take a few years for a significant portion of customers to move from CMS 12 to CMS 13. Therefore, I expect to maintain the CMS 12 version for the short to mid term.&lt;/p&gt;
&lt;h2&gt;Getting Started&lt;/h2&gt;
&lt;p&gt;Stott Robots Handler v7 is available for&amp;nbsp;&lt;strong&gt;Optimizely PaaS CMS 13&lt;/strong&gt; on nuget.org and on api.nuget.optimizely.com. You can see the full installation instructions and package information over on the &lt;a href=&quot;https://github.com/GeekInTheNorth/Stott.Optimizely.RobotsHandler&quot;&gt;github page&lt;/a&gt;, including how to configure the add-on for use with Opti Id.&lt;/p&gt;
&lt;p&gt;Getting started can be as simple as:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;// Install the nuget package
dotnet add package Stott.Optimizely.RobotsHandler

// Add this to your services
services.AddRobotsHandler();

// Add this to your configure method
services.UseRobotsHandler();

// Add this to your _ViewImports.cshtml if you are running traditional
@addTagHelper *, Stott.Optimizely.RobotsHandler&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;p&gt;I am an OMVP and the author and maintainer of&amp;nbsp;&lt;a title=&quot;Stott Security&quot; href=&quot;https://github.com/GeekInTheNorth/Stott.Security.Optimizely&quot;&gt;Stott Security&lt;/a&gt;&amp;nbsp;and&amp;nbsp;&lt;a title=&quot;Stott Robots Handler&quot; href=&quot;https://github.com/GeekInTheNorth/Stott.Optimizely.RobotsHandler&quot;&gt;Stott Robots Handler&lt;/a&gt; for Optimizely CMS 12 &amp;amp; 13. You can find all of my content collated on&amp;nbsp;&lt;a title=&quot;The blog website for Mark Stott.&quot; href=&quot;https://www.stott.pro/&quot;&gt;https://www.stott.pro/&lt;/a&gt;&amp;nbsp;&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/mark-stott/dates/2026/4/stott-robots-handler-v7-for-optimizely-cms-13/</guid>            <pubDate>Thu, 02 Apr 2026 09:44:40 GMT</pubDate>           <category>Blog post</category></item><item> <title>Announcing Stott Security Version 5.0</title>            <link>https://world.optimizely.com/blogs/mark-stott/dates/2026/3/announcing-stott-security-version-5.0</link>            <description>&lt;p&gt;March 2026 marks the release of Stott Security v5, a significant update to the popular web security add-on for Optimizely CMS 12+, with more than 115,000 downloads across nuget.org and nuget.optimizely.com. Below is a high-level overview of what&amp;rsquo;s new in this release.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Custom Header Management&lt;/li&gt;
&lt;li&gt;Audit Record Clean Up&lt;/li&gt;
&lt;li&gt;Audit Record Search&lt;/li&gt;
&lt;li&gt;Granular Settings Import&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Before we get into the details, we all know that the release of Optimizely CMS 13 is imminent. A CMS 13 compatible version of Stott Security has already been developed and will be updated as we see more previews of CMS 13. It is my intention to have a day 1 release of this add-on.&lt;/p&gt;
&lt;h2&gt;Custom Headers&lt;/h2&gt;
&lt;p&gt;I have had multiple requests to add functionality that allows users to add or remove custom headers from the response. The data storage and UI for the existing&amp;nbsp;&lt;strong&gt;Response Headers&lt;/strong&gt;&amp;nbsp;UI was inflexible, therefore I have rebuilt this feature from the ground up.&lt;/p&gt;
&lt;p&gt;Users can add new headers with any valid header name structure and define a value and a behaviour. The three behaviours are:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Add&lt;/strong&gt;: This will add the header to the response and will require a value to be specified.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Remove&lt;/strong&gt;: This will remove the header from the response.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Disabled&lt;/strong&gt;: No action will be performed for this header.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://www.stott.pro/assets/StottSecurityCustomHeaders.png&quot; alt=&quot;Custom Headers Interface&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;For traditional / in-process websites:&lt;/strong&gt; the order of your middlewares and the point in time where the header is added will impact the success rate for removal of headers. The server header for example is not present in the Response object while middlewares are processed and as such will not be impacted. Also headers added after the response has been served will not be affected, this means headers added by CloudFlare for example will not be removed.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;For Headless websites:&lt;/strong&gt; the Header Listing API has been updated so that all configured headers now have an &amp;ldquo;isRemoval&amp;rdquo; property that highlights if the header should be removed or not.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Headless API Examples:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;/stott.security.optimizely/api/compiled-headers/list/&lt;/li&gt;
&lt;li&gt;/stott.security.optimizely/api/compiled-headers/list/?pageId=123&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Example Response:&lt;/strong&gt;&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;[
    {
        &quot;key&quot;: &quot;a-custom-header&quot;,
        &quot;value&quot;: &quot;a-value&quot;,
        &quot;isRemoval&quot;: false
    },
    {
        &quot;key&quot;: &quot;Content-Security-Policy&quot;,
        &quot;value&quot;: &quot;default-src \u0027self\u0027;...&quot;, // Full CSP will be included
        &quot;isRemoval&quot;: false
    },
    {
        &quot;key&quot;: &quot;server&quot;,
        &quot;value&quot;: &quot;&quot;,
        &quot;isRemoval&quot;: true
    },
    {
        &quot;key&quot;: &quot;X-Content-Type-Options&quot;,
        &quot;value&quot;: &quot;nosniff&quot;,
        &quot;isRemoval&quot;: false
    },
    {
        &quot;key&quot;: &quot;X-Xss-Protection&quot;,
        &quot;value&quot;: &quot;0&quot;,
        &quot;isRemoval&quot;: false
    }
]&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;⚠️&amp;nbsp;&lt;strong&gt;Migration warning:&lt;/strong&gt; Any configuration on the old Response Headers interface will need to be recreated. Response Headers that were previously managed through the old interface will appear as Disabled and the edit modal will present the same friendly options that were available in the previous UI.&amp;nbsp; The headers that need re-configuring are limited to:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Cross-Origin-Embedder-Policy&lt;/li&gt;
&lt;li&gt;Cross-Origin-Opener-Policy&lt;/li&gt;
&lt;li&gt;Cross-Origin-Resource-Policy&lt;/li&gt;
&lt;li&gt;X-Content-Type-Options&lt;/li&gt;
&lt;li&gt;X-XSS-Protection&lt;/li&gt;
&lt;li&gt;X-Frame-Options&lt;/li&gt;
&lt;li&gt;Referrer-Policy&lt;/li&gt;
&lt;li&gt;Strict-Transport-Security (HSTS)&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Audit Record Clean Up&lt;/h2&gt;
&lt;p&gt;Stott Security has long had the ability to Audit changes to the configuration settings, but there has not been any means to clean up the audit records. A new scheduled job called&amp;nbsp;&lt;strong&gt;[Stott Security] Audit Record Clean Up&lt;/strong&gt;&amp;nbsp;has been created that will remove audit records that exceed a configured retention period. By default this period is set to 2 years, but can be altered during the Add-On configuration by specifying the&amp;nbsp;&lt;strong&gt;AuditRetentionPeriod&lt;/strong&gt;.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;services.AddStottSecurity(options =&amp;gt;
{
  options.ConnectionStringName = &quot;EPiServerDB&quot;;
  options.NonceHashExclusionPaths.Add(&quot;/exclude-me&quot;);
  options.AuditRetentionPeriod = TimeSpan.FromDays(730); 
},
authorization =&amp;gt;
{
  authorization.AddPolicy(CspConstants.AuthorizationPolicy, policy =&amp;gt;
  {
    policy.RequireRole(&quot;WebAdmins&quot;);
  });
});&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Audit Record Search&lt;/h2&gt;
&lt;p&gt;Filtering of audit records has always been present within the system, however it was limited to simple filters for user, operation, record and date range. Looking for very specific changes was a time consuming exercise. This change adds a text filter which will be used to find matches within the following fields:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Indicator
&lt;ul&gt;
&lt;li&gt;Source for Content Security Policy Sources&lt;/li&gt;
&lt;li&gt;Directive for Permission Policy directives&lt;/li&gt;
&lt;li&gt;Header Name for Custom Headers&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Old Value&lt;/li&gt;
&lt;li&gt;New Value&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://www.stott.pro/assets/StottSecurityAuditWithFreeTextFilter.png&quot; alt=&quot;Audit Interface&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&#128591;&amp;nbsp;&lt;strong&gt;Thank you&lt;/strong&gt;&amp;nbsp;to&amp;nbsp;&lt;a href=&quot;https://github.com/SamuelJoseph23&quot;&gt;Samuel Joseph&lt;/a&gt;&amp;nbsp;for delivering&amp;nbsp;&lt;a href=&quot;https://github.com/GeekInTheNorth/Stott.Security.Optimizely/issues/347&quot;&gt;Add a free text filter to the Audit Screen&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;Granular Settings Import&lt;/h2&gt;
&lt;p&gt;The ability to import and export settings was introduced back in version 2.6.0 but was straight forwards in terms of options. Over time this functionality was updated to support partial imports by ignoring root settings that were null. As part of this release, users now have the ability to choose specifically which settings they want to import.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://www.stott.pro/assets/StottSecurityImportSettingsModal.png&quot; alt=&quot;Import Settings Modal&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&#128591;&amp;nbsp;&lt;strong&gt;Thank you&lt;/strong&gt;&amp;nbsp;to&amp;nbsp;&lt;a href=&quot;https://github.com/SamuelJoseph23&quot;&gt;Samuel Joseph&lt;/a&gt;&amp;nbsp;for providing the initial implementation of&amp;nbsp;&lt;a href=&quot;https://github.com/GeekInTheNorth/Stott.Security.Optimizely/issues/346&quot;&gt;Enhance Settings Import Tool&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;Summary&lt;/h2&gt;
&lt;p&gt;Stott Security is a free, open-source add-on for Optimizely CMS 12+, designed from the ground up to be accessible to both technical and non-technical users, with built-in audit functionality to provide clear accountability. The add-on supports both PaaS-based traditional headed and headless solutions.&lt;/p&gt;
&lt;p&gt;Version 5 delivers a major update featuring a rebuilt&amp;nbsp;&lt;strong&gt;Response Headers&lt;/strong&gt;&amp;nbsp;functionality with enhanced support for custom headers; enhanced&amp;nbsp;&lt;strong&gt;Audit Management&lt;/strong&gt;&amp;nbsp;with configurable retention periods and new free-text audit search, and a more granular&amp;nbsp;&lt;strong&gt;Settings Import&lt;/strong&gt; tool that allows selective configuration.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;I am an OMVP and the author and maintainer of&amp;nbsp;&lt;a title=&quot;Stott Security&quot; href=&quot;https://github.com/GeekInTheNorth/Stott.Security.Optimizely&quot;&gt;Stott Security&lt;/a&gt;&amp;nbsp;and&amp;nbsp;&lt;a title=&quot;Stott Robots Handler&quot; href=&quot;https://github.com/GeekInTheNorth/Stott.Optimizely.RobotsHandler&quot;&gt;Stott Robots Handler&lt;/a&gt;&amp;nbsp;for Optimizely CMS 12. You can find all of my content collated on&amp;nbsp;&lt;a title=&quot;The blog website for Mark Stott.&quot; href=&quot;https://www.stott.pro/&quot;&gt;https://www.stott.pro/&lt;/a&gt;&amp;nbsp;&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/mark-stott/dates/2026/3/announcing-stott-security-version-5.0</guid>            <pubDate>Thu, 05 Mar 2026 09:17:42 GMT</pubDate>           <category>Blog post</category></item><item> <title>Working With Applications in Optimizely CMS 13</title>            <link>https://world.optimizely.com/blogs/mark-stott/dates/2026/1/working-with-applications-in-optimizely-cms-13/</link>            <description>&lt;blockquote&gt;
&lt;p&gt;&#128161;&lt;strong&gt;Note:&lt;/strong&gt;&amp;nbsp;The following content has been written based on Optimizely CMS 13 Preview 2 and may not accurately reflect the final release version.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;As part of my preparation of Stott Security and Stott Robots Handler for PaaS CMS 13, I&amp;rsquo;ve had to revisit key functionality in my add-ons that use site definitions. These add-ons allow a user to define robots.txt, llms.txt and security.txt configurations on a global, per site and per host definition basis. As a result I need to be able to read out data about the different site configurations.&lt;/p&gt;
&lt;h2&gt;Accessing Site / Application Definitions&lt;/h2&gt;
&lt;p&gt;With CMS 13 taking the lead from SaaS and thinking about more composable architectures, there is a shift from thinking about &amp;ldquo;sites&amp;rdquo; and instead thinking about &amp;ldquo;applications&amp;rdquo;. There is also a shift to having two different application configurations. There is the traditional &lt;strong&gt;&amp;ldquo;in-process&amp;rdquo;&lt;/strong&gt; CMS application which is represented by the &lt;strong&gt;Website &lt;/strong&gt;class, and there is the &lt;strong&gt;&amp;ldquo;headless&amp;rdquo;&lt;/strong&gt;&amp;nbsp;application which is represented by the &lt;strong&gt;RemoteWebsite&lt;/strong&gt; class. These two applications both inherit the&amp;nbsp;&lt;strong&gt;Application&lt;/strong&gt;&amp;nbsp;class.&lt;/p&gt;
&lt;p&gt;The existing &lt;strong&gt;ISiteDefinitionRepository&lt;/strong&gt; is now deprecated, but functions for now and only returns &lt;strong&gt;in-process&lt;/strong&gt; applications. In order to access both application types you need to use the new interface of &lt;strong&gt;IApplicationRepository&lt;/strong&gt;. This new repository exposes the following methods for listing applications:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;// In-process applications
var data = await applicationRepository.ListAsync&amp;lt;Website&amp;gt;();

// Headless applications
var data = await applicationRepository.ListAsync&amp;lt;RemoteWebsite&amp;gt;();

// All applications
var data = await applicationRepository.ListAsync();
var data = await applicationRepository.ListAsync&amp;lt;Application&amp;gt;();&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Another key change with here is that the GUID Id field is now defunct, and while &lt;strong&gt;ISiteDefinitionRepository &lt;/strong&gt;still functions, the Id is populated with an empty GUID for compatibility purposes. The new objects use a &lt;strong&gt;name &lt;/strong&gt;property instead and is generated as a sanitized lowercase version of the provided Display Name on creation and then becomes an immutable property.&lt;/p&gt;
&lt;p&gt;Another welcome change with &lt;strong&gt;IApplicationRepository &lt;/strong&gt;is the shift to asynchronous APIs, which makes it easier to write non-blocking and scalable code, particularly in high-throughput&amp;nbsp;scenarios.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Migration warning:&lt;/strong&gt; If you previously keyed configuration by SiteDefinition.Id, you will need a migration strategy to map legacy GUIDs to Application.Name. There is no one-to-one replacement, and the new name is immutable once created.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;Resolving The Current Application&lt;/h2&gt;
&lt;p&gt;Historically getting the current website for your current page would be accessed by &lt;strong&gt;SiteDefinition.Current&lt;/strong&gt;. This is no longer available and instead we need to use &lt;strong&gt;IApplicationResolver &lt;/strong&gt;instead. This provides access to the following methods:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;// Retrieve the application based on a host name 
var result = applicationResolver.GetByHostname(hostName, fallbackToDefault);
var result = await applicationResolver.GetByHostnameAsync(hostName, fallbackToDefault, cancellationToken);

// Retrieve the application based on a given content reference
var app = applicationResolver.GetByContent(contentReference, fallbackToDefault);
var app = await applicationResolver.GetByContentAsync(contentReference, fallbackToDefault, cancellationToken);

// Retrieve the application based on the current HTTP Context
var app = GetByContext();
var app = await GetByContextAsync(cancellationToken);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Decompiling the &lt;strong&gt;DefaultApplicationResolver &lt;/strong&gt;I can see that the &lt;strong&gt;GetByContext &lt;/strong&gt;methods actually just wrap the &lt;strong&gt;GetByContent &lt;/strong&gt;and &lt;strong&gt;GetByHostName &lt;/strong&gt;methods and attempt to do this by Content first. If your functionality needs to operate outside of a content route then it could be better to retieve the application by host name directly, just bear in mind that this returns an &lt;strong&gt;ApplicationHostResolution &lt;/strong&gt;rather than an &lt;strong&gt;Application&lt;/strong&gt;.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;public async ValueTask&amp;lt;Application?&amp;gt; GetByContextAsync(CancellationToken cancellationToken = default(CancellationToken))
{
    Application application = null;
    ContentReference routedContentLink = _routedContentLinkResolver.RoutedContentLink;
    if (!ContentReference.IsNullOrEmpty(routedContentLink))
    {
        application = await GetByContentAsync(routedContentLink, fallbackToDefault: true, cancellationToken).ConfigureAwait(continueOnCapturedContext: false);
    }

    if (application == null)
    {
        string hostName = _requestHostResolver.HostName;
        (application, _) = await GetByHostnameAsync(hostName, fallbackToDefault: true, cancellationToken).ConfigureAwait(continueOnCapturedContext: false);
    }

    return application;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Summary&lt;/h2&gt;
&lt;p&gt;CMS 13 replaces the traditional concept of sites with applications, supporting both in-process and headless models. While legacy site APIs still exist for compatibility, new development should use IApplicationRepository and IApplicationResolver.&lt;/p&gt;
&lt;p&gt;The shift from GUID-based site IDs to immutable application names is the most significant change and has real implications for configuration storage and migration. Although this requires some adjustment, the new application model provides a cleaner and more flexible foundation for modern CMS architectures.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;I am an OMVP and the author and maintainer of&amp;nbsp;&lt;a title=&quot;Stott Security&quot; href=&quot;https://github.com/GeekInTheNorth/Stott.Security.Optimizely&quot;&gt;Stott Security&lt;/a&gt;&amp;nbsp;and&amp;nbsp;&lt;a title=&quot;Stott Robots Handler&quot; href=&quot;https://github.com/GeekInTheNorth/Stott.Optimizely.RobotsHandler&quot;&gt;Stott Robots Handler&lt;/a&gt;&amp;nbsp;for Optimizely CMS 12. You can find all of my content collated on&amp;nbsp;&lt;a title=&quot;The blog website for Mark Stott.&quot; href=&quot;https://www.stott.pro/&quot;&gt;https://www.stott.pro/&lt;/a&gt;&amp;nbsp;&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/mark-stott/dates/2026/1/working-with-applications-in-optimizely-cms-13/</guid>            <pubDate>Fri, 30 Jan 2026 12:10:23 GMT</pubDate>           <category>Blog post</category></item><item> <title>Announcing Stott Security Version 4.0</title>            <link>https://world.optimizely.com/blogs/mark-stott/dates/2026/1/announcing-stott-security-version-4.0</link>            <description>&lt;p&gt;January 2026 marks the release of Stott Security v4, a significant update to the popular web security add-on for Optimizely CMS 12, with more than 109,000 downloads across nuget.org and nuget.optimizely.com. Below is a high-level overview of what&amp;rsquo;s new in this release.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Features
&lt;ul&gt;
&lt;li&gt;Manage security.txt files&lt;/li&gt;
&lt;li&gt;Improved nonce and hash support&lt;/li&gt;
&lt;li&gt;UI rebuilt with Vite&lt;/li&gt;
&lt;li&gt;.NET 10 support&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Bug fixes
&lt;ul&gt;
&lt;li&gt;Conditional output of HSTS headers&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Manage Security.txt Files&lt;/h2&gt;
&lt;p&gt;A security.txt file provides a standardized way for security researchers to report vulnerabilities by clearly defining contact details and disclosure policies. This helps ensure that security issues are reported responsibly and reach the appropriate team quickly. Stott Security allows you to manage security.txt content globally, per site, or even per host, and serves it from the standard path /.well-known/security.txt. You can read more about the specification on the official&amp;nbsp;&lt;a href=&quot;https://securitytxt.org/&quot;&gt;security.txt website&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The UI for managing security.txt content will be familiar to users of Stott Robots Handler. The core functionality has been carried across and enhanced with minor behavioural adjustments and security.txt specific guidance. While there are recommended formats for security.txt, real-world implementations vary widely, so this initial release supports free-form content entry and links to the official security.txt site, where a useful generator is available.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://www.stott.pro/assets/stott-security-securitytxt-management.png&quot; alt=&quot;A screenshot of the security.txt maintenance screen&quot; /&gt;&lt;/p&gt;
&lt;p&gt;As with all features in Stott Security, changes to security.txt content are fully audited. However, security.txt files are intentionally excluded from the security header import and export functionality. Security headers are applied site-wide, whereas security.txt content can vary by site and host and is typically simple to copy between environments when required.&lt;/p&gt;
&lt;h2&gt;Improved Nonce and Hash Support&lt;/h2&gt;
&lt;p&gt;Previously, Nonce support for script and style elements was enabled via a single, all-or-nothing option in the CSP Settings screen. In version 4, the&amp;nbsp;&lt;strong&gt;Generate Nonce&lt;/strong&gt;&amp;nbsp;and&amp;nbsp;&lt;strong&gt;Use Strict Dynamic&lt;/strong&gt;&amp;nbsp;options have been removed from&amp;nbsp;&lt;strong&gt;CSP Settings&lt;/strong&gt;&amp;nbsp;and are now configured as sources within the&amp;nbsp;&lt;strong&gt;CSP Sources&lt;/strong&gt;&amp;nbsp;screen. This change allows for more granular control over CSP configuration. Existing installations will have their settings automatically migrated during upgrade to preserve current behaviour.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://www.stott.pro/assets/stott-security-nonce-source.png&quot; alt=&quot;A screenshot of the content security policy Nonce source modal&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Support for hashes has also been added to the CSP Sources list, with validated support for SHA-256, SHA-384, and SHA-512. While hash-based CSP rules are now fully supported, they are generally not the recommended primary approach. Where possible, nonce attributes should be applied to script and style elements, inline scripts and styles should be avoided, and nonce support should be used within GTM-injected tags.&lt;/p&gt;
&lt;p&gt;It is important to note that Optimizely CMS does not natively support nonce or hash attributes in the Editor and Admin interfaces. These interfaces do not apply nonce attributes to script or style elements, and the client-side Quick Navigator gadget injects page-specific data into inline script blocks. As a result, a unique hash would be required for every page. Enabling nonce or hash support therefore requires disabling the Quick Navigator widget.&lt;/p&gt;
&lt;p&gt;To provide deterministic control over where nonce and hash functionality is applied, version 4 introduces configurable exclusion paths. Requests routed to these paths, or any sub-paths beneath them, will have nonce and hash sources automatically removed from the generated Content Security Policy.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;services.AddStottSecurity(cspSetupOptions =&amp;gt;
{
    cspSetupOptions.ConnectionStringName = &quot;EPiServerDB&quot;;
    cspSetupOptions.NonceHashExclusionPaths.Add(&quot;/exclude-me&quot;);
},
authorizationOptions =&amp;gt;
{
    authorizationOptions.AddPolicy(CspConstants.AuthorizationPolicy, policy =&amp;gt;
    {
        policy.RequireRole(&quot;WebAdmins&quot;, &quot;Everyone&quot;);
    });
});&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;By default the following paths are already added to the Nonce and Hash exclusion list:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;/episerver&lt;/li&gt;
&lt;li&gt;/ui&lt;/li&gt;
&lt;li&gt;/util&lt;/li&gt;
&lt;li&gt;/stott.robotshandler&lt;/li&gt;
&lt;li&gt;/stott.security.optimizely&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Framework Updates&lt;/h2&gt;
&lt;p&gt;When I started Stott Security, the UI was built using&amp;nbsp;&lt;strong&gt;Create React App&lt;/strong&gt;&amp;nbsp;and this was my first full React UI. Since then, the&amp;nbsp;&lt;strong&gt;Create React App&lt;/strong&gt;&amp;nbsp;way of building React UI has been deprecated. To bring the UI up to date, I have rebuilt the UI using Vite and I&amp;rsquo;ve taken the opportunity to update all dependencies.&lt;/p&gt;
&lt;p&gt;An extra compilation target has been added for .NET 10. Historically I&amp;rsquo;ve had to compile for multiple versions of .NET due to breaking changes in Entity Framework. In order to maintain backwards compatibility for existing users, this now means I am compiling for .NET 6, 8, 9 and 10. When CMS 13 is released, I will be reducing my compilation targets to just .NET 10 for CMS 13.&lt;/p&gt;
&lt;h2&gt;Small Changes&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;The&amp;nbsp;&lt;strong&gt;Strict-Transport-Security&lt;/strong&gt;&amp;nbsp;header will now only be included in HTTPS responses in alignment with best practices. See:&amp;nbsp;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Strict-Transport-Security&quot;&gt;MDN : Strict-Transport-Security header&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;The &amp;ldquo;Add Content Delivery API Headers&amp;rdquo; button on the CORS has been renamed and will add all of the following headers for both the Content Delivery and Definition APIs:
&lt;ul&gt;
&lt;li&gt;x-epi-contentguid&lt;/li&gt;
&lt;li&gt;x-epi-branch&lt;/li&gt;
&lt;li&gt;x-epi-siteid&lt;/li&gt;
&lt;li&gt;x-epi-startpageguid&lt;/li&gt;
&lt;li&gt;x-epi-remainingroute&lt;/li&gt;
&lt;li&gt;x-epi-contextmode&lt;/li&gt;
&lt;li&gt;x-epi-continuation&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Closing Thoughts&lt;/h2&gt;
&lt;p&gt;Stott Security is a free, open-source add-on for Optimizely CMS 12, designed from the ground up to be accessible to both technical and non-technical users, with built-in audit functionality to provide clear accountability. The add-on supports both PaaS-based traditional headed sites and headless solutions.&lt;/p&gt;
&lt;p&gt;For existing users, this release aims to enhance flexibility and modernise key areas without disrupting established configurations. For those new to Stott Security, version 4 provides a solid, standards-aligned foundation for securing Optimizely CMS applications.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;I am an OMVP and the author and maintainer of&amp;nbsp;&lt;a title=&quot;Stott Security&quot; href=&quot;https://github.com/GeekInTheNorth/Stott.Security.Optimizely&quot;&gt;Stott Security&lt;/a&gt;&amp;nbsp;and&amp;nbsp;&lt;a title=&quot;Stott Robots Handler&quot; href=&quot;https://github.com/GeekInTheNorth/Stott.Optimizely.RobotsHandler&quot;&gt;Stott Robots Handler&lt;/a&gt;&amp;nbsp;for Optimizely CMS 12. You can find all of my content collated on&amp;nbsp;&lt;a title=&quot;The blog website for Mark Stott.&quot; href=&quot;https://www.stott.pro/&quot;&gt;https://www.stott.pro/&lt;/a&gt;&amp;nbsp;&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/mark-stott/dates/2026/1/announcing-stott-security-version-4.0</guid>            <pubDate>Fri, 16 Jan 2026 10:27:26 GMT</pubDate>           <category>Blog post</category></item><item> <title>Triggering Opal Workflows When Publishing Content</title>            <link>https://world.optimizely.com/blogs/mark-stott/dates/2026/1/triggering-opal-workflows-when-publishing-content/</link>            <description>&lt;p&gt;Over the course of November, my colleagues at &lt;a title=&quot;Netcel - The Optimizely Experts&quot; href=&quot;https://www.netcel.com/&quot;&gt;Netcel&lt;/a&gt; and I took part in Optimizely&amp;rsquo;s Opal Innovation Challenge. We were tasked to be inventive and to discover new ways in which we could use Opal with emphasis on Specialized Agents, Workflows and Tools. If you are unaware of what these features are, my colleague&amp;nbsp;&lt;a href=&quot;https://www.linkedin.com/in/carrgraham/&quot;&gt;Graham Carr&lt;/a&gt;&amp;nbsp;has written a great introduction blog entitled&amp;nbsp;&lt;a href=&quot;/link/265af866ee474623ac29926f5540b4c6.aspx&quot;&gt;A day in the life of an Optimizely OMVP - Optimizely Opal: Specialized Agents, Workflows, and Tools Explained&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;In this technical blog, I&amp;rsquo;m going to focus on how we can leverage publish events to trigger workflows within Opal. The traditional method of doing this would be to leverage content events in&amp;nbsp;&lt;strong&gt;C#&lt;/strong&gt;&amp;nbsp;by registering handlers for the&amp;nbsp;&lt;strong&gt;IContentEvents&lt;/strong&gt;&amp;nbsp;interface. This approach has the downside of only being applicable for&amp;nbsp;&lt;strong&gt;PaaS&lt;/strong&gt;&amp;nbsp;solutions. I will be showing you how to achieve the same goal using webhooks in&amp;nbsp;&lt;strong&gt;Content Graph&lt;/strong&gt;&amp;nbsp;which has the advantage of being applicable to both&amp;nbsp;&lt;strong&gt;SaaS&lt;/strong&gt;&amp;nbsp;and&amp;nbsp;&lt;strong&gt;PaaS&lt;/strong&gt; CMS without relying on CMS hosted event handlers.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&#128161;&lt;strong&gt;Tip:&lt;/strong&gt;&amp;nbsp;Optimizely is moving away from&amp;nbsp;&lt;strong&gt;Search &amp;amp; Navigation&lt;/strong&gt;&amp;nbsp;to&amp;nbsp;&lt;strong&gt;Content Graph&lt;/strong&gt;&amp;nbsp;for improved flexibility, stability and performance. Use add-ons such as&amp;nbsp;&lt;a href=&quot;/link/9111166ce102481493342ea7b7330b5a.aspx&quot;&gt;OptiGraphExtensions&lt;/a&gt;&amp;nbsp;to manage your synonyms in&amp;nbsp;&lt;strong&gt;Content Graph&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;Solution Flow&lt;/h2&gt;
&lt;p&gt;Optimizely Content Graph has webhook functionality that will allow you to subscribe to indexing events. If you&amp;rsquo;re developing headless solutions with Content Graph you should already be familiar with this functionality. For this solution the data journey will be as follows:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;User publishes a page or experience in the CMS&lt;/li&gt;
&lt;li&gt;The content changes are indexed into Content Graph&lt;/li&gt;
&lt;li&gt;Content Graph will send a notification to an Integration API using webhooks&lt;/li&gt;
&lt;li&gt;The Integration API will retrieve additional information from Content Graph&lt;/li&gt;
&lt;li&gt;The Integration API will package the data up and send it to a webhook endpoint in Optimizely Opal&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://www.stott.pro/assets/cms-to-opal-sequence-diagram.png&quot; alt=&quot;Sequence diagram for the CMS to Opal Workflow integration&quot; /&gt;&lt;/p&gt;
&lt;p&gt;You might ask why there is an Integration API included here? This is because Content Graph webhooks intentionally provide minimal payloads, which keeps them fast and reliable but requires enrichment downstream.&lt;/p&gt;
&lt;h2&gt;Testing Webhooks in Third Party Systems&lt;/h2&gt;
&lt;p&gt;Testing Webhook responses can be a challenge, especially if you are unsure what real data is going to look like. This is where tools such as&amp;nbsp;&lt;a href=&quot;https://webhook.cool/&quot;&gt;&lt;strong&gt;Webhook&lt;/strong&gt;Cool&lt;/a&gt;&amp;nbsp;come in. This particular website provides you with a temporary end point that you can point your webhook at so that you can examine your data before implementing an endpoint. I&amp;rsquo;ve found this particular tool to be very helpful during the prototyping phases.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&#128274;&amp;nbsp;&lt;strong&gt;Security Warning:&lt;/strong&gt;&amp;nbsp;Third party tools like WebhookCool can be useful for testing APIs, however you should&amp;nbsp;&lt;strong&gt;never&lt;/strong&gt; use them with a production environment or anything that might contain PII data.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;Creating Webhooks In Content Graph&lt;/h2&gt;
&lt;p&gt;A webhook can be registered with Content Graph using a POST request. This POST request will require authorization using Basic or HMAC Authentication. In this example I am registering the webhook using Basic Authentication with the following key pieces of information:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The&amp;nbsp;&lt;strong&gt;request&lt;/strong&gt;&amp;nbsp;contains the URL and method for the webhook.&lt;/li&gt;
&lt;li&gt;The&amp;nbsp;&lt;strong&gt;topic&lt;/strong&gt;&amp;nbsp;is set to be &amp;ldquo;doc.updated&amp;rdquo;, this tells Content Graph we are only interested in single document update events.&lt;/li&gt;
&lt;li&gt;The&amp;nbsp;&lt;strong&gt;filter&lt;/strong&gt; is set to filter content to published items only.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;POST https://cg.optimizely.com/api/webhooks
Content-Type: application/json
Authorization: Basic &amp;lt;Base64 encoded App Key and Secret&amp;gt;

{
    &quot;request&quot; : {
        &quot;url&quot; : &quot;https://webhook.cool/at/randomly-generated-name&quot;,
        &quot;method&quot;: &quot;post&quot;
    },
    &quot;topic&quot;: [&quot;doc.updated&quot;], 
    &quot;filters&quot;: [
        {
            &quot;status&quot;: { &quot;eq&quot;: &quot;Published&quot; }
        }
    ]
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When you update the content item in Content Graph you will get one or more webhook events being executed with a response like so:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;{
  &quot;timestamp&quot;: &quot;2025-12-22T13:56:00.7410789+00:00&quot;,
  &quot;tenantId&quot;: &quot;00000000000000000000000000000000&quot;,
  &quot;type&quot;: {
    &quot;subject&quot;: &quot;doc&quot;,
    &quot;action&quot;: &quot;updated&quot;
  },
  &quot;data&quot;: {
    &quot;docId&quot;: &quot;662e2c97-c658-497d-a998-1c00df22e600_en_Published&quot;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Out of the box this isn&amp;rsquo;t ready for consumption for Opal as this doesn&amp;rsquo;t contain any information about the updated page beyond the docId which is a unique identifier within graph for the content item. If you wanted to pass something more useful to Opal, like a URL, then you will need an Integration API end point to handle this.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&#128161;&lt;strong&gt;Tip:&lt;/strong&gt;&amp;nbsp;Please note that webhooks are registered per CMS and Content Graph environment, typically these are Integration, Preproduction and Production. However, there is only one Opal instance and you may not want events raised during QA or UAT to burn through your Opal credits.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;Creating The Integration API&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;&#128274;&amp;nbsp;&lt;strong&gt;Please Note:&lt;/strong&gt;&amp;nbsp;identifiers in these examples have been swapped out with a randomly generated id to aid visualization and should not reflect any specific system.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The first thing we will need is to convert the Content Graph docId into data we can use to filter graph queries. This requires us to split the docId based on underscores with the first component being the Content GUID, the second being the language for the content and the third being the published state. To make matters more interesting, the Content GUIDs are stored without hyphens in Content Graph, so we have to strip those out too:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;public bool TryConvertContentIdentifiers(string docId, out string id, out string locale)
{
    id = string.Empty;
    locale = string.Empty;
    if (string.IsNullOrWhiteSpace(docId))
    {
        return false;
    }

    var components = docId.Split(&#39;_&#39;);
    if (components.Length &amp;lt; 2 || !Guid.TryParse(components[0], out _) || string.IsNullOrWhiteSpace(components[1]))
    {
        return false;
    }

    id = components[0].Replace(&quot;-&quot;, string.Empty);
    locale = components[1];
    return true;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This will essentially turn &lt;strong&gt;662e2c97-c658-497d-a998-1c00df22e600_en_Published&lt;/strong&gt; into &lt;strong&gt;662e2c97c658497da9981c00df22e600&lt;/strong&gt; and &lt;strong&gt;en&lt;/strong&gt;. We can then use these to request URL data from Content Graph with our query looking like:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;query MyQuery {
  _Page(
    orderBy: { _modified: DESC }
    where: {
      _metadata: { key: { eq: &quot;662e2c97c658497da9981c00df22e600&quot; }, locale: { eq: &quot;en&quot; } }
    }
  ) {
    items {
      _id
      _metadata {
        url {
          base
          hierarchical
          default
        }
      }
    }
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This will have a response that looks like:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;{
  &quot;data&quot;: {
    &quot;_Page&quot;: {
      &quot;items&quot;: [
        {
          &quot;_id&quot;: &quot;662e2c97-c658-497d-a998-1c00df22e600_en_Published&quot;,
          &quot;_metadata&quot;: {
            &quot;url&quot;: {
              &quot;base&quot;: &quot;https://www.example.com&quot;,
              &quot;hierarchical&quot;: &quot;/insights/&quot;,
              &quot;default&quot;: &quot;/insights/&quot;
            }
          }
        }
      ]
    }
  },
  &quot;extensions&quot;: { ... }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We can then wrap this up into a request to get the data from Content Graph by performing the following:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Using a StringBuilder to create the query.&lt;/li&gt;
&lt;li&gt;Retrieving the data from Content Graph using single key authentication.&lt;/li&gt;
&lt;li&gt;Serializing the data onto an object model based on the example response above.&lt;/li&gt;
&lt;li&gt;Validating the URL data for each content item in the response&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;public async Task&amp;lt;List&amp;lt;Uri&amp;gt;&amp;gt; GetContentUris(string id, string locale)
{
    if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(locale))
    {
        return [];
    }

    // Build GraphQL Query to fetch content details
    var stringBuilder = new StringBuilder();
    stringBuilder.AppendLine(&quot;query MyQuery {&quot;);
    stringBuilder.AppendLine(&quot;  _Page(&quot;);
    stringBuilder.AppendLine(&quot;    orderBy: { _modified: DESC }&quot;);
    stringBuilder.AppendLine(&quot;    where: {&quot;);
    stringBuilder.AppendLine($&quot;      _metadata: {{ key: {{ eq: \&quot;{id}\&quot; }}, locale: {{ eq: \&quot;{locale}\&quot; }} }}&quot;);
    stringBuilder.AppendLine(&quot;    }&quot;);
    stringBuilder.AppendLine(&quot;  ) {&quot;);
    stringBuilder.AppendLine(&quot;    items {&quot;);
    stringBuilder.AppendLine(&quot;      _id&quot;);
    stringBuilder.AppendLine(&quot;      _metadata {&quot;);
    stringBuilder.AppendLine(&quot;        types&quot;);
    stringBuilder.AppendLine(&quot;        key&quot;);
    stringBuilder.AppendLine(&quot;        locale&quot;);
    stringBuilder.AppendLine(&quot;        published&quot;);
    stringBuilder.AppendLine(&quot;        url {&quot;);
    stringBuilder.AppendLine(&quot;          base&quot;);
    stringBuilder.AppendLine(&quot;          hierarchical&quot;);
    stringBuilder.AppendLine(&quot;          default&quot;);
    stringBuilder.AppendLine(&quot;        }&quot;);
    stringBuilder.AppendLine(&quot;      }&quot;);
    stringBuilder.AppendLine(&quot;    }&quot;);
    stringBuilder.AppendLine(&quot;  }&quot;);
    stringBuilder.AppendLine(&quot;}&quot;);

    // Get the data from Content Graph using a GET request using single key authentication
    var query = stringBuilder.ToString();
    var authKey = configuration.GetValue&amp;lt;string&amp;gt;(&quot;GraphSingleKey&quot;);
    var graphUri = new Uri($&quot;https://cg.optimizely.com/content/v2?auth={authKey}&amp;amp;cg-roles=Content%20Editors&amp;amp;query={Uri.EscapeDataString(query)}&quot;);

    var httpClient = httpClientFactory.CreateClient();
    var response = await httpClient.GetAsync(graphUri);

    response.EnsureSuccessStatusCode();

    var content = await response.Content.ReadAsStringAsync();
    var contentResponse = JsonSerializer.Deserialize&amp;lt;ContentQueryResponseDto&amp;gt;(content)!;

    // Validate the results so we only return URLs that are valid absolute URLs.
    var pages = contentResponse?.Data?.Pages?.Items ?? [];
    var contentUris = new List&amp;lt;Uri&amp;gt;();
    foreach (var page in pages)
    {
        var contentUrl = $&quot;{page.Metadata?.Url?.Base}{page.Metadata?.Url?.Hierarchical}&quot;;
        if (Uri.IsWellFormedUriString(contentUrl, UriKind.Absolute))
        {
            contentUris.Add(new Uri(contentUrl));
        }
    }

    return contentUris;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now we have a means to transform the data from the Graph Webhook and to retrieve the URL data from Content Graph, we can use this inside of a C# Controller to do the following:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Act as the endpoint for the Content Graph Webhook&lt;/li&gt;
&lt;li&gt;Retrieve the URLs for the updated content items from Content Graph&lt;/li&gt;
&lt;li&gt;Push each URL into the Opal Workflow Webhook&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;[HttpPost]
[Route(&quot;api/content/publish&quot;)]
[Consumes(&quot;text/plain&quot;, &quot;application/json&quot;)]
public async Task&amp;lt;IActionResult&amp;gt; ContentPublishEvent([FromQuery] string apiKey)
{
    // [FromBody] attribute does not work with a content-type of text/plain.
    // So read the response body out manually and then deserialize it.
    WebhookEventDto? contentPublishEvent;
    using (var reader = new StreamReader(Request.Body, Encoding.UTF8))
    {
        var body = await reader.ReadToEndAsync();
        if (string.IsNullOrWhiteSpace(body))
        {
            return BadRequest();
        }

        contentPublishEvent = JsonSerializer.Deserialize&amp;lt;WebhookEventDto&amp;gt;(body);
    }

    // Validate the request payload has the required data items
    if (string.IsNullOrWhiteSpace(contentPublishEvent?.Data?.DocId) || string.IsNullOrWhiteSpace(apiKey))
    {
        return BadRequest();
    }

    // Convert Identifiers from the Content Graph Webhook data into values to locate the item in Content Graph
    if (!TryConvertContentIdentifiers(contentPublishEvent.Data.DocId, out var id, out var locale))
    {
        return BadRequest();
    }

    // Get Page Uris from Content Graph
    var pageUris = await GetContentUris(id, locale);

    // Send Page Uri&#39;s to the Opal Workflow Webhook
    foreach (var pageUri in pageUris)
    {
        await PostAsync(opalUri, new { url = pageUri.ToString() });
    }

    return Ok();
}

public async Task PostAsync&amp;lt;TRequest&amp;gt;(string url, TRequest requestBody)
{
    var httpClient = httpClientFactory.CreateClient();
    var jsonContent = new StringContent(JsonSerializer.Serialize(requestBody), Encoding.UTF8, &quot;application/json&quot;);
    var response = await httpClient.PostAsync(url, jsonContent);
    
    response.EnsureSuccessStatusCode();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Keen observers may note that the controller is configured to accept both&amp;nbsp;&lt;strong&gt;text/plain&lt;/strong&gt;&amp;nbsp;and&amp;nbsp;&lt;strong&gt;application/json&lt;/strong&gt;&amp;nbsp;payloads. In practice, Content Graph webhooks can deliver requests with a&amp;nbsp;&lt;strong&gt;text/plain&lt;/strong&gt;&amp;nbsp;MIME type, while tools such as HTTP files or Postman typically send payloads as&amp;nbsp;&lt;strong&gt;application/json&lt;/strong&gt;. Since standard C# model binding does not run for requests with a&amp;nbsp;&lt;strong&gt;text/plain&lt;/strong&gt;&amp;nbsp;content type, the request body is read and deserialized manually within the controller.&lt;/p&gt;
&lt;p&gt;The end result of this integration API is that content update actions for non-pages will be ignored and Opal will only recieve an instruction to run a workflow for publicly accessible pages with a provided URL in a simple payload:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;{ 
  &quot;url&quot;: &quot;https://www.example.com/insights/&quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Creating the Workflow in Opal&lt;/h2&gt;
&lt;p&gt;A workflow in Optimizely Opal is a collection of one or more specialized agents that can perform a set of actions either as a direct result of a chat prompt or independently from the chat prompt based on triggers. One of these triggers is a Webhook trigger. In order to create a new Opal Workflow you will need to do the following:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Login to Opal at&amp;nbsp;&lt;a href=&quot;https://opal.optimizely.com/&quot;&gt;https://opal.optimizely.com/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Click on Agents in the left-hand menu&lt;/li&gt;
&lt;li&gt;Click on the &amp;ldquo;Add Agent&amp;rdquo; drop-down CTA&lt;/li&gt;
&lt;li&gt;Click on &amp;ldquo;Workflow Agent&amp;rdquo;&lt;/li&gt;
&lt;li&gt;Enter a name and unique id for the agent&lt;/li&gt;
&lt;li&gt;Click on &amp;ldquo;Edit Workflow&amp;rdquo;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;At this point you should have the workflow editor screen showing, this is a drag and drop interface that allows you to add triggers, logic gates and agents and to link them together.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://www.stott.pro/assets/opal-workflow-webhook.png&quot; alt=&quot;A screenshot of the workflow editor interface&quot; /&gt;&lt;/p&gt;
&lt;p&gt;In this example I have created a new workflow trigger and set the payload so that it knows to expect a simple JSON object containing a URL. I have also given the workflow a chat trigger and a chain of two specialized agents. When you save your workflow trigger for the first time, the Webhook URL is automatically generated using the following format:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;https://webhook.opal.optimizely.com/webhooks/&amp;lt;instance-id&amp;gt;/&amp;lt;webhook-trigger-id&amp;gt;
https://webhook.opal.optimizely.com/webhooks/abcdef0123456789abcdef0123456789/abcdef01-2345-6789-abcd-ef0123456789&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This Webhook URL is what will be referenced by our Integration API.&lt;/p&gt;
&lt;h2&gt;End to End&lt;/h2&gt;
&lt;p&gt;Now we have all the components needed to bring the solution flow together by performing the following:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Creating the Opal Workflow Webhook.&lt;/li&gt;
&lt;li&gt;Deploying the Integration API and configuring environment variables:
&lt;ul&gt;
&lt;li&gt;Content Graph Single Key: For public content graph consumption and retrieval of page meta data.&lt;/li&gt;
&lt;li&gt;Opal Workflow Url: So that the Integration API can send payloads to the Opal Workflow&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Register the Content Graph Webhook: So that content update events are sent to the Integration API&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This approach allows content publishing in Optimizely CMS to automatically trigger AI-driven workflows in Optimizely Opal, regardless of whether the CMS is hosted on SaaS or PaaS. Content Graph provides lightweight publish events, the Integration API enriches those events with a public URL, and Opal executes a workflow using a simple, stable webhook payload.&lt;/p&gt;
&lt;p&gt;Within Opal, you can then configure the workflow to orchestrate a chain of specialized agents, each focused on a specific responsibility. Because agents receive only a URL, they remain decoupled from CMS internals and can evolve independently over time.&lt;/p&gt;
&lt;p&gt;Typical agents in this workflow might include:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Automated GEO Recommendations &amp;ndash; analysing content structure and semantics to improve visibility in AI-driven search and generative experiences.&lt;/li&gt;
&lt;li&gt;Automated SEO Assessment &amp;ndash; evaluating headings, metadata, internal linking, and page structure to surface actionable optimisation insights.&lt;/li&gt;
&lt;li&gt;Automated Brand Assessment &amp;ndash; checking tone, messaging, and consistency against brand guidelines at publish time.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;By combining these agents into a single workflow, teams can receive immediate, automated feedback on published content, reducing manual review effort while keeping the integration scalable, flexible, and CMS-agnostic.&lt;/p&gt;
&lt;h3&gt;Other Considerations&lt;/h3&gt;
&lt;p&gt;When building integrations like this, it&amp;rsquo;s important to consider security and resilience from the outset. As part of this solution, you should consider the following best practices:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Protect the Integration API
&lt;ul&gt;
&lt;li&gt;Register the Content Graph webhook with an API key passed via the query string and validate it in the Integration API.&lt;/li&gt;
&lt;li&gt;This helps prevent unauthorised requests if the endpoint URL is discovered.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Secure the Opal Workflow webhook
&lt;ul&gt;
&lt;li&gt;Configure the authorization parameters on the Opal Workflow webhook trigger.&lt;/li&gt;
&lt;li&gt;Ensure the Integration API includes the required authorization details when invoking the workflow.&lt;/li&gt;
&lt;li&gt;This prevents third-party actors from triggering workflows if the webhook URL is exposed.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Harden the Integration API
&lt;ul&gt;
&lt;li&gt;Implement structured logging to support monitoring and diagnostics.&lt;/li&gt;
&lt;li&gt;Add robust error handling and validation.&lt;/li&gt;
&lt;li&gt;Use retry mechanisms to handle transient failures when calling external services.&lt;/li&gt;
&lt;li&gt;Apply brute-force and rate-limiting protections to reduce abuse risk.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;References&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.developers.optimizely.com/platform-optimizely/docs/manage-webhooks&quot;&gt;Optimizely Developer Documentation - Content Graph - Manage Webhooks&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;/link/265af866ee474623ac29926f5540b4c6.aspx&quot;&gt;A day in the life of an Optimizely OMVP - Optimizely Opal: Specialized Agents, Workflows, and Tools Explained&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://webhook.cool/&quot;&gt;&lt;strong&gt;Webhook&lt;/strong&gt;Cool&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;p&gt;I am an OMVP and the author and maintainer of&amp;nbsp;&lt;a title=&quot;Stott Security&quot; href=&quot;https://github.com/GeekInTheNorth/Stott.Security.Optimizely&quot;&gt;Stott Security&lt;/a&gt;&amp;nbsp;and&amp;nbsp;&lt;a title=&quot;Stott Robots Handler&quot; href=&quot;https://github.com/GeekInTheNorth/Stott.Optimizely.RobotsHandler&quot;&gt;Stott Robots Handler&lt;/a&gt;&amp;nbsp;for Optimizely CMS 12. You can find all of my content collated on&amp;nbsp;&lt;a title=&quot;The blog website for Mark Stott.&quot; href=&quot;https://www.stott.pro/&quot;&gt;https://www.stott.pro/&lt;/a&gt;&amp;nbsp;&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/mark-stott/dates/2026/1/triggering-opal-workflows-when-publishing-content/</guid>            <pubDate>Mon, 05 Jan 2026 09:05:42 GMT</pubDate>           <category>Blog post</category></item><item> <title>Creating Opal Tools Using The C# SDK</title>            <link>https://world.optimizely.com/blogs/mark-stott/dates/2025/12/creating-opal-tools-using-the-c-sdk/</link>            <description>&lt;p&gt;Over the last few months, my colleagues at Netcel and I have partaken in two different challenge events organised by Optimizely and centered around Opal. Opal is Optimizely&amp;rsquo;s agentic marketing AI, deeply integrated across the Optimizely One platform. What transforms Opal from a conversational assistant into a true content-orchestration engine is its use of Agents, Workflows and Opal Tools. Opal tools are APIs that allow Opal to perform functional tasks that would otherwise fall outside the AI&amp;rsquo;s native capabilities. Throughout August, our teams were looking at building Opal Tools as part of the Optimizely Opal Hackathon. Throughout November we took part in the Opal Innovation Challenge which saw us leveraging new capabilities using Opal Tools, Specialized Agents, Instructions and Workflows.&lt;/p&gt;
&lt;p&gt;Optimizely currently provides SDKs in three languages for building Opal Tools: C#, JavaScript, and Python. For C#, Optimizely offers two separate SDKs:&amp;nbsp;&lt;strong&gt;OptimizelyOpal.OpalToolsSDK&lt;/strong&gt;&amp;nbsp;and&amp;nbsp;&lt;strong&gt;Optimizely.Opal.Tools&lt;/strong&gt;. At the time of writing, the documentation and training materials primarily reference&amp;nbsp;&lt;strong&gt;OptimizelyOpal.OpalToolsSDK&lt;/strong&gt;, which only reached version 0.1.0 in May 2025. In contrast,&amp;nbsp;&lt;strong&gt;Optimizely.Opal.Tools&lt;/strong&gt;&amp;nbsp;appears to be receiving more active development, with version 0.4.0 being released in September 2025.&lt;/p&gt;
&lt;p&gt;Both C# SDKs can deliver Opal tools, we used&amp;nbsp;&lt;strong&gt;OptimizelyOpal.OpalToolsSDK&lt;/strong&gt;&amp;nbsp;as part of the Hackathon and&amp;nbsp;&lt;strong&gt;Optimizely.Opal.Tools&lt;/strong&gt; as part of the innovation challenge. There are some differences in terms of capabilities for both SDKs which are as follows:&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; border-width: 1px;&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;background-color: rgb(206, 212, 217); border: 1px solid rgb(126, 140, 141);&quot;&gt;Feature&lt;/td&gt;
&lt;td style=&quot;background-color: rgb(206, 212, 217); border: 1px solid rgb(126, 140, 141); text-align: center;&quot;&gt;OptimizelyOpal.OpalToolsSDK&lt;/td&gt;
&lt;td style=&quot;background-color: rgb(206, 212, 217); border: 1px solid rgb(126, 140, 141); text-align: center;&quot;&gt;Optimizely.Opal.Tools&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid rgb(126, 140, 141);&quot;&gt;Automatic Discovery API&lt;/td&gt;
&lt;td style=&quot;border: 1px solid rgb(126, 140, 141); text-align: center;&quot;&gt;✔&lt;/td&gt;
&lt;td style=&quot;border: 1px solid rgb(126, 140, 141); text-align: center;&quot;&gt;✔&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid rgb(126, 140, 141);&quot;&gt;Tools API&lt;/td&gt;
&lt;td style=&quot;border: 1px solid rgb(126, 140, 141); text-align: center;&quot;&gt;✔&lt;/td&gt;
&lt;td style=&quot;border: 1px solid rgb(126, 140, 141); text-align: center;&quot;&gt;✔&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid rgb(126, 140, 141);&quot;&gt;Supports Tool Authentication&lt;/td&gt;
&lt;td style=&quot;border: 1px solid rgb(126, 140, 141); text-align: center;&quot;&gt;✔&lt;/td&gt;
&lt;td style=&quot;border: 1px solid rgb(126, 140, 141); text-align: center;&quot;&gt;✔&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid rgb(126, 140, 141);&quot;&gt;Supports Dependency Injection&lt;/td&gt;
&lt;td style=&quot;border: 1px solid rgb(126, 140, 141); text-align: center;&quot;&gt;✘&lt;/td&gt;
&lt;td style=&quot;border: 1px solid rgb(126, 140, 141); text-align: center;&quot;&gt;✔&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid rgb(126, 140, 141);&quot;&gt;Supports Island Functionality&lt;/td&gt;
&lt;td style=&quot;border: 1px solid rgb(126, 140, 141); text-align: center;&quot;&gt;✘&lt;/td&gt;
&lt;td style=&quot;border: 1px solid rgb(126, 140, 141); text-align: center;&quot;&gt;✔&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;While there isn&amp;rsquo;t a lot between them and both are pre-version 1.x, &lt;strong&gt;Optimizely.Opal.Tools&lt;/strong&gt; is the stronger and more flexible offering.&lt;/p&gt;
&lt;h2&gt;Getting Started&lt;/h2&gt;
&lt;p&gt;This guide assumes you are familiar with C# and creating basic web applications. In your IDE of choice, create a new C# Web API project and install the&amp;nbsp;&lt;a title=&quot;Optimizely.Opal.Tools on nuget.org&quot; href=&quot;https://www.nuget.org/packages/Optimizely.Opal.Tools/&quot;&gt;Optimizely.Opal.Tools&lt;/a&gt;&amp;nbsp;nuget package&lt;/p&gt;
&lt;h3&gt;Creating a Tool&lt;/h3&gt;
&lt;p&gt;In this example, we&amp;rsquo;ll create a simple &amp;ldquo;Hello World&amp;rdquo; tool. Every Opal Tool&amp;nbsp;&lt;strong&gt;must&lt;/strong&gt;&amp;nbsp;have a parameters class, which can contain zero or more properties. Start by creating a new class with a single property named FirstName. If you decorate the property with the&amp;nbsp;&lt;strong&gt;[Required]&lt;/strong&gt;&amp;nbsp;attribute, Opal will know that this parameter must always be provided. You should also add a&amp;nbsp;&lt;strong&gt;[Description]&lt;/strong&gt; attribute to help Opal understand the purpose of the property. Clear descriptions improve Opal&amp;rsquo;s ability to supply correct values.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;public class HelloWorldParameters
{
    [Required]
    [Description(&quot;The first name of the person to greet.&quot;)]
    public string FirstName { get; set; } = string.Empty;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Next, create a class that serves as the entry point for your tool. Add a public method that returns either an&amp;nbsp;&lt;strong&gt;object&lt;/strong&gt;&amp;nbsp;or&amp;nbsp;&lt;strong&gt;Task&amp;lt;object&amp;gt;&lt;/strong&gt;&amp;nbsp;if your code can run asynchronously. Decorate the method with the&amp;nbsp;&lt;strong&gt;[OpalTool]&lt;/strong&gt;&amp;nbsp;attribute to declare the tool&amp;rsquo;s name (using snake_case or kebab-case). Decorate the method with the&amp;nbsp;&lt;strong&gt;[Description]&lt;/strong&gt; attribute to explain when and how Opal should use it. Whatever you return must be serializable to JSON.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;public class HelloWorldTools(IHelloWorldService service)
{
    [OpalTool(&quot;say-hello-world&quot;)]
    [Description(&quot;Says hello based on a provided first name&quot;)]
    public object HelloWorld(HelloWorldParameters parameters)
    {
        return new
        {
            Message = service.SayHello(parameters.FirstName)
        };
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here we&amp;rsquo;re using&amp;nbsp;&lt;strong&gt;dependency injection&lt;/strong&gt;&amp;nbsp;to provide an&amp;nbsp;&lt;strong&gt;IHelloWorldService&lt;/strong&gt;. This is supported only in the&amp;nbsp;&lt;strong&gt;Optimizely.Opal.Tools&lt;/strong&gt;&amp;nbsp;SDK. You could also return a concrete type, but an anonymous object keeps the example simple.&lt;/p&gt;
&lt;p&gt;Now set up your tools in Program.cs or Startup.cs. The&amp;nbsp;&lt;strong&gt;AddOpalToolService()&lt;/strong&gt;&amp;nbsp;extension registers the tool infrastructure and must be called before registering any tools. Then call&amp;nbsp;&lt;strong&gt;AddOpalTool&amp;lt;ToolClass&amp;gt;()&lt;/strong&gt;&amp;nbsp;for each tool class. Finally, use&amp;nbsp;&lt;strong&gt;MapOpalTools()&lt;/strong&gt; to make the endpoints available in the request pipeline:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;var builder = WebApplication.CreateBuilder(args);

// Register tool dependencies:
builder.Services.AddScoped&amp;lt;IHelloWorldService, HelloWorldService&amp;gt;();

// Register Opal Tool Service and Tools
builder.Services.AddOpalToolService();
builder.Services.AddOpalTool&amp;lt;HelloWorldTools&amp;gt;();

var app = builder.Build();

// Add the Opal tools into the HTTP request pipeline
app.MapOpalTools();

app.Run();&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Testing Your Tools&lt;/h3&gt;
&lt;p&gt;Because Opal Tools are invoked by Opal itself, the only way to test them locally is through API testing tools such as&amp;nbsp;&lt;strong&gt;Postman&lt;/strong&gt;&amp;nbsp;or&amp;nbsp;&lt;strong&gt;HTTP&lt;/strong&gt;&amp;nbsp;files. HTTP files allow you to version-control your test requests and run them directly from your IDE. Requirements for using HTTP files are:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Visual Studio&lt;/strong&gt;: install version 17.14.19 or later (earlier versions offer no or only partial support).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Visual Studio Code&lt;/strong&gt;: install the&amp;nbsp;&lt;a title=&quot;REST Client extension by Huachao Mao&quot; href=&quot;https://marketplace.visualstudio.com/items?itemName=humao.rest-client&quot;&gt;REST Client extension by Huachao Mao&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Here is an example HTTP file for testing both the discovery endpoint and the tool endpoint. Your IDE will render a &amp;ldquo;Send Request&amp;rdquo; link above each request:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;### Variables for all tools
@Tools_HostAddress = https://localhost:8000
@token = dev-token-value
###

### 1. Tool Discovery Endpoint
GET /discovery
Accept: application/json
###

### 2. Say Hello

POST /tools/say-hello-world
Accept: application/json
Content-Type: application/json
Authorization: Bearer 

{
  &quot;parameters&quot;: {
    &quot;FirstName&quot;: &quot;Mark&quot;
  }
}

###&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Executing the discovery endpoint from the HTTP file should provide the following response:&lt;/p&gt;
&lt;pre class=&quot;language-javascript&quot;&gt;&lt;code&gt;{
  &quot;functions&quot;: [
    {
      &quot;name&quot;: &quot;say-hello-world&quot;,
      &quot;description&quot;: &quot;Says hello based on a provided first name&quot;,
      &quot;parameters&quot;: [
        {
          &quot;name&quot;: &quot;FirstName&quot;,
          &quot;type&quot;: &quot;string&quot;,
          &quot;description&quot;: &quot;The first name of the person to greet.&quot;,
          &quot;required&quot;: false
        }
      ],
      &quot;endpoint&quot;: &quot;/tools/say-hello-world&quot;,
      &quot;auth_requirements&quot;: [],
      &quot;http_method&quot;: &quot;POST&quot;
    }
  ]
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Executing the tool endpoint should produce the following response:&lt;/p&gt;
&lt;pre class=&quot;language-javascript&quot;&gt;&lt;code&gt;{
  &quot;message&quot;: &quot;Hello Mark&quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You&amp;rsquo;ll notice from the HTTP file example that the tool endpoint uses an&amp;nbsp;&lt;strong&gt;Authorization&lt;/strong&gt;&amp;nbsp;header while the discovery endpoint does not. This is because the discovery endpoint must allow anonymous access. Tool endpoints can optionally require a&amp;nbsp;&lt;strong&gt;bearer token&lt;/strong&gt;. Note that this token must be static and shared by all tools within the same application. This is because you can only provide a single static value when registering your tools application in Opal.&lt;/p&gt;
&lt;p&gt;Because the SDK controls the mapping of tool endpoints, you cannot apply individual&amp;nbsp;&lt;strong&gt;[Authorize]&lt;/strong&gt;&amp;nbsp;attributes to them. If you want to require bearer-token authentication then you&amp;rsquo;ll need to implement custom middleware. That middleware should intercept requests whose path begins with&amp;nbsp;&lt;strong&gt;/tools/&lt;/strong&gt; and validate that a bearer token is present (and valid) before allowing the request to proceed:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;public sealed class ToolAuthenticationMiddleware
{
    private readonly RequestDelegate _next;

    public ToolAuthenticationMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        // Check if the request is for a tool endpoint
        if (context.Request.Path.StartsWithSegments(&quot;/tools&quot;))
        {
            // Validate that the Authorization header is present and valid...
            if (!context.Request.Headers.TryGetValue(&quot;Authorization&quot;, out var authHeader) || !IsValidToken(authHeader))
            {
                context.Response.StatusCode = StatusCodes.Status401Unauthorized;
                await context.Response.WriteAsync(&quot;Unauthorized&quot;);
                return;
            }
        }

        await _next(context);
    }

    private bool IsValidToken(string authHeader)
    {
        // Validate the bearer token value here...
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Adding Authentication to a Tool&lt;/h3&gt;
&lt;p&gt;The bearer token discussed earlier is used solely to secure communication between Opal and your tool&amp;rsquo;s endpoint. It does not represent the identity of the Opal user. There may be scenarios where your tool needs to operate in the context of the authenticated user who initiated the request. To support this, Optimizely Opal can pass user authentication data to Opal Tools.&lt;/p&gt;
&lt;p&gt;At the time of writing, the only supported authentication provider is &amp;ldquo;OptiId&amp;rdquo;. If your discovery endpoint includes a tool that references an unsupported provider, Opal will fail to process the discovery response. During initial tool registration, Opal provides feedback indicating that the provider is unsupported. If the tool has already been registered and you are syncing updates, Opal will return a success response but silently skip the update; something that can be quite misleading.&lt;/p&gt;
&lt;p&gt;Let&amp;rsquo;s create a new tool that requires user authentication. To do this, you must apply the&amp;nbsp;&lt;strong&gt;[OpalAuthorization]&lt;/strong&gt;&amp;nbsp;attribute to the method, specifying the provider (&amp;ldquo;OptiId&amp;rdquo;), the scope and whether authentication is mandatory. The method signature should include both a custom parameters object (like the previous example) and an instance of&amp;nbsp;&lt;strong&gt;OpalToolContext&lt;/strong&gt;, which contains the user&amp;rsquo;s authentication data and additional request context.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;[OpalTool(&quot;test-auth&quot;)]
[Description(&quot;A tool to test auth&quot;)]
[OpalAuthorization(&quot;OptiId&quot;, &quot;cms&quot;, true)]
public object TestAuthorization(AuthenticationTestParameters parameters, OpalToolContext context)
{
    return new
    {
        Provider = context?.AuthorizationData?.Provider,
        Details = context?.AuthorizationData?.Credentials,
        EmailAddress = parameters?.EmailAddress
    };
}

public class AuthenticationTestParameters
{
    [Description(&quot;The email address of the current opal user making this request.&quot;)]
    [Required]
    public string EmailAddress { get; set; } = string.Empty;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When inspecting the discovery endpoint for this tool, you&amp;rsquo;ll notice that the&amp;nbsp;&lt;strong&gt;auth_requirements&lt;/strong&gt;&amp;nbsp;property is an array. This is intentional as Optimizely intends for Opal to support multiple authentication schemes for a single tool. If you want a tool to accept more than one provider, simply apply multiple&amp;nbsp;&lt;strong&gt;[OpalAuthorization]&lt;/strong&gt; attributes to the method.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;{
  &quot;functions&quot;: [
    {
      &quot;name&quot;: &quot;test-auth&quot;,
      &quot;description&quot;: &quot;A tool to test auth&quot;,
      &quot;parameters&quot;: [
        {
          &quot;name&quot;: &quot;EmailAddress&quot;,
          &quot;type&quot;: &quot;string&quot;,
          &quot;description&quot;: &quot;The email address of the current opal user making this request.&quot;,
          &quot;required&quot;: true
        }
      ],
      &quot;endpoint&quot;: &quot;/tools/test-auth&quot;,
      &quot;auth_requirements&quot;: [
        {
          &quot;provider&quot;: &quot;OptiId&quot;,
          &quot;scope_bundle&quot;: &quot;cms&quot;,
          &quot;required&quot;: true
        }
      ],
      &quot;http_method&quot;: &quot;POST&quot;
    }
  ]
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When Opal executes this tool, it sends the request body (shown below) to your endpoint. Notice that we decorated&amp;nbsp;&lt;strong&gt;AuthenticationTestParameters.EmailAddress&lt;/strong&gt;&amp;nbsp;with a&amp;nbsp;&lt;strong&gt;[Description]&lt;/strong&gt;&amp;nbsp;attribute of&amp;nbsp;&lt;em&gt;&amp;ldquo;The email address of the current Opal user making this request.&amp;rdquo;&lt;/em&gt;. Opal understands this description and will populate&amp;nbsp;&lt;strong&gt;parameters.EmailAddress&lt;/strong&gt; automatically based on the current user without any intervention being needed. This really highlights the ability for Opal to infer, supply, and validate contextual user data without intervention from the user or the tool. It can also give you a means to feed back to the user asynchronously using email.&lt;/p&gt;
&lt;pre class=&quot;language-javascript&quot;&gt;&lt;code&gt;{
  &quot;parameters&quot;: {
    &quot;EmailAddress&quot;: &quot;jo.bloggs@example.com&quot;
  },
  &quot;auth&quot;: {
    &quot;provider&quot;: &quot;OptiID&quot;,
    &quot;credentials&quot;: {
      &quot;token_type&quot;: &quot;Bearer&quot;,
      &quot;access_token&quot;: &quot;&amp;lt;access-token-1446-chars&amp;gt;&quot;,
      &quot;org_sso_id&quot;: &quot;&amp;lt;id&amp;gt;&quot;,
      &quot;cfg_client_id&quot;: &quot;&quot;,
      &quot;user_id&quot;: &quot;&amp;lt;guid-no-hyphens&amp;gt;&quot;,
      &quot;instance_id&quot;: &quot;&amp;lt;guid-no-hyphens&amp;gt;&quot;,
      &quot;customer_id&quot;: &quot;&amp;lt;guid-no-hyphens&amp;gt;&quot;,
      &quot;product_sku&quot;: &quot;OPAL&quot;
    }
  },
  &quot;environment&quot;: {
    &quot;execution_mode&quot;: &quot;interactive&quot;
  },
  &quot;chat_metadata&quot;: {
    &quot;thread_id&quot;: &quot;&amp;lt;guid&amp;gt;&quot;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Summary&lt;/h2&gt;
&lt;p&gt;In this article, we explored how to build Opal Tools with the&amp;nbsp;&lt;strong&gt;Optimizely.Opal.Tools&lt;/strong&gt;&amp;nbsp;SDK, define parameters, handle authentication, and secure tool endpoints. You should now have a clear understanding of how Opal discovers, registers, and executes tools, as well as how user and bearer authentication data flow into your tool methods. With these fundamentals, you&amp;rsquo;re ready to create secure, functional tools that extend Opal&amp;rsquo;s capabilities.&lt;/p&gt;
&lt;p&gt;Key Points:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Choose&amp;nbsp;&lt;strong&gt;Optimizely.Opal.Tools&lt;/strong&gt;&amp;nbsp;over&amp;nbsp;&lt;strong&gt;OptimizelyOpal.OpalToolsSDK&lt;/strong&gt;&amp;nbsp;- The newer SDK is more mature and feature rich.&lt;/li&gt;
&lt;li&gt;All Opal Tool methods&amp;nbsp;&lt;strong&gt;require&lt;/strong&gt;&amp;nbsp;a parameters class - If your tool takes no parameters, you must define an empty parameters class.&lt;/li&gt;
&lt;li&gt;Good descriptions are critical for Opal&amp;rsquo;s understanding - Use&amp;nbsp;&lt;strong&gt;[Description]&lt;/strong&gt;&amp;nbsp;attributes effectively to get the best results.&lt;/li&gt;
&lt;li&gt;Discovery endpoints must allow anonymous access, tool endpoints should be secured with a bearer token.&lt;/li&gt;
&lt;li&gt;Use&amp;nbsp;&lt;strong&gt;[OpalAuthorization]&lt;/strong&gt;&amp;nbsp;and&amp;nbsp;&lt;strong&gt;OpalToolContext&lt;/strong&gt;&amp;nbsp;for Opal Tools which require an authorized user context.&lt;/li&gt;
&lt;li&gt;Use&amp;nbsp;&lt;strong&gt;HTTP&lt;/strong&gt; files for local testing of your discovery and tool endpoints.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;p&gt;I am an OMVP and the author and maintainer of&amp;nbsp;&lt;a title=&quot;Stott Security&quot; href=&quot;https://github.com/GeekInTheNorth/Stott.Security.Optimizely&quot;&gt;Stott Security&lt;/a&gt;&amp;nbsp;and&amp;nbsp;&lt;a title=&quot;Stott Robots Handler&quot; href=&quot;https://github.com/GeekInTheNorth/Stott.Optimizely.RobotsHandler&quot;&gt;Stott Robots Handler&lt;/a&gt;&amp;nbsp;for Optimizely CMS 12. You can find all of my content collated on&amp;nbsp;&lt;a title=&quot;The blog website for Mark Stott.&quot; href=&quot;https://www.stott.pro/&quot;&gt;https://www.stott.pro/&lt;/a&gt;&amp;nbsp;&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/mark-stott/dates/2025/12/creating-opal-tools-using-the-c-sdk/</guid>            <pubDate>Wed, 03 Dec 2025 09:16:55 GMT</pubDate>           <category>Blog post</category></item><item> <title>Creating Opal Tools for Stott Robots Handler</title>            <link>https://world.optimizely.com/blogs/mark-stott/dates/2025/9/creating-opal-tools-for-stott-robots-handler/</link>            <description>&lt;p&gt;This summer, the Netcel Development team and I took part in Optimizely&amp;rsquo;s Opal Hackathon. The challenge from Optimizely was to extend Opal&amp;rsquo;s abilities by creating tools that would wrap actual business flows allowing Opal to focus on inputs and outputs in a conversational context. Our initial submission was to develop event management tooling that would integrate with Optimizely SAAS Content Management System, Optimizely Content Management Platform and Eventbrite.&lt;/p&gt;
&lt;p&gt;Optimizely has created SDKs in C#, JavaScript and Python to accelerate the process of developing Opal Tools, we opted to use the C# SDK due to language familiarity. This SDK required us to create static classes and methods that performed the tool actions while the SDK itself managed the routing for the tools and provided the discovery endpoint in its entirety. After delivering several tools for the hackathon I reflected on the SDK and how it was achieving our goals. As the routing was managed by the SDK, it really limited our ability to create our own controllers or to consider other hosting options such as Azure Functions. As the owner and maintainer of two Optimizely Add-ons I started to think about how this could work with PaaS CMS Add-ons.&lt;/p&gt;
&lt;p&gt;I concluded that if I wanted to add Opal tools to my Add-ons that I would have to consider not using the SDK at all for the following reasons:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;To avoid conflict with CMS Implementations that have tools as part of their delivery.&lt;/li&gt;
&lt;li&gt;To avoid conflict with other CMS Add-ons that also attempted to use the SDK.&lt;/li&gt;
&lt;li&gt;To keep Discovery and Tool endpoints in the same routing structure as the rest of my Add-on.&lt;/li&gt;
&lt;li&gt;To apply custom bearer token validation and controller responses.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Opal Tool Requirements&lt;/h2&gt;
&lt;p&gt;When we consider that Opal Tools are simply REST APIs with a specific JSON requirement, we realise that this is something we have been delivering for years. There are essentially two flavours of endpoint; the discovery endpoint which describes your tools to Opal and then your tool endpoints themselves.&lt;/p&gt;
&lt;h3&gt;Discovery Endpoint&lt;/h3&gt;
&lt;p&gt;The discovery endpoint must return a JSON object containing an array of functions. Each function in this array describes a tool as below. Each entry must specify a name, description, endpoint, an array of parameters and a desired HTTP method. The description is especially important here as it will help Opal understand the scope and intent of your tool, getting this right is essential.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;name&lt;/strong&gt;: This is the name of your tool and should be all one word and unique.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;description&lt;/strong&gt;: This description will help Opal understand the intent for your tool, it&amp;rsquo;s important that this is meaningful.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;parameters&lt;/strong&gt;: This is a list of parameters Opal should send to your endpoint.
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;name&lt;/strong&gt;: This is the name of the parameter, this should match the case you expect to receive the data on.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;description&lt;/strong&gt;: This description will help Opal understand how and what it should pass into this parameter.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;type&lt;/strong&gt;: This tells Opal what type of data it should send you.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;required&lt;/strong&gt;: This tells Opal if it should or could provide this parameter.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;endpoint&lt;/strong&gt;: This should be the endpoint for your API and &lt;strong&gt;must&lt;/strong&gt; be relative to the &lt;strong&gt;discovery&lt;/strong&gt; endpoint.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;http_method&lt;/strong&gt;: This tells Opal what HTTP method to use.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;auth_requirements&lt;/strong&gt;: This is an &lt;strong&gt;optional&lt;/strong&gt; field and can be entirely omitted. Only populate this if you need an authentication to run your tool either from Opal or another identity provider.
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;provider&lt;/strong&gt;: The name of the identity provider.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;scope_bundle&lt;/strong&gt;: The permission scope requested.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;required&lt;/strong&gt;: An indicator as to whether this authentication method is required or not.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;GET: /discovery&lt;br /&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;pre class=&quot;language-javascript&quot;&gt;&lt;code&gt;{
    &quot;functions&quot;: [
        {
            &quot;name&quot;: &quot;myuniquetoolname&quot;,
            &quot;description&quot;: &quot;This is a description of what this tool will do.&quot;,
            &quot;parameters&quot;: [
                {
                    &quot;name&quot;: &quot;parameterOne&quot;,
                    &quot;type&quot;: &quot;string&quot;,
                    &quot;description&quot;: &quot;A description of what data should be passed into this parameter.&quot;,
                    &quot;required&quot;: false
                }
            ],
            &quot;endpoint&quot;: &quot;/tools/tool-api-endpoint&quot;,
            &quot;http_method&quot;: &quot;POST&quot;,
            &quot;auth_requirements&quot;: [
                {
                    &quot;provider&quot;: &quot;OptiID&quot;,
                    &quot;scope_bundle&quot;: &quot;tasks&quot;,
                    &quot;required&quot;: true
                }
            ]
        }
    ]
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;&#128161; Tip: Opal will not include the Authorization header when making a request to the discovery endpoint, so make sure your implementation is accessible anonymously.&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;Tool Endpoint&lt;/h3&gt;
&lt;p&gt;The endpoint for a tool must be relative to the discovery endpoint, i.e. it must exist beneath the discovery endpoint. If we assume that the discovery endpoint responds on &lt;strong&gt;https://www.example.com/path-one/discovery&lt;/strong&gt;, and a tool has an endpoint of &lt;strong&gt;/tools/tool-one&lt;/strong&gt; then Opal will send the request to &lt;strong&gt;https://www.example.com/path-one/tools/tool-one&lt;/strong&gt;. It should be noted that if you register the with a trailing slash like &lt;strong&gt;https://www.example.com/path-one/discovery/&lt;/strong&gt;, then Opal will send the request to &lt;strong&gt;https://www.example.com/path-one/discovery/tools/tool-one&lt;/strong&gt; instead. Because of this, you may want to check on any redirect rules within your solution that force trailing slashes etc.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;parameters&lt;/strong&gt;: This will be a JSON object that has properties matching the defined tool parameters.
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;parameterOne&lt;/strong&gt;: This is just an example parameter; your own parameters defined in the discovery endpoint will appear here.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;auth&lt;/strong&gt;: This is an optional object and will only be provided if the tool has been specified as requiring a specific authentication.
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;provider&lt;/strong&gt;: This will be the name of the authentication provider.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;credentials&lt;/strong&gt;: These are the specific authentication details.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Additional data items are also included as &lt;strong&gt;environment&lt;/strong&gt; and &lt;strong&gt;chat_metadata&lt;/strong&gt;, but these are not essential for the operation of your tool and could be useful in tracing operations. In the following example, a tool has been declared as having OptiId as an authentication requirement.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;POST: /tools/tool-name&lt;br /&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;pre class=&quot;language-javascript&quot;&gt;&lt;code&gt;{
	&quot;parameters&quot;: {
		&quot;parameterOne&quot;: &quot;Some value&quot;
	},
	&quot;auth&quot;: {
		&quot;provider&quot;: &quot;OptiID&quot;,
		&quot;credentials&quot;: {
			&quot;token_type&quot;: &quot;Bearer&quot;,
			&quot;access_token&quot;: &quot;...&quot;,
			&quot;org_sso_id&quot;: null,
			&quot;user_id&quot;: &quot;...&quot;,
			&quot;instance_id&quot;: &quot;...&quot;,
			&quot;customer_id&quot;: &quot;...&quot;,
			&quot;product_sku&quot;: &quot;OPAL&quot;
		}
	},
	&quot;environment&quot;: {
		&quot;execution_mode&quot;: &quot;interactive&quot;
	},
	&quot;chat_metadata&quot;: {
		&quot;thread_id&quot;: &quot;e597710c-2d10-4f07-9817-6fad9f2b748d&quot;
	}
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Implementing Opal Tools&lt;/h2&gt;
&lt;p&gt;Implementing the discovery endpoint itself is a simple. The C# SDK for example uses reflection and understands the attributes you decorate on your tools. As I&amp;rsquo;m not using an SDK in this scenario I could either ship a JSON object with my code, or in this case create the classes that achieve the same outcome. I opted to create the DTO objects that would be serialized into the desired JSON structure. I decorated the controller action with the &lt;strong&gt;HttpGet&lt;/strong&gt;&amp;nbsp;and &lt;strong&gt;AllowAnonymous&lt;/strong&gt;&amp;nbsp;attributes to ensure that the endpoint was publicly accessible for GET requests only.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;[HttpGet]
[AllowAnonymous]
[Route(&quot;/stott.robotshandler/opal/discovery/&quot;)]
public IActionResult Discovery()
{
    var model = new FunctionsRoot { Functions = new List&amp;lt;Function&amp;gt;() };

    model.Functions.Add(new Function
    {
        Name = &quot;getrobottxtconfigurations&quot;,
        Description = &quot;Get a collection of robot.txt configurations optionally filtered by host name.&quot;,
        Parameters = new List&amp;lt;FunctionParameter&amp;gt;
        {
            new FunctionParameter
            {
                Name = &quot;hostName&quot;,
                Type = &quot;string&quot;,
                Description = &quot;The host name to filter the robot.txt configurations by.&quot;,
                Required = false
            }
        },
        Endpoint = &quot;/tools/get-robot-txt-configurations/&quot;,
        HttpMethod = &quot;POST&quot;
    });

    // More tools defined here...

    return CreateSafeJsonResult(model);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;As I was aiming to support multiple endpoints with the same model structure, I used generics to create a wrapping object using the specific content model required by my endpoint. This is then added as a parameter to my controller actions with the &lt;strong&gt;FromBody&lt;/strong&gt; attribute to ensure the model was pulled from the request body. Note that it is worth specifying the &lt;strong&gt;JsonPropertyName&lt;/strong&gt; attributes as you cannot guarantee the serialization options of the hosting solution.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;public class ToolRequest&amp;lt;TModel&amp;gt; where TModel : class
{
    [JsonPropertyName(&quot;parameters&quot;)]
    public TModel Parameters { get; set; }
}

public class GetRobotTextConfigurationsQuery
{
    [JsonPropertyName(&quot;hostName&quot;)]
    public string HostName { get; set; }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If your tool needs to have authentication passed into it, then you can extend this wrapping object to contain the authentication data like so:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;public class AuthenticatedToolRequest&amp;lt;TModel&amp;gt; where TModel : class
{
    [JsonPropertyName(&quot;parameters&quot;)]
    public TModel Parameters { get; set; }

    [JsonPropertyName(&quot;auth&quot;)]
    public AuthData Auth { get; set; }
}

public class AuthData
{
    [JsonPropertyName(&quot;provider&quot;)]
    public string Provider { get; set; } = string.Empty;

    [JsonPropertyName(&quot;credentials&quot;)]
    public Dictionary&amp;lt;string, object&amp;gt; Credentials { get; set; } = new Dictionary&amp;lt;string, object&amp;gt;();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In the following example, I have decorated my controller with the HttpPost attribute and then two separate Route attributes. This helps my controller respond to both possible request paths, depending on whether the discovery endpoint was registered with a trailing slash or not. I have then used the generic &lt;strong&gt;ToolRequest&amp;lt;T&amp;gt;&lt;/strong&gt; class to wrap my specific model of &lt;strong&gt;GetRobotTextConfigurationsQuery&lt;/strong&gt; as the parameter for the method.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;[HttpPost]
[Route(&quot;/stott.robotshandler/opal/tools/get-robot-txt-configurations/&quot;)]
[Route(&quot;/stott.robotshandler/opal/discovery/tools/get-robot-txt-configurations/&quot;)]
[OpalAuthorization(OpalScopeType.Robots, OpalAuthorizationLevel.Read)]
public IActionResult GetRobotTxtConfigurations([FromBody] ToolRequest&amp;lt;GetConfigurationsQuery&amp;gt; model)
{
    try
    {
        var configurations = _service.GetAll();
        if (!string.IsNullOrWhiteSpace(model?.Parameters?.HostName))
        {
            var hostName = model.Parameters.HostName.Trim();
            var specificConfiguration =
                configurations.FirstOrDefault(x =&amp;gt; string.Equals(x.SpecificHost, hostName, StringComparison.OrdinalIgnoreCase)) ??
                configurations.FirstOrDefault(x =&amp;gt; x.AvailableHosts.Any(h =&amp;gt; string.Equals(h.HostName, hostName, StringComparison.OrdinalIgnoreCase)));

            if (specificConfiguration is null)
            {
                return Json(new
                {
                    Success = false,
                    Message = $&quot;Could not locate a robots.txt config that matched the host name of {model.Parameters.HostName}.&quot;
                });
            }

            return Json(ConvertToModel(specificConfiguration, hostName, x =&amp;gt; x.RobotsContent));
        }

        return Json(ConvertToModels(configurations, x =&amp;gt; x.RobotsContent));
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, &quot;An error was encountered while processing the getrobottxtconfigurations tool.&quot;);
        throw;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You may have noticed that my controller action also has an &lt;strong&gt;OpalAuthorization&lt;/strong&gt; attribute. When you register your tools in Opal with the discovery endpoint, you have the option to provide a bearer token that will be sent in an Authorization header. This is a custom attribute that checks for the presence of an Authorization header with a bearer token and checks it against a user defined bearer token within my Add-on. In my Add-on I allow the user to define multiple bearer tokens with different read/write permissions like so:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://www.stott.pro/assets/robots-handler-opal-tools-2.png&quot; alt=&quot;A screenshot of the create new token modal&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Wrapping Up&lt;/h2&gt;
&lt;p&gt;At the end of the day, Opal tools are just REST APIs with a specific JSON contract. Before you start building, it&amp;rsquo;s worth validating which official SDK (JavaScript, Python, or C#) best suits your project. They provide a quick path to getting up and running. If you find the SDK constraints don&amp;rsquo;t fit your use case, building your own endpoints without an SDK gives you full control over routing, authentication, and integration with your existing codebase. This makes it easier to plug tools into your Optimizely CMS or Add-ons without worrying about conflicts and to consider serverless hosting such as Azure Functions if you are deploying your tools separately.&lt;/p&gt;
&lt;p&gt;If you&amp;rsquo;re a developer working with Optimizely, I&amp;rsquo;d encourage you to give this a try yourself. Start small: build a discovery endpoint, define a tool with one or two parameters, and watch Opal call it directly. Once you see it working end-to-end, you&amp;rsquo;ll have a solid foundation for building more advanced tools tailored to your projects.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;I am an OMVP and the author and maintainer of&amp;nbsp;&lt;a title=&quot;Stott Security&quot; href=&quot;https://github.com/GeekInTheNorth/Stott.Security.Optimizely&quot;&gt;Stott Security&lt;/a&gt;&amp;nbsp;and&amp;nbsp;&lt;a title=&quot;Stott Robots Handler&quot; href=&quot;https://github.com/GeekInTheNorth/Stott.Optimizely.RobotsHandler&quot;&gt;Stott Robots Handler&lt;/a&gt;&amp;nbsp;for Optimizely CMS 12. You can find all of my content collated on&amp;nbsp;&lt;a title=&quot;The blog website for Mark Stott.&quot; href=&quot;https://www.stott.pro/&quot;&gt;https://www.stott.pro/&lt;/a&gt;&amp;nbsp;&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/mark-stott/dates/2025/9/creating-opal-tools-for-stott-robots-handler/</guid>            <pubDate>Sun, 28 Sep 2025 08:22:40 GMT</pubDate>           <category>Blog post</category></item><item> <title>Raising and Retrieving Custom Events in Application Insights</title>            <link>https://world.optimizely.com/blogs/mark-stott/dates/2025/9/custom-search-term-tracking-with-application-insights/</link>            <description>&lt;p&gt;Following on from Minesh&#39;s insight on how to extend &lt;a title=&quot;Extending Application Insights in an Optimizely PaaS CMS Solution&quot; href=&quot;/link/36657c9c100d4c1186844783f52ecef2.aspx&quot;&gt;Extending Application Insights in an Optimizely PaaS CMS Solution&lt;/a&gt;, I&#39;d like to share another way you can use Application Insights to improve your offering.&amp;nbsp; Application Insights has the ability to record and report on Custom Events that you raise within your application and raising an event is easy.&amp;nbsp; I&#39;m using this mechanism to track search terms without using a database and the examples below are about raising custom events during search and then retrieving the data back out and aggregating it.&lt;/p&gt;
&lt;p&gt;Before you can get started with recording your own custom events, you need to prepare your application:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Install the&amp;nbsp;&lt;strong&gt;Microsoft.ApplicationInsights.AspNetCore&lt;/strong&gt; package&lt;/li&gt;
&lt;li&gt;Retrieve and add the following settings from your Application Insights instance and add them to your appsettings.json:
&lt;ul&gt;
&lt;li&gt;ApplicationInsights__ConnectionString&lt;/li&gt;
&lt;li&gt;ApplicationInsights__apiKey&lt;/li&gt;
&lt;li&gt;ApplicationInsights__AppId&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Raising an Event&lt;/h2&gt;
&lt;p&gt;Raising an event is performed through the &lt;strong&gt;TelemetryClient &lt;/strong&gt;which is part of the &lt;strong&gt;Microsoft.ApplicationInsights.AspNetCore&lt;/strong&gt; package.&amp;nbsp; The &lt;strong&gt;TrackEvent &lt;/strong&gt;method takes two parameters, the name of your event and a &lt;strong&gt;dictionary&amp;lt;string, string&amp;gt;&lt;/strong&gt; of custom data.&amp;nbsp; The &lt;strong&gt;TelemetryClient &lt;/strong&gt;will automatically add data such as the HTTP Method and Request Path along with other interesting information so you can focus on the details that matter to you.&amp;nbsp; It goes without saying that you should not include PII data in your event data.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;using Microsoft.ApplicationInsights;
using Microsoft.AspNetCore.Mvc;

public sealed class SearchController(ISearchService searchService, TelemetryClient telemetryClient) : Controller
{
    public async Task&amp;lt;IActionResult&amp;gt; Search(string? query)
    {
        LogTrackSearch(query);

        // Search Logic Goes Here
        var results = searchService.Search(query);

        return Json(results);
    }

    private void LogTrackSearch(string? query)
    {
        if (query is { Length: &amp;gt;3 })
        {
            telemetryClient.TrackEvent(&quot;TrackSearch&quot;, new Dictionary&amp;lt;string, string&amp;gt; { { &quot;Query&quot;, query } });
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You can then search for you custom events directly in the Logs section of Application Insights:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/f93a2c67e4434b568e6da5a8b192d689.aspx&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Retrieving Events&lt;/h2&gt;
&lt;p&gt;Now you have your events logged in Application Insights, you may want to retrieve this data back out to present it to an administrator.&amp;nbsp; I strongly recommend you only use this functionality on an administration screen and do not use it to power the frontend of your website.&lt;/p&gt;
&lt;p&gt;The first thing you will want to do is design your query, I recommend you do that directly in the Logs tool in Application Inisights.&amp;nbsp; For my query, I only wanted data relating to my specific event type named &quot;TrackSearch&quot; and I wanted to aggregate that data by unique &lt;strong&gt;Query&lt;/strong&gt; value and include a count of instances as &lt;strong&gt;UniqueCount&lt;/strong&gt;.&amp;nbsp; As a result my query looks like this:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;customEvents
| where name contains &quot;TrackSearch&quot;
| where timestamp &amp;gt; ago(7d)
| where tostring(customDimensions[&quot;Query&quot;]) != &quot;&quot;
| summarize UniqueCount=count() by Query=tolower(tostring(customDimensions[&quot;Query&quot;]))
| project Query, UniqueCount
| order by UniqueCount desc&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In order to retrieve this directly in your application, you need to make a GET request to Application Insights and include an escaped version of your query.&amp;nbsp; You will recieve a response which contains a collection of tables.&amp;nbsp; As my query is summarizing the customEvents table, that is the only populated table I receive in my response.&amp;nbsp; Now the data isn&#39;t as straight forward as you might think.&amp;nbsp; I had to include the following DTOs so I could serialize out the content:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;public sealed class InsightsResponse
{
    [JsonPropertyName(&quot;tables&quot;)]
    public List&amp;lt;InsightsTable&amp;gt;? Tables { get; set; }
}

public sealed class InsightsTable
{
    [JsonPropertyName(&quot;name&quot;)]
    public string? Name { get; set; }

    [JsonPropertyName(&quot;columns&quot;)]
    public List&amp;lt;InsightsColumn&amp;gt;? Columns { get; set; }

    [JsonPropertyName(&quot;rows&quot;)]
    public List&amp;lt;List&amp;lt;object&amp;gt;&amp;gt;? Rows { get; set; }
}

public sealed class InsightsColumn
{
    [JsonPropertyName(&quot;name&quot;)]
    public string? Name { get; set; }

    [JsonPropertyName(&quot;type&quot;)]
    public string? Type { get; set; }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then in my controller I use a &lt;strong&gt;StringBuilder &lt;/strong&gt;to recreate my query, I then make a GET request to application insights and serialize out the response.&amp;nbsp; The data I then have is not very usable in it&#39;s current form, so I have additional logic which then transforms the response data into a collection of TrackSearchTerm objects which is another DTO to contain the flat data I want to present back to the user.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;public sealed class InsightsController(IConfiguration configuration) : Controller
{
    public async Task&amp;lt;IActionResult&amp;gt; SearchTerms(int numberOfDays = 7)
    {
        var stringBuilder = new StringBuilder();
        stringBuilder.AppendLine(&quot;customEvents&quot;);
        stringBuilder.AppendLine(&quot;| where name contains \&quot;TrackSearch\&quot;&quot;);
        stringBuilder.AppendLine($&quot;| where timestamp &amp;gt; ago({numberOfDays}d)&quot;);
        stringBuilder.AppendLine(&quot;| where tostring(customDimensions[\&quot;Query\&quot;]) != \&quot;\&quot;&quot;);
        stringBuilder.AppendLine(&quot;| summarize UniqueCount=count() by Query=tolower(tostring(customDimensions[\&quot;Query\&quot;]))&quot;);
        stringBuilder.AppendLine(&quot;| project Query, UniqueCount&quot;);
        stringBuilder.AppendLine(&quot;| order by UniqueCount desc&quot;);
        
        var query = stringBuilder.ToString();
        var settings = GetApplicationInsightSettings();
        
        // Build the request URL
        string url = $&quot;https://api.applicationinsights.io/v1/apps/{settings.AppId}/query?query={Uri.EscapeDataString(query)}&quot;;

        using var client = new HttpClient();

        // Set up API key in the header
        client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue(&quot;application/json&quot;));
        client.DefaultRequestHeaders.Add(&quot;x-api-key&quot;, settings.ApiKey);

        // Send the request
        var response = await client.GetAsync(url);
        if (response.IsSuccessStatusCode)
        {
            var content = await response.Content.ReadAsStringAsync();

            var data = JsonSerializer.Deserialize&amp;lt;InsightsResponse&amp;gt;(content);
            var searchTerms = GetSearchTerms(data).ToList();

            return Json(searchTerms);
        }

        return Json(Enumerable.Empty&amp;lt;TrackSearchTerm&amp;gt;());
    }

    private static IEnumerable&amp;lt;TrackSearchTerm&amp;gt; GetSearchTerms(InsightsResponse? insightsResponse)
    {
        if (insightsResponse is not { Tables.Count: &amp;gt;0 })
        {
            yield break;
        }
        
        foreach (var table in insightsResponse.Tables)
        {
            var queryIndex = -1;
            var uniqueCountIndex = -1;

            if (table is not { Columns.Count: &amp;gt;0, Rows.Count: &amp;gt;0 })
            {
                continue;
            }

            // Get the numerical index of your column
            foreach (var column in table.Columns)
            {
                queryIndex = string.Equals(column.Name, &quot;Query&quot;, StringComparison.OrdinalIgnoreCase) ? table.Columns.IndexOf(column) : queryIndex;
                uniqueCountIndex = string.Equals(column.Name, &quot;UniqueCount&quot;, StringComparison.OrdinalIgnoreCase) ? table.Columns.IndexOf(column) : uniqueCountIndex;
            }

            foreach (var row in table.Rows)
            {
                yield return new TrackSearchTerm
                {
                    Query = queryIndex &amp;gt;= 0 ? row[queryIndex]?.ToString() : string.Empty,
                    UniqueCount = uniqueCountIndex &amp;gt;= 0 &amp;amp;&amp;amp; int.TryParse(row[uniqueCountIndex]?.ToString(), out var uniqueCount) ? uniqueCount : 1
                };
            }
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You can then present this result directly to your Administor in whatever shape you want.&amp;nbsp; I do recommend adding additional logic to deduplicate search terms as you may end up with multiple events being executed due to delays in user input.&amp;nbsp; For example you may get a responses of &quot;Hello W&quot; and &quot;Hello World&quot; from a single user if a search request is executed during a delay in user input.&amp;nbsp; I haven&#39;t included that code here, but my complete solution includes it.&lt;/p&gt;
&lt;p&gt;You now have all the information you need to start using custom events in application insights for your own needs.&amp;nbsp; Happy Coding &#128522;&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;I am an OMVP and the author and maintainer of&amp;nbsp;&lt;a title=&quot;Stott Security&quot; href=&quot;https://github.com/GeekInTheNorth/Stott.Security.Optimizely&quot;&gt;Stott Security&lt;/a&gt;&amp;nbsp;and&amp;nbsp;&lt;a title=&quot;Stott Robots Handler&quot; href=&quot;https://github.com/GeekInTheNorth/Stott.Optimizely.RobotsHandler&quot;&gt;Stott Robots Handler&lt;/a&gt;&amp;nbsp;for Optimizely CMS 12. You can find all of my content collated on&amp;nbsp;&lt;a title=&quot;The blog website for Mark Stott.&quot; href=&quot;https://www.stott.pro/&quot;&gt;https://www.stott.pro/&lt;/a&gt;&amp;nbsp;&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/mark-stott/dates/2025/9/custom-search-term-tracking-with-application-insights/</guid>            <pubDate>Thu, 25 Sep 2025 08:49:35 GMT</pubDate>           <category>Blog post</category></item><item> <title>Helping Opal Build LLMS.TXT Content</title>            <link>https://world.optimizely.com/blogs/mark-stott/dates/2025/9/helping-opal-build-llms.txt-content</link>            <description>&lt;p&gt;In my previous blog, &lt;a title=&quot;Opal Tools Support Comes to Stott Robots Handler v6&quot; href=&quot;/link/223a9abc82c04fd190094a586ba35393.aspx&quot;&gt;Opal Tools Support Comes to Stott Robots Handler v6&lt;/a&gt;, I shared the news about new Opal Tools being built directly into the Stott Robots Handler AddOn for Optimizely PAAS CMS 12.&amp;nbsp; In practice Opal handles generating and modifying &lt;strong&gt;robots.txt&lt;/strong&gt; content effectively using these tools.&amp;nbsp; However, it currently lacks the ability to structure or generate an valid &lt;strong&gt;llms.txt&lt;/strong&gt; file. This is where instructions can help you achieve success.&lt;/p&gt;
&lt;h2&gt;What Are Instructions?&lt;/h2&gt;
&lt;div class=&quot;_1l4j4na3 u95xkz _1bsc07t tio8pq&quot;&gt;Opal instructions are written in markdown and help guide Opal in it&#39;s tasks. I asked Opal to describe Instructions and what their intent was and Opal had the following to say:&lt;/div&gt;
&lt;div class=&quot;_1l4j4na3 u95xkz _1bsc07t tio8pq&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;_1l4j4na3 u95xkz _1bsc07t tio8pq&quot; style=&quot;padding-left: 20pt;&quot;&gt;Opal Instructions are the guidelines and rules that govern my behavior and responses as the Optimizely AI Assistant. Their primary intent is to ensure that I:&lt;/div&gt;
&lt;ol&gt;
&lt;li style=&quot;list-style-type: none;&quot;&gt;
&lt;ol class=&quot;tio8pq _1l4j4na7&quot;&gt;
&lt;li class=&quot;tio8pq _1l4j4na2&quot;&gt;&lt;strong class=&quot;pl3pgu _183ijnj tio8pq&quot;&gt;Provide specific and actionable guidance:&lt;/strong&gt;&amp;nbsp;Instead of generic advice, I aim to give you concrete steps and relevant information.&lt;/li&gt;
&lt;li class=&quot;tio8pq _1l4j4na2&quot;&gt;&lt;strong class=&quot;pl3pgu _183ijnj tio8pq&quot;&gt;Understand and utilize context:&lt;/strong&gt;&amp;nbsp;I use your current workflow, user information, and the Optimizely product you&#39;re using to tailor my responses.&lt;/li&gt;
&lt;li class=&quot;tio8pq _1l4j4na2&quot;&gt;&lt;strong class=&quot;pl3pgu _183ijnj tio8pq&quot;&gt;Prioritize and use tools effectively:&lt;/strong&gt;&amp;nbsp;The instructions help me decide which tools to use (like creating campaigns, tasks, or searching documentation) and in what order, to best fulfill your request.&lt;/li&gt;
&lt;li class=&quot;tio8pq _1l4j4na2&quot;&gt;&lt;strong class=&quot;pl3pgu _183ijnj tio8pq&quot;&gt;Focus on business outcomes:&lt;/strong&gt;&amp;nbsp;My responses are geared towards helping you achieve measurable results in digital optimization, experimentation, and customer experience.&lt;/li&gt;
&lt;li class=&quot;tio8pq _1l4j4na2&quot;&gt;&lt;strong class=&quot;pl3pgu _183ijnj tio8pq&quot;&gt;Maintain a consistent and helpful persona:&lt;/strong&gt;&amp;nbsp;They guide my communication style, ensuring I use appropriate terminology and offer clear, concise, and thorough explanations.&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;div class=&quot;_1l4j4na3 u95xkz _1bsc07t tio8pq&quot; style=&quot;padding-left: 20pt;&quot;&gt;In essence, these instructions are my operational manual, designed to make me a more effective and helpful assistant for you within the Optimizely platform.&lt;/div&gt;
&lt;h2&gt;Where to Manage Instructions&lt;/h2&gt;
&lt;p&gt;To view instructions, open&amp;nbsp;the Optimizely Opal Interface and click on the instructions button in the left hand menu (book icon).&amp;nbsp; Opal comes with a number of built-in instructions that you can view and use as examples for building your own instructions.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;img src=&quot;/link/4c2b97541e0e4585a90bf85e10ae5948.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;When you click on the Add Instruction Button, you will be provided with this screen, which will allow you to:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Name your Instruction&lt;/li&gt;
&lt;li&gt;Enter the details for your instruction&lt;/li&gt;
&lt;li&gt;Define where the instructions can be used.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;img src=&quot;/link/87038e455f9d4ec3b8bc462035a52dc3.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;You can read more about managing instructions over in Optimizely&#39;s support documentation here:&amp;nbsp;&lt;a title=&quot;Instructions overview&quot; href=&quot;https://support.optimizely.com/hc/en-us/articles/36353487109133-Instructions-overview&quot;&gt;Instructions overview&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;Creating Instructions for Generating LLMS.TXT Content&lt;/h2&gt;
&lt;p&gt;The instructions content should start with an overview, this should give Opal the context of what it will be doing and what it will be expected to do.&amp;nbsp; Then define the execution steps; Each step should be built be built up of one or more bullet points that allow it to complete that step.&lt;/p&gt;
&lt;p&gt;A bullet labeled &lt;strong&gt;**Prompt**&lt;/strong&gt; tells Opal to request specific input from the user. If the user has already provided this in their own prompt, then Opal will recognize this and move onto the next bullet.&amp;nbsp;&amp;nbsp;A bullet labeled with &lt;strong&gt;**Action**&lt;/strong&gt; will provide Opal with the knowledge of the actions it will need to perform before feeding back to the user.&lt;/p&gt;
&lt;p&gt;In the following instructions, I have split the execution into three main steps:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Analysis
&lt;ol&gt;
&lt;li&gt;Request a URL for the home page of the website we are generating the llms.txt content for.&lt;/li&gt;
&lt;li&gt;Review the requested page and generate a title, a synopsis and a collection of links ordered by section.&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;Generation
&lt;ol&gt;
&lt;li&gt;Use the information gained in the Analysis step to generate the llms.txt content using the example detailed in the Generation step as an example&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;Review
&lt;ol&gt;
&lt;li&gt;Show the generated llms.txt content to the user.&lt;/li&gt;
&lt;li&gt;Ask them if they would like to save the llms.txt content and which host name they want to save it for&lt;/li&gt;
&lt;li&gt;Save the llms.txt content using the tool provided by the Stott Robots Handler&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Here is my example instructions content:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;# Overview

When a user asks you to create an llms.txt file for a website.  You will enter an analysis mode for a provided website.  You will review the root page of the website and understand the business title for the website and generate a synopsis and collate a set of links found on that page.

Understanding this information you will then follow the execution steps below.

## Execution Steps

### Step 1 : Analysis
- **Prompt**: Request a home page URL for analysis
- **Action**: Review the content on the page and understand the following information:
  - A title for the website
  - A synopsis for the purpose of the website.
  - A collection of links ordered by section

### Step 2 : Generation

- **Action**: Using the information collated in Step 1. Create markdown content as per the following example structure:

```
# Title

&amp;gt; Optional description goes here

Optional details go here

## Section name

- [Link title](https://link_url): Optional link details
- [Link title](https://link_url): Optional link details
- [Link title](https://link_url): Optional link details

## Optional

- [Link title](https://link_url) : Optional link details
- [Link title](https://link_url): Optional link details
- [Link title](https://link_url): Optional link details
```

### Step 3 : Review

- **Action**: Show the generated llms.txt content to the user
- **Prompt**: Ask the user whether they will like to save the llms.txt content and which hostName they want to save it for
- **Action**: Save the content using the savellmstxtconfigurations tool&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Before I created these instructions, when I asked Opal to generate an &lt;strong&gt;llms.txt file&lt;/strong&gt;, it would typically provide me with a content not too dissimilar to &lt;strong&gt;robots.txt&lt;/strong&gt; style content.&amp;nbsp; Using these instructions I was able to get Opal to scan my personal website and generate a starting &lt;strong&gt;llms.txt&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Note that I have trimmed down this generated output for brevity:&lt;/em&gt;&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;# Title: Mark Stott, Solutions Architect, Optimizely MVP

&amp;gt; This website is the personal blog and portfolio of Mark Stott, a Software Solution Architect and Optimizely MVP. It highlights his professional experience, specializations, and recent technical articles.

## Mark Stott: Software Solution Architect &amp;amp; OMVP

- [GitHub](https://github.com/GeekInTheNorth/)
- [LinkedIn](https://www.linkedin.com/in/mark-antony-stott/)

## Latest Posts

- [Opal Tools Support Comes to Stott Robots Handler v6](https://www.stott.pro/article/stott-robots-handler-v6): Published: 17th September 2025, Description: Stott Robots Handler for Optimizely CMS 12 has been released with added support for Opal Tools. Categories: Development, Optimizely, Stott Robots Handler
- [Simple Feature Flags In Optimizely CMS](https://www.stott.pro/article/simple-feature-flags-in-optimizely-cms): Published: 31st August 2025, Description: A simple approach to feature flagging within Optimizely CMS. Categories: Development, Optimizely
- [LLMS.txt support comes to Stott Robots Handler v5](https://www.stott.pro/article/stott-robots-handler-v5): Published: 8th August 2025, Description: Stott Robots Handler for Optimizely CMS 12 has been released with added support for llms.txt files. Categories: Development, Optimizely, Stott Robots Handler
- [Optimizing Content Security Policies to Stay Within HTTP Header Limits](https://www.stott.pro/article/optimize-your-csp): Published: 1st August 2025, Description: Discover how to audit, reduce, and optimize Content Security Policies. Helping you stay within browser and CDN header size limits. Categories: Development, Optimizely, Stott Security
- [Stott Security Version 3](https://www.stott.pro/article/stott-security-3): Published: 8th April 2025, Description: A summary of all new functionality and changes that have been introduced to the Stott Security module so far. Categories: Development, Optimizely, Stott Security

## Article Categories

- [Optimizely](https://www.stott.pro/article/list/optimizely)
- [Development](https://www.stott.pro/article/list/development)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;In Summary&lt;/h2&gt;
&lt;p&gt;Opal Tools can be used to grant new functionality to Opal, but it is through the power of instructions that you can go from functionality to high impact.&amp;nbsp; With more investment into the &lt;strong&gt;llms.txt&lt;/strong&gt; instructions, you can help Opal craft great content for your &lt;strong&gt;llms.txt&lt;/strong&gt; that is more specific to your business and the websites you release your content on.&amp;nbsp; &lt;strong&gt;Opal Tools&lt;/strong&gt; and &lt;strong&gt;llms.txt&lt;/strong&gt; functionality is available in &lt;strong&gt;Stott Robots Handler v6&lt;/strong&gt; for &lt;strong&gt;Optimizely PAAS CMS 12&lt;/strong&gt; now.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;I am an OMVP and the author and maintainer of&amp;nbsp;&lt;a title=&quot;Stott Security&quot; href=&quot;https://github.com/GeekInTheNorth/Stott.Security.Optimizely&quot;&gt;Stott Security&lt;/a&gt;&amp;nbsp;and&amp;nbsp;&lt;a title=&quot;Stott Robots Handler&quot; href=&quot;https://github.com/GeekInTheNorth/Stott.Optimizely.RobotsHandler&quot;&gt;Stott Robots Handler&lt;/a&gt;&amp;nbsp;for Optimizely CMS 12. You can find all of my content collated on&amp;nbsp;&lt;a title=&quot;The blog website for Mark Stott.&quot; href=&quot;https://www.stott.pro/&quot;&gt;https://www.stott.pro/&lt;/a&gt;&amp;nbsp;&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/mark-stott/dates/2025/9/helping-opal-build-llms.txt-content</guid>            <pubDate>Fri, 19 Sep 2025 07:34:48 GMT</pubDate>           <category>Blog post</category></item><item> <title>Opal Tools Support Comes to Stott Robots Handler v6</title>            <link>https://world.optimizely.com/blogs/mark-stott/dates/2025/9/opal-tools-support-comes-to-stott-robots-handler-v6/</link>            <description>&lt;p&gt;During August, the Netcel team and I took part in the Optimizely Opal Hackathon. &amp;nbsp;The challenge for this hackathon was to generate tools that extend Opal&#39;s abilities within the Optimizely Platform. &amp;nbsp;As a late second entry for our team, I put forward a proposal to add Opal Tools support to an Optimizely AddOn. &amp;nbsp;This work sparked something bigger, and today I&#39;m excited to share that Opal Tools functionality and improved headless support has made it&#39;s way into a production-ready release of Stott Robots Handler v6.&lt;/p&gt;
&lt;p&gt;This update makes it easy to manage your robots.txt and llms.txt content through Opal with secure token management.&lt;/p&gt;
&lt;h2&gt;Managing Bearer Tokens&lt;/h2&gt;
&lt;p&gt;Optimizely&amp;rsquo;s Opal uses&amp;nbsp;&lt;strong&gt;bearer tokens&lt;/strong&gt;&amp;nbsp;for managing secure access to the tools. In version 6, A new tab has been added to the robots interface that allows the user to create and manage their bearer tokens. This screen not only provides supporting details around the available endpoints, but also lets you create multiple tokens, each with it&amp;rsquo;s own permissions for the&amp;nbsp;&lt;strong&gt;robots.txt&lt;/strong&gt;&amp;nbsp;and&amp;nbsp;&lt;strong&gt;llms.txt&lt;/strong&gt;&amp;nbsp;content.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://www.stott.pro/assets/robots-handler-opal-tools-1.png&quot; alt=&quot;A screenshot of the API Token management screen&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Creating a token only takes a few clicks:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Click&amp;nbsp;&lt;strong&gt;Add Token&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Give your token a name.&lt;/li&gt;
&lt;li&gt;Generate it&amp;rsquo;s value.&lt;/li&gt;
&lt;li&gt;Choose permission scopes for&amp;nbsp;&lt;strong&gt;robots.txt&lt;/strong&gt;&amp;nbsp;and&amp;nbsp;&lt;strong&gt;llms.txt&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;It is important to remember that token values are only shown once. Do remember to take a copy of your token value before saving, because once it dissappears, you won&amp;rsquo;t see it again.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://www.stott.pro/assets/robots-handler-opal-tools-2.png&quot; alt=&quot;A screenshot of the create new token modal&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The available scopes are:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;None&lt;/strong&gt;: This grants no access to any endpoint.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Read&lt;/strong&gt;: This grants access to the&amp;nbsp;&lt;strong&gt;Get&lt;/strong&gt;&amp;nbsp;endpoint only.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Write&lt;/strong&gt;: this grants access to both the&amp;nbsp;&lt;strong&gt;Get&lt;/strong&gt;&amp;nbsp;and&amp;nbsp;&lt;strong&gt;Save&lt;/strong&gt;&amp;nbsp;endpoints.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Registering The Tools in Opal&lt;/h2&gt;
&lt;p&gt;Adding the tools into Opal is a straightforward registration process.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Open the Optimizely Opal interface.&lt;/li&gt;
&lt;li&gt;Click Tools (bottom of the left-hand menu).&lt;/li&gt;
&lt;li&gt;From the tools screen, click Add Tool Registry.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;img src=&quot;https://www.stott.pro/assets/robots-handler-opal-tools-3.png&quot; alt=&quot;A screenshot of the Opal Tools interface in Optimizely Opal&quot; /&gt;&lt;/p&gt;
&lt;p&gt;You will see a short form:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Registry Name&lt;/strong&gt;: Any value works; I recommend &amp;ldquo;Robots Handler Tools&amp;rdquo;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Discovery URL&lt;/strong&gt;: On the Robots Handler token screen, use the&amp;nbsp;&lt;strong&gt;Copy URL&lt;/strong&gt;&amp;nbsp;button next to the&amp;nbsp;&lt;strong&gt;Discovery API&lt;/strong&gt;&amp;nbsp;and paste it here.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Bearer Token&lt;/strong&gt;: Paste the token you generated earlier.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://www.stott.pro/assets/robots-handler-opal-tools-4.png&quot; alt=&quot;A screenshot of the Add Tool Registry form&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Using The Tools&lt;/h2&gt;
&lt;p&gt;Once registered, you can start using the Robots Handler tools immediately in Opal&amp;rsquo;s chat interface. Just ask questions or give instructions, such as:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&amp;ldquo;Get me a list of robots.txt configurations.&amp;rdquo;&lt;/li&gt;
&lt;li&gt;&amp;ldquo;Show me the robots.txt for mydomain.com.&amp;rdquo;&lt;/li&gt;
&lt;li&gt;&amp;ldquo;Add a sitemap.xml to the robots.txt for mydomain.com.&amp;rdquo;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://www.stott.pro/assets/robots-handler-opal-tools-5.png&quot; alt=&quot;A screenshot of the conversations with Opal regarding robots.txt content&quot; /&gt;&lt;/p&gt;
&lt;p&gt;It is best practice to be as verbose as possible when getting Opal to make changes to your robots.txt or llms.txt content. Ask Opal to show you the changes first, review them and iterate before applying your changes.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://www.stott.pro/assets/robots-handler-opal-tools-6.png&quot; alt=&quot;A screenshot of a more verbose conversations with Opal regarding robots.txt content&quot; /&gt;&lt;/p&gt;
&lt;p&gt;One thing to note: the discovery endpoint itself doesn&amp;rsquo;t send a token. This means Opal sees all tools as potentially available, but actual access depends on the permissions you granted. If a tool requires more permissions than your token allows, Opal will receive a &amp;ldquo;not authorised&amp;rdquo; response.&lt;/p&gt;
&lt;h2&gt;Getting Started&lt;/h2&gt;
&lt;p&gt;Stott Robots Handler v6 is available for&amp;nbsp;&lt;strong&gt;Optimizely PAAS CMS 12&lt;/strong&gt;&amp;nbsp;on both nuget.optimizely.com and on nuget.org. You can see the full installation instructions and package information over on the&amp;nbsp;&lt;a href=&quot;https://github.com/GeekInTheNorth/Stott.Optimizely.RobotsHandler&quot;&gt;github page&lt;/a&gt;, including how to configure the addon for use with Opti Id.&lt;/p&gt;
&lt;p&gt;Getting started can be as simple as:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;// Install the nuget package
dotnet add package Stott.Optimizely.RobotsHandler

// Add this to your services
services.AddRobotsHandler();

// Add this to your configure method
services.UseRobotsHandler();

// Add this to your _ViewImports.cshtml if you are running traditional
@addTagHelper *, Stott.Optimizely.RobotsHandler&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With Opal Tools support now built into Robots Handler, managing your search visibility and AI indexing just got a lot more powerful and easier. Try it out today, and let me know how you use it in your projects.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;I am an OMVP and the author and maintainer of &lt;a title=&quot;Stott Security&quot; href=&quot;https://github.com/GeekInTheNorth/Stott.Security.Optimizely&quot;&gt;Stott Security&lt;/a&gt; and &lt;a title=&quot;Stott Robots Handler&quot; href=&quot;https://github.com/GeekInTheNorth/Stott.Optimizely.RobotsHandler&quot;&gt;Stott Robots Handler&lt;/a&gt; for Optimizely CMS 12. You can find all of my content collated on&amp;nbsp;&lt;a title=&quot;The blog website for Mark Stott.&quot; href=&quot;https://www.stott.pro/&quot;&gt;https://www.stott.pro/&lt;/a&gt;&amp;nbsp;&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/mark-stott/dates/2025/9/opal-tools-support-comes-to-stott-robots-handler-v6/</guid>            <pubDate>Wed, 17 Sep 2025 09:03:03 GMT</pubDate>           <category>Blog post</category></item><item> <title>Simple Feature Flags In Optimizely CMS</title>            <link>https://world.optimizely.com/blogs/mark-stott/dates/2025/1/simple-feature-flagging-in-paas-cms/</link>            <description>&lt;h2&gt;The Problem&lt;/h2&gt;
&lt;p&gt;I was working with a CMS 11 client who wanted to introduce two new third party content sources. These would be synchronized into new specific page types that they would then surface alongside content written directly within the CMS on their listing pages. Their listing page has the ability to select a subset of content types which will be allowed within the results of the current listng page. This functionality allows them to use the same Listing Page type to act as a Case Studies listing page or a News Article listing page.&lt;/p&gt;
&lt;p&gt;Different parts of the business would be sponsoring each new integration and there was an overlap in terms of related content types and functionality they would be referenced on. Rather than getting into regular merge conflicts with different branches implementing changes on the same objects I decided to adopt a feature flagging approach.&lt;/p&gt;
&lt;h2&gt;The Solution&lt;/h2&gt;
&lt;p&gt;The first requirement was a static object with a method that could be accessed globally with minimal overhead. As this was a CMS 11 solution built on .NET Framework, I placed the flags in the AppSettings section of the &lt;em&gt;web.config&lt;/em&gt; file and accessed them through the &lt;em&gt;ConfigurationManager&lt;/em&gt;.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;public static class FeatureFlagProvider
{
    public const string FeatureOne = &quot;FeatureOne:IsEnabled&quot;;
    public const string FeatureTwo = &quot;FeatureTwo:IsEnabled&quot;;

    public static bool IsFeatureFlagActive(string featureFlag)
    {
        var featureFlagValue = ConfigurationManager.AppSettings[featureFlag];
            
        if (bool.TryParse(featureFlagValue, out var result))
        {
            return result;
        }

        return false;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The solution includes a Site Settings content type that holds global content and settings for the site. Both integrations introduced options on the Site Settings content type and added properties to the Listing Page content type. These properties needed to be conditionally hidden based on feature flags. To achieve this, I created a custom attribute inheriting from &lt;em&gt;ScaffoldColumn&lt;/em&gt;:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field, AllowMultiple = false)]
public class FeatureFlaggedColumnAttribute : ScaffoldColumnAttribute
{
    public FeatureFlaggedColumnAttribute(string featureFlag) : base(FeatureFlagProvider.IsFeatureFlagActive(featureFlag))
    {
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;em&gt;ScaffoldColumn&lt;/em&gt; attribute accepts a single boolean indicating whether a property should be visible in the editor UI. My &lt;em&gt;FeatureFlaggedColumn&lt;/em&gt; attribute instead accepts a string and converts it into a boolean using the &lt;em&gt;FeatureFlagProvider&lt;/em&gt; which is passed into the base constructor. Applying this to a property on a content type looks like this:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;[FeatureFlaggedColumn(FeatureFlagProvider.FeatureOne)]
public virtual ContentReference FeatureOneProperty { get; set; }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The Listing Page also had a multi-select field allowing editors to select one or more content types to filter Search &amp;amp; Navigation results. I needed to hide the new page types in the selection factory for this field without removing the field itself. The same FeatureFlagProviderwas used inside the selection factory:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;public class ContentTypesSelectionFactory : ISelectionFactory
{
    public IEnumerable&amp;lt;ISelectItem&amp;gt; GetSelections(ExtendedMetadata metadata)
    {
        yield return new SelectItem 
        { 
            Text = &quot;Existing Page Type One&quot;, 
            Value = typeof(ExistingPageOne).FullName 
        };
        yield return new SelectItem 
        { 
            Text = &quot;Existing Page Type Two&quot;, 
            Value = typeof(ExistingPageTwo).FullName 
        };

        if (FeatureFlagProvider.IsFeatureFlagActive(FeatureFlagProvider.FeatureOne))
        {
            yield return new SelectItem 
            { 
                Text = &quot;Feature One Page&quot;, 
                Value = typeof(FeatureOnePage).FullName 
            };
        }

        if (FeatureFlagProvider.IsFeatureFlagActive(FeatureFlagProvider.FeatureTwo))
        {
            yield return new SelectItem 
            { 
                Text = &quot;Feature Two Page&quot;, 
                Value = typeof(FeatureTwoPage).FullName 
            };
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Finally I needed to prevent the new scheduled jobs for the new integrations from running in higher environments where the feature would be turned off. Again the same &lt;em&gt;FeatureFlagProvider&lt;/em&gt; was checked and if the feature was disabled, the scheduled job would immediately exit.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;[ScheduledPlugIn(DisplayName = &quot;[Feature One] Content Sync&quot;, ...)]
public class FeatureOneSyncScheduledJob : ScheduledJobBase
{
    public override string Execute()
    {
        if (!FeatureFlagProvider.IsFeatureFlagActive(FeatureFlagProvider.FeatureOne))
        {
            return &quot;Feature One Integration is not currently enabled.&quot;;
        }

        var results = SynchronizeContent();

        return results.Any() ? string.Join(&quot;&amp;lt;br/&amp;gt;&quot;, results) : &quot;Complete.&quot;;
    }

    private IList&amp;lt;string&amp;gt; SynchronizeContent()
    {
        // Integration Logic Goes Here
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;The Outcome&lt;/h2&gt;
&lt;p&gt;By using &lt;em&gt;web.config&lt;/em&gt; transforms for higher environments, I was able to then turn features on or off at an environment level. As a result of this simple feature flagging approach, we were able to release our three features separately with features turned on or off across multiple releases to production like so:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Release One
&lt;ul&gt;
&lt;li&gt;Feature One was feature complete and enabled on &lt;strong&gt;Integration&lt;/strong&gt; and &lt;strong&gt;Preproduction&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Feature Two was incomplete and enabled on &lt;strong&gt;Integration&lt;/strong&gt; only.&lt;/li&gt;
&lt;li&gt;Feature Three was not yet started.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Release Two
&lt;ul&gt;
&lt;li&gt;Feature One was released to &lt;strong&gt;all&lt;/strong&gt; environments.&lt;/li&gt;
&lt;li&gt;Feature Two was incomplete and enabled on &lt;strong&gt;Integration&lt;/strong&gt; only.&lt;/li&gt;
&lt;li&gt;Feature Three was not yet started.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Release Three
&lt;ul&gt;
&lt;li&gt;Feature Two was feature complete and enabled on &lt;strong&gt;Integration&lt;/strong&gt; and &lt;strong&gt;Preproduction&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Feature Three was released to &lt;strong&gt;all&lt;/strong&gt; environments.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Release Four
&lt;ul&gt;
&lt;li&gt;Feature Two was released to &lt;strong&gt;all&lt;/strong&gt; environments.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;What About CMS 12?&lt;/h2&gt;
&lt;p&gt;There are more options to consider when it comes to CMS 12 and my approach would likely be different for a CMS 12 solution. The first thing I would consider is the use of Microsoft&#39;s .NET Feature Management package and extending it&#39;s usage in the same way. This package comes with built in support for feature flagging on Routing, Filters and Action Attributes. There is also support for feature filters to activate a feature flag with a given set of constraints and the SDK is open source. In this specific case, the only functionality I would have actually used was the ability to check whether a feature flag was enabled.&lt;/p&gt;
&lt;p&gt;Another option to consider is whether your client is an Optimizely Feature Experimentation customer. With Feature Experimentation, your client is able to perform experimentations anywhere within the technical stack, however experimenting on the server side does require a development partnership. With Feature Experimentation the customer could choose when to enable or disable specific feature flags without an actual deployment needing to take place. There is a C# SDK to support this which is also open source.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a title=&quot;Microsoft Learn - .NET Feature Management&quot; href=&quot;https://learn.microsoft.com/en-us/azure/azure-app-configuration/feature-management-dotnet-reference&quot;&gt;Microsoft Learn - .NET Feature Management&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a title=&quot;GitHub - .NET Feature Management&quot; href=&quot;https://github.com/microsoft/FeatureManagement-Dotnet&quot;&gt;GitHub - .NET Feature Management&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a title=&quot;Optimizely - Feature Experimentation&quot; href=&quot;https://www.optimizely.com/products/feature-experimentation/&quot;&gt;Optimizely - Feature Experimentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a title=&quot;GitHub - Optimizely Feature Experimentation C# SDK&quot; href=&quot;https://github.com/optimizely/csharp-sdk&quot;&gt;GitHub - Optimizely Feature Experimentation C# SDK&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a title=&quot;Optimizely Developer Documentation - Feature Experimentation C# Example&quot; href=&quot;https://docs.developers.optimizely.com/feature-experimentation/docs/example-usage-csharp&quot;&gt;Optimizely Developer Documentation - Feature Experimentation C# Example&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;p&gt;I am an OMVP and the author and maintainer of &lt;a title=&quot;Stott Security&quot; href=&quot;https://github.com/GeekInTheNorth/Stott.Security.Optimizely&quot;&gt;Stott Security&lt;/a&gt; and &lt;a title=&quot;Stott Robots Handler&quot; href=&quot;https://github.com/GeekInTheNorth/Stott.Optimizely.RobotsHandler&quot;&gt;Stott Robots Handler&lt;/a&gt; for Optimizely CMS 12. You can find all of my content collated on&amp;nbsp;&lt;a title=&quot;The blog website for Mark Stott.&quot; href=&quot;https://www.stott.pro/&quot;&gt;https://www.stott.pro/&lt;/a&gt;&amp;nbsp;&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/mark-stott/dates/2025/1/simple-feature-flagging-in-paas-cms/</guid>            <pubDate>Sun, 31 Aug 2025 17:56:00 GMT</pubDate>           <category>Blog post</category></item><item> <title>LLMS.txt support comes to Stott Robots Handler v5</title>            <link>https://world.optimizely.com/blogs/mark-stott/dates/2025/8/llms.txt-support-comes-to-stott-robots-handler-v5</link>            <description>&lt;h2&gt;So you&#39;ve heard of llms.txt?&lt;/h2&gt;
&lt;p&gt;If you&amp;rsquo;ve been keeping an eye on developments in AI and search, you may have heard of&amp;nbsp;&lt;strong&gt;llms.txt&lt;/strong&gt; files. The &lt;strong&gt;llms.txt&lt;/strong&gt; file is a simple, human-readable document designed to help &lt;strong&gt;Large Language Models (LLMs)&lt;/strong&gt; better understand the content and context of your website.&amp;nbsp; It is intended to be a new standard in terms of &lt;strong&gt;Generative Engine Optimization&lt;/strong&gt; &lt;strong&gt;(GEO)&lt;/strong&gt; and its fair to say it is early days.&amp;nbsp; You can see a list of early adopters here: &lt;a href=&quot;https://llmstxt.site/&quot;&gt;https://llmstxt.site/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;The llms.txt file itself should be written in Markdown which is a lightweight formatting language that uses plain text syntax to style content.&amp;nbsp; Markdown is then typically transformed into HTML for presentation for normal consumption, but in this case it happens to be easily parsed by AI.&lt;/p&gt;
&lt;h2&gt;Why Add llms.txt to the Stott Robots Handler?&lt;/h2&gt;
&lt;p&gt;The &lt;strong&gt;Stott Robots Handler&lt;/strong&gt; has long been the go-to tool for managing &lt;strong&gt;robots.txt&lt;/strong&gt; content&amp;nbsp;in &lt;strong&gt;Optimizely CMS 12&lt;/strong&gt;. Because the &lt;strong&gt;llms.txt&lt;/strong&gt; concept shares a similar purpose and delivery method (providing machine-readable guidance from a fixed location) it made perfect sense to integrate support for it directly into the same tool.&amp;nbsp; The synergy between the delivery and management of the two file formats allowed me to deliver this functionality swiftly into Stott Robots Handler, allowing you to manage your site&amp;rsquo;s LLM guidance and crawler instructions all from a single, familiar interface.&lt;/p&gt;
&lt;h2&gt;The Interface&lt;/h2&gt;
&lt;p&gt;Existing users of the &lt;strong&gt;Stott Robots Handler&lt;/strong&gt; will find the interface for managing &lt;strong&gt;llms.txt&lt;/strong&gt; content to be immediately familiar.&amp;nbsp; The &lt;strong&gt;llms.txt&lt;/strong&gt; configuration list is nearly identical to that of the &lt;strong&gt;robots.txt&lt;/strong&gt; configuration list.&amp;nbsp; Users can create, modify and delete configurations on a per site / per host basis allowing for flexible variations in instructions for large language models.&amp;nbsp; Unlike the &lt;strong&gt;robots.txt&lt;/strong&gt;, there are no default configurations for each website.&amp;nbsp; The &lt;strong&gt;llms.txt&lt;/strong&gt; content only exists and is only served when it has been defined by the user.&amp;nbsp; If a configuration does not exist for a given domain, then the users will receive a 404 response instead of a default value.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/c9940a7abb43427f98e1f74620e70e10.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;When creating or modifying &lt;strong&gt;llms.txt&lt;/strong&gt; content, a familair modal interface is displayed. The modal interface is larger for managing &lt;strong&gt;llms.txt&lt;/strong&gt; content as there are expectations that this file will be significantly larger than &lt;strong&gt;robots.txt&lt;/strong&gt;.&amp;nbsp; Users can select a site and they can opt between selecting a specific host definition or configuring the &lt;strong&gt;llms.txt&lt;/strong&gt; content to be the default for a website.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/53c88e1b6df742ea96e30c16b9da615f.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Please note that when content is served on the /llms.txt path, the response will be resolved in the following order:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;llms content that matches the specific site and host definition&lt;/li&gt;
&lt;li&gt;llms content that matches a specific site but is configured as a default&lt;/li&gt;
&lt;li&gt;404 response&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;Getting Started&lt;/h2&gt;
&lt;p&gt;Stott Robots Handler v5 is available for Optimizely PAAS CMS 12 on nuget.optimizely.com and on nuget.org.&amp;nbsp; You can see the full installation instructions and package information over on &lt;a title=&quot;Stott Robots Handler Github page&quot; href=&quot;https://github.com/GeekInTheNorth/Stott.Optimizely.RobotsHandler&quot;&gt;github&lt;/a&gt; page, including how to configure the addon for use with Opti Id.&lt;/p&gt;
&lt;p&gt;Getting started can be as simple as:&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;// Install the nuget package
dotnet add package Stott.Optimizely.RobotsHandler

// Add this to your services
services.AddRobotsHandler();

// Add this to your configure method
services.UseRobotsHandler();

// Add this to your _ViewImports.cshtml if you are running traditional
@addTagHelper *, Stott.Optimizely.RobotsHandler&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;p&gt;You can find all of my content collated on &lt;a href=&quot;https://www.stott.pro/&quot;&gt;https://www.stott.pro/&lt;/a&gt;&amp;nbsp;&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/mark-stott/dates/2025/8/llms.txt-support-comes-to-stott-robots-handler-v5</guid>            <pubDate>Fri, 08 Aug 2025 08:22:05 GMT</pubDate>           <category>Blog post</category></item><item> <title>Optimizing Content Security Policies to Stay Within HTTP Header Limits</title>            <link>https://world.optimizely.com/blogs/mark-stott/dates/2025/8/optimizing-content-security-policies-to-stay-within-http-header-limits/</link>            <description>&lt;div&gt;As the Stott Security module continues to gain traction across a growing number of Optimizely CMS solutions, I&amp;rsquo;ve encountered a broader and often more complex range of Content Security Policies (CSPs). Earlier this year, two separate clients reported that their websites were not responding as intended. After investigation, the root cause wasn&amp;rsquo;t the application server, it was &lt;strong&gt;Cloudflare silently dropping the response&lt;/strong&gt; due to excessive header size.&lt;/div&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;div&gt;In both cases, the Content Security Policy (CSP) contained between 220&amp;ndash;250 domain entries. After reviewing and optimizing their policies, I was able to resolve the issue by reducing the size of the CSP by 30&amp;ndash;50%. In this article, I&amp;rsquo;ll share common CSP pitfalls and practical techniques to shrink the size of your Content Security Policy, helping you avoid silent failures and stay within browser and CDN limits.&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div style=&quot;padding-left: 20pt;&quot;&gt;&lt;strong&gt;&#128161;Please note&lt;/strong&gt; Cloudflare will drop any HTTP response where the combined headers exceed 32KB or a single header exceeds 16KB.&lt;/div&gt;
&lt;div style=&quot;padding-left: 20pt;&quot;&gt;- &lt;a title=&quot;Cloudflare Request Limits&quot; href=&quot;https://developers.cloudflare.com/workers/platform/limits/#request-limits&quot;&gt;Cloudflare Docs&lt;/a&gt;&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;h2&gt;1. Simplify the directives being used&lt;/h2&gt;
&lt;div&gt;
&lt;p&gt;In CSP 3, the ability to split &lt;strong&gt;script-src&lt;/strong&gt; into &lt;strong&gt;script-src-elem &lt;/strong&gt;and &lt;strong&gt;script-src-attr &lt;/strong&gt;was added. &amp;nbsp;This was intended to give more control over what could be used in a &lt;strong&gt;&amp;lt;script&amp;gt; &lt;/strong&gt;element vs what could be used in a JavaScript attribute on an element. &amp;nbsp;There was a period of time where if you wanted to use &lt;strong&gt;script-src-elem &lt;/strong&gt;and &lt;strong&gt;script-src-attr &lt;/strong&gt;you still had to produce &lt;strong&gt;script-src &lt;/strong&gt;to support devices and browsers that were not yet CSP 3 compliant. &amp;nbsp;CSP 3 support is now very broad so you can omit script-src. &amp;nbsp;If you still have a lot of sources and there is an overlap of sources for &lt;strong&gt;script-src-elem &lt;/strong&gt;and &lt;strong&gt;script-src-attr &lt;/strong&gt;then you could switch back to using &lt;strong&gt;script-src &lt;/strong&gt;instead.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Larger and very specific:&lt;/strong&gt;&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;script-src-elem &#39;self&#39; &#39;nonce-r4nd0m&#39; https://www.example.com https://www.elem-only.com;
script-src-attr &#39;self&#39; https://www.example.com https://www.attr-only.com;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;br /&gt;&lt;strong&gt;Smaller but less specific:&lt;/strong&gt;&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;script-src &#39;self&#39; &#39;nonce-r4nd0m&#39; https://www.example.com https://www.elem-only.com https://www.attr-only.com;&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;padding-left: 20pt;&quot;&gt;&lt;br /&gt;&lt;strong&gt;&#128161;Tip:&lt;/strong&gt; the same technique can be used for style-src, style-src-elem and style-src-attr.&lt;/p&gt;
&lt;h2&gt;2. Keep default-src simple&lt;/h2&gt;
&lt;p&gt;The &lt;strong&gt;default-src &lt;/strong&gt;directive serves as a fallback for most other Content Security Policy directives. If a directive like script-src or img-src isn&#39;t explicitly defined, the browser will fall back to whatever you&#39;ve set in default-src. To reduce complexity and prevent overly permissive defaults, it&#39;s best to keep &lt;strong&gt;default-src &lt;/strong&gt;as tight as possible. Ideally restricted to your own domain or even disabled altogether.&lt;/p&gt;
&lt;p&gt;Here are three practical options:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;default-src &#39;none&#39;; &lt;/strong&gt;Blocks all resources unless explicitly allowed by another directive. This is the most restrictive and secure default.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;default-src &#39;self&#39;; &lt;/strong&gt;Allows only resources from the current domain. This is a common and safe choice for many sites.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;default-src &#39;self&#39; https://*.mydomain.com;&amp;nbsp;&lt;/strong&gt; More permissive, this allows resources from your domain and all subdomains. Be cautious: this could include dev, test, or legacy subdomains unless you scope them intentionally.&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;padding-left: 20pt;&quot;&gt;&lt;strong&gt;&#128161;Tip:&lt;/strong&gt; If you&#39;re already specifying individual directives like script-src, style-src, and img-src, you may not need a permissive default-src at all. In that case, consider using &#39;none&#39; to avoid accidentally allowing fallback behaviors you didn&amp;rsquo;t intend.&lt;/p&gt;
&lt;h2&gt;3. Keep base-uri simple&lt;/h2&gt;
&lt;p&gt;The &lt;strong&gt;&amp;lt;base&amp;gt;&lt;/strong&gt; HTML element defines the base URL for all relative links on a page. The &lt;strong&gt;base-uri &lt;/strong&gt;directive in your Content Security Policy restricts which domains are allowed to be set in this element.&lt;/p&gt;
&lt;p&gt;Without this restriction, a malicious actor could modify the &lt;strong&gt;&amp;lt;base&amp;gt; &lt;/strong&gt;element to redirect your users&amp;rsquo; traffic to an attacker-controlled domain. For example, an attacker could inject a &lt;strong&gt;&amp;lt;base href=&quot;https://malicious.site/&quot;&amp;gt;&lt;/strong&gt;&amp;nbsp;tag, causing all relative links on your page to resolve to the malicious site. This can lead to phishing attacks, misleading users into submitting sensitive information or downloading harmful content.&lt;/p&gt;
&lt;p&gt;Because this directive serves a very specific purpose, it&amp;rsquo;s best to keep it narrowly scoped, typically to one of the following:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;base-uri &#39;self&#39;;

or

base-uri &#39;self&#39; https://*.mydomain.com;&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;4. Keep frame-ancestors simple&lt;/h2&gt;
&lt;p&gt;The purpose of &lt;strong&gt;frame-ancestors&lt;/strong&gt; is to restrict which websites can host your CMS site in an iframe. &amp;nbsp;For most Optimizely websites, this should be limited to allowing only the website to frame itself:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;frame-ancestors &#39;self&#39;;
or
frame-ancestors &#39;self&#39;; https://*.mydomain.com;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;br /&gt;If you are using Optimizely Web Experimentation then you will also want to allow optimizely.com in order to allow the edit variant interface to work:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;frame-ancestors &#39;self&#39;; https://*.mydomain.com https://*.optimizely.com;&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;5. Avoid duplicate entries&lt;/h2&gt;
&lt;p&gt;In the Content Security Policies that I reviewed, I noted that there were multiple instances of the same domain being added twice. &amp;nbsp;Both with and without a trailing slash like so:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;script-src &#39;self&#39; https://www.example.com https://www.example.com/&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;br /&gt;In a Content Security Policy, these two forms are functionally identical. The trailing slash has &lt;strong&gt;no effect&lt;/strong&gt; when the source is just a host (i.e. no path). Browsers treat both as allowing all content from the origin https://www.example.com.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Simplify your policy&lt;/strong&gt; by removing duplicates and keeping only one clean version:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;script-src &#39;self&#39; https://www.example.com;&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;padding-left: 20pt;&quot;&gt;&lt;br /&gt;&lt;strong&gt;&#128161;Tip:&lt;/strong&gt; If you&#39;re using a CSP source that includes a **path**, then the trailing slash *does* matter and it will limit resources to just those immediately below the path:&lt;/p&gt;
&lt;ul&gt;
&lt;li style=&quot;list-style-type: none;&quot;&gt;
&lt;ul&gt;
&lt;li&gt;https://example.com/js/ matches /js/app.js&lt;/li&gt;
&lt;li&gt;https://example.com/js matches only /js&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;6. Check if wildcard subdomains already cover more specific domains&lt;/h2&gt;
&lt;p&gt;In the reported examples, I found multiple instances of redundant wildcard entries for closely related domains. These examples included:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;script-src https://*.consentmanager.net https://*.delivery.consentmanager.net https://*.fls.doubleclick.net https://*.g.doubleclick.net https://*.doubleclick.net&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;br /&gt;When using a wildcard source like https://*.example.com, it&#39;s important to understand how wildcards behave:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;https://*.example.com matches any number of subdomain levels, such as cdn.sub.example.com&lt;/li&gt;
&lt;li&gt;It does **not** match the apex domain itself (i.e. https://example.com)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Knowing this, we can simplify the content security policy to:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;script-src https://*.consentmanager.net https://*.doubleclick.net;&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;padding-left: 20pt;&quot;&gt;&#128161; &lt;strong&gt;Tip:&lt;/strong&gt; Always verify that the broader wildcard covers all required sources and doesn&#39;t introduce unwanted access. If the root domain (e.g. https://doubleclick.net) is needed, it must still be listed separately. It is not included in a wildcard like *.doubleclick.net.&lt;/p&gt;
&lt;h2&gt;7. Be cautious with https: and wss: wildcard protocols in source directives&lt;/h2&gt;
&lt;p&gt;On large, multinational CMS platforms (especially those where editors frequently embed third-party content like donation forms, interactive widgets, or 360&amp;deg; views) Content Security Policies can become hard to maintain. In these cases, it&#39;s tempting to use a broad directive like:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;frame-src &#39;self&#39; https:;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;br /&gt;This allows any content to be iframed as long as it&#39;s served over HTTPS, which can be a &lt;strong&gt;pragmatic short-term solution&lt;/strong&gt; when content sources are constantly changing and difficult to manage. However, this convenience comes with serious trade-offs.&lt;/p&gt;
&lt;p&gt;Using &lt;strong&gt;https:&lt;/strong&gt; effectively tells the browser to allow &lt;strong&gt;any&lt;/strong&gt; secure domain and not just trusted partners. That means explicitly listing a domain like https://example.com is redundant, and worse, it also implicitly permits https://malicious.site or any other HTTPS-based domain to load scripts or frames on your site.&amp;nbsp; The same behaviour exists for wss: and http: protocols as well. So do be cautious when using them, or avoid them entirely:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;script-src &#39;self&#39; https: https://example.com;  // Redundant and risky&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;padding-left: 20pt;&quot;&gt;&lt;br /&gt;&#128161;&lt;strong&gt;Recommendation:&lt;/strong&gt; While &lt;strong&gt;https: &lt;/strong&gt;or&lt;strong&gt; wss:&lt;/strong&gt;&amp;nbsp;might seem like a helpful shortcut, it&#39;s generally better to define a specific &lt;strong&gt;allowlist&lt;/strong&gt; of trusted domains. Avoid &lt;strong&gt;https: &lt;/strong&gt;and &lt;strong&gt;wss:&amp;nbsp;&lt;/strong&gt;as standalone protocol only sources and instead use fully qualified entries like https://trustedpartner.com to maintain control and minimize your exposure to third-party threats.&lt;/p&gt;
&lt;h2&gt;8. Audit Your Content Security Policy&lt;/h2&gt;
&lt;p&gt;Optimizely have previously noted that most websites go an average of five years between rebuilds or rebrands. Over that time, it&#39;s common for third-party tools to be added and removed, often through platforms like Google Tag Manager (GTM). Each time a tool is introduced, it typically requires updates to your Content Security Policy to allow scripts, iframes, or connections from new domains.&lt;/p&gt;
&lt;p&gt;But what happens when you stop using one of those tools?&amp;nbsp;It&#39;s easy to forget to remove those permissions from your Content Security Policy. Over time, this results in bloated, outdated, and potentially less secure policies.&lt;/p&gt;
&lt;p&gt;The &lt;a title=&quot;Stott Security&quot; href=&quot;https://github.com/GeekInTheNorth/Stott.Security.Optimizely&quot;&gt;Stott Security&lt;/a&gt; module includes features that make auditing easier:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Navigate to the &lt;strong&gt;Stott Security Interface&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;On the &lt;strong&gt;Tools&lt;/strong&gt; page, export your current security configuration&lt;/li&gt;
&lt;li&gt;On the &lt;strong&gt;CSP Settings&lt;/strong&gt; page:
&lt;ol&gt;
&lt;li&gt;Enable&amp;nbsp;&lt;strong&gt;&quot;Use Report-Only Mode&quot;&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Enable&amp;nbsp;&lt;strong&gt;&quot;Use Internal Reporting Endpoints&quot;&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;Begin removing CSP sources you believe may no longer be in use&lt;/li&gt;
&lt;li&gt;Monitor the &lt;strong&gt;CSP Violations&lt;/strong&gt; page to confirm whether any legitimate content breaks&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;If any issues arise, you can simply re-import your saved configuration from the Tools page. Once you&#39;re confident that your updated policy is safe, turn off &lt;strong&gt;&quot;Use Report-Only Mode&quot;&lt;/strong&gt; and &lt;strong&gt;&quot;Use Internal Reporting Endpoints&quot;&lt;/strong&gt; to enforce the streamlined policy and to reduce traffic to the webserver caused by CSP reports.&lt;/p&gt;
&lt;h2&gt;9. Use page specific extensions of the source list&lt;/h2&gt;
&lt;p&gt;&lt;a title=&quot;Stott Security&quot; href=&quot;https://github.com/GeekInTheNorth/Stott.Security.Optimizely&quot;&gt;Stott Security&lt;/a&gt; has long supported the ability to extend the sources for a Content Security Policy for a specific page. &amp;nbsp;Lets say you do have a website with lots of embedded content and most of this is fairly unique. &amp;nbsp;You can end up bloating your CSP just trying to keep up with the number of embeds. &amp;nbsp;This can be undesirable because you will end up allowing a domain to act on &lt;strong&gt;all&lt;/strong&gt; of your website when it is only required on a single page.&lt;/p&gt;
&lt;p&gt;To implement this, your development team or agency partner can implement the &lt;strong&gt;IContentSecurityPolicyPage&amp;nbsp;&lt;/strong&gt;interface either as a CMS editable property or by using code you implement yourself as follows:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;public class MyPage : PageData, IContentSecurityPolicyPage
{
    [Display(
        Name = &quot;Content Security Policy Sources&quot;,
        Description = &quot;The following Content Security Policy Sources will be merged into the global Content Security Policy when visiting this page&quot;,
        GroupName = &quot;Security&quot;,
        Order = 10)]
    [EditorDescriptor(EditorDescriptorType = typeof(CspSourceMappingEditorDescriptor))]
    public virtual IList&amp;lt;PageCspSourceMapping ContentSecurityPolicySources { get; set; }
}

or

public class MyPage : PageData, IContentSecurityPolicyPage
{
    public IList&amp;lt;PageCspSourceMapping ContentSecurityPolicySources = this.GetSourceListForPage();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;br /&gt;When this page is served, the sources and directives specified here are merged into the global content security policy just for the purposes of serving this page.&lt;/p&gt;
&lt;h2&gt;Changes to Stott Security for Optimizely CMS 12&lt;/h2&gt;
&lt;p&gt;In order to prevent other cusumers of &lt;a title=&quot;Stott Security&quot; href=&quot;https://github.com/GeekInTheNorth/Stott.Security.Optimizely&quot;&gt;Stott Security&lt;/a&gt;&amp;nbsp;from encountering the same issue with header size limits, I decided that I would add some safety nets for the generated Content Security Policy that would respect Cloudflare&#39;s hard 16KB limit. When researching header size limits in general, I observed that the most common recommendation was to keep your headers below 8KB per header to ensure broad compatability.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;Starting in version 3.0.2 of &lt;a title=&quot;Stott Security&quot; href=&quot;https://github.com/GeekInTheNorth/Stott.Security.Optimizely&quot;&gt;Stott Security&lt;/a&gt;, CSPs are now intelligently split into &lt;a title=&quot;Multiple CSP Header Behaviour&quot; href=&quot;https://content-security-policy.com/examples/multiple-csp-headers/&quot;&gt;multiple CSP Headers&lt;/a&gt; if their size approaches the 8KB limit. Since browsers enforce the most restrictive policy among multiple CSP headers, these headers are carefully divided based on directive hierarchy and fallback behavior.&lt;/p&gt;
&lt;p&gt;If a header grows beyond 12KB, additional logic is applied to consolidate directives as follows:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;script-src-elem &lt;/strong&gt;and &lt;strong&gt;script-src-attr &lt;/strong&gt;are merged into &lt;strong&gt;script-src&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;style-src-elem &lt;/strong&gt;and &lt;strong&gt;style-src-attr &lt;/strong&gt;are merged into &lt;strong&gt;style-src&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;frame-src&lt;/strong&gt;, &lt;strong&gt;worker-src&lt;/strong&gt; are merged into &lt;strong&gt;child-src&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Any omitted source style directives falling back to&amp;nbsp;&lt;strong&gt;default-src&lt;/strong&gt; are added explicitly, defaulting to &lt;strong&gt;&#39;self&#39; &lt;/strong&gt;if needed.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;As a final safeguard, if the combined size of all CSP headers approaches the 16KB Cloudflare limit, the CSP will not be generated to avoid unexpected failures.&lt;/p&gt;
&lt;h2&gt;Summary&lt;/h2&gt;
&lt;p&gt;In order to simplify your Content Security Policy and keep it below the recommend 8KB or Cloudflare&#39;s hard 16KB limit consider the following:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Simplify Directive Use
&lt;ul&gt;
&lt;li&gt;Use just &lt;strong&gt;script-src&lt;/strong&gt; instead of &lt;strong&gt;script-src, &lt;/strong&gt;&lt;strong&gt;script-src-elem &lt;/strong&gt;and &lt;strong&gt;script-src-attr&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Use just &lt;strong&gt;style-src&lt;/strong&gt; instead of &lt;strong&gt;style-src, &lt;/strong&gt;&lt;strong&gt;style-src-elem &lt;/strong&gt;and &lt;strong&gt;style-src-attr&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Keep &lt;strong&gt;default-src&lt;/strong&gt;, &lt;strong&gt;base-uri&lt;/strong&gt; and &lt;strong&gt;frame-ancestors&lt;/strong&gt; simple by restricting them just to &lt;strong&gt;&#39;self&#39;&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Avoid duplicate entries such as https://www.example.com and https://www.example.com/&lt;/li&gt;
&lt;li&gt;Avoid using less specific wildcards (https://*.one.example.com) if there is already a more permissive wildcard (https://*.example.com).&lt;/li&gt;
&lt;li&gt;Consider the use of the &lt;strong&gt;https:&lt;/strong&gt; and &lt;strong&gt;wss:&lt;/strong&gt; protocol wildcards very carefully as it allows &lt;strong&gt;all&lt;/strong&gt; domains, not just those you specify.&lt;/li&gt;
&lt;li&gt;Audit your Content Security Policy and remove permissions for scripts you are no longer using.&lt;/li&gt;
&lt;li&gt;Consider using the page specific extension of the Content Security Policy feature that is part of &lt;a title=&quot;Stott Security&quot; href=&quot;https://github.com/GeekInTheNorth/Stott.Security.Optimizely&quot;&gt;Stott Security&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Consider updating to the latest version of &lt;a title=&quot;Stott Security&quot; href=&quot;https://github.com/GeekInTheNorth/Stott.Security.Optimizely&quot;&gt;Stott Security&lt;/a&gt; today in order to benefit from automatic CSP simplification and to protect your servers from bloated content security policies.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;p&gt;You can find all of my content collated on&amp;nbsp;&lt;a href=&quot;https://www.stott.pro/&quot;&gt;https://www.stott.pro/&lt;/a&gt;&amp;nbsp;&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;</description>            <guid>https://world.optimizely.com/blogs/mark-stott/dates/2025/8/optimizing-content-security-policies-to-stay-within-http-header-limits/</guid>            <pubDate>Mon, 04 Aug 2025 13:52:54 GMT</pubDate>           <category>Blog post</category></item><item> <title>Announcing Stott Security Version 3.0</title>            <link>https://world.optimizely.com/blogs/mark-stott/dates/2025/4/announcing-stott-security-version-3.0</link>            <description>&lt;p&gt;I&#39;m proud to announce the release of version 3 of Stott Security for Optimizely PAAS CMS. This release has been developed over several months owing to a significant new feature among other quality of life changes.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;To see the full release notes you can head over to the discussion page on &lt;a title=&quot;Github - Stott Security v3.0.0&quot; href=&quot;https://github.com/GeekInTheNorth/Stott.Security.Optimizely/discussions/278&quot;&gt;GitHub.&lt;/a&gt;&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;h2&gt;Permissions-Policy Support&lt;/h2&gt;
&lt;p&gt;The &lt;strong&gt;Permissions-Policy&lt;/strong&gt; HTTP header (formerly &lt;strong&gt;Feature-Policy&lt;/strong&gt;) allows developers to control which web features and APIs can be used in the browser, and by which origin. This header can improve security and performance by restricting access to sensitive capabilities such as the camera, microphone, and geolocation.&lt;/p&gt;
&lt;p&gt;Currently, support for the &lt;strong&gt;Permissions-Policy&lt;/strong&gt; header is mixed. It is well-supported by Chromium-based browsers like Chrome and Edge, but not yet implemented in Firefox or Safari. This naturally raises the question: why invest time in implementing a feature that isn&amp;rsquo;t universally supported?&lt;/p&gt;
&lt;p&gt;According to the latest &lt;a title=&quot;browser market share data&quot; href=&quot;https://gs.statcounter.com/browser-market-share&quot;&gt;browser market share data&lt;/a&gt;, over 70% of users are on browsers that support this header. Additionally, unsupported browsers simply ignore the header, without causing any issues. While I&#39;ve yet to see a penetration test flag the absence of this header as a vulnerability, I&amp;rsquo;m increasingly seeing clients request its inclusion as part of their CMS security requirements.&lt;/p&gt;
&lt;p&gt;To support this, I&amp;rsquo;ve added a new &lt;strong&gt;Permissions Policy&lt;/strong&gt; screen to Stott Security. This interface allows administrators to enable or disable the &lt;strong&gt;Permissions-Policy&lt;/strong&gt; header globally with a single toggle. It also includes a filter bar for quickly narrowing directives by source (URL) or current configuration (e.g., *Disabled*, *Allow None*, *Allow All Sites*, etc.).&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/83b55fc2cfae462eb112c8cdcaaf50e2.aspx?1744186114798&quot; alt=&quot;The Permission Policy listing for Stott Security&quot; width=&quot;3420&quot; height=&quot;1276&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Clicking the &lt;strong&gt;Edit&lt;/strong&gt; button for a directive opens a modal where the configuration for that specific directive can be adjusted.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/3fc3a110a51d401bad8d7fc98a2734b6.aspx?1744186158433&quot; alt=&quot;The Permission Policy modal for a single directive within Stott Security&quot; width=&quot;2280&quot; height=&quot;842&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Available options include:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Disabled (Omitted this directive from the policy)&lt;/li&gt;
&lt;li&gt;Allow None&lt;/li&gt;
&lt;li&gt;Allow All Sites&lt;/li&gt;
&lt;li&gt;Allow Just This Website&lt;/li&gt;
&lt;li&gt;Allow This Website and Specific Third Party Websites&lt;/li&gt;
&lt;li&gt;Allow Specific Third Party Websites&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For configurations involving third-party origins, administrators can specify one or more sources. Input is strictly validated to require only protocol and domain. Wildcards are supported, but only as the first segment after the protocol (e.g. &lt;em&gt;https://*.example.com&lt;/em&gt;).&lt;/p&gt;
&lt;p&gt;All changes to the Permissions Policy are fully audited. Additionally, import/export functionality has been extended to include this new feature.&lt;/p&gt;
&lt;h2&gt;Small Features&lt;/h2&gt;
&lt;h3&gt;.NET 9 Support&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;If you&#39;re building an Optimizely PaaS solution targeting &lt;strong&gt;.NET 9&lt;/strong&gt;, you can now integrate Stott Security into your project, regardless of whether your solution is headed, headless, or hybrid.&lt;/div&gt;
&lt;br /&gt;
&lt;div&gt;Stott Security leverages Entity Framework for database access and operations such as migrations. Since each major release of Entity Framework often introduces breaking changes aligned with specific .NET versions, the Stott Security package has been updated to include build targets for &lt;strong&gt;.NET 6&lt;/strong&gt;, &lt;strong&gt;.NET 8&lt;/strong&gt;, and &lt;strong&gt;.NET 9&lt;/strong&gt;, each using the appropriate version of Entity Framework under the hood.&lt;/div&gt;
&lt;h3&gt;Import Settings Tool&lt;/h3&gt;
&lt;p&gt;In previous versions, the import tool required that CSP, CORS, and Response Headers configurations all be present in the import file. To support backwards compatibility with export files created in version 2.x, validation in version 3.x has been relaxed. Now, settings are only applied if they contain a non-null value. This allows for partial imports; if your import file includes only the CSP configuration, then only the CSP settings will be updated, leaving all other settings unchanged.&lt;/p&gt;
&lt;h3&gt;X-XSS-Protection Header Warning&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;The &lt;strong&gt;X-XSS-Protection&lt;/strong&gt; header was originally introduced to instruct browsers to enable their built-in XSS filters, aiming to protect users from cross-site scripting attacks. However, the feature introduced its own set of issues. When configured with &lt;code&gt;X-XSS-Protection: 1; mode=block&lt;/code&gt;, malicious actors could exploit it to trigger denial-of-service conditions by injecting scripts that caused legitimate content to be blocked. Alternatively, when simply enabled without blocking (&lt;code&gt;X-XSS-Protection: 1&lt;/code&gt;), the header became susceptible to &lt;strong&gt;XS-Search&lt;/strong&gt; attacks this is where an attacker submits crafted XSS payloads to probe for differences in application behavior, potentially exposing sensitive data in applications that would otherwise be considered secure.&lt;/div&gt;
&lt;br /&gt;
&lt;div&gt;Although Stott Security continues to support the &lt;strong&gt;X-XSS-Protection&lt;/strong&gt; header, it should &lt;strong&gt;only&lt;/strong&gt; be set to &lt;strong&gt;disabled&lt;/strong&gt; or &lt;strong&gt;omitted entirely&lt;/strong&gt;. To help guide users, explanatory notes have been added to the UI outlining current best practices.&lt;/div&gt;
&lt;br /&gt;
&lt;div&gt;It&amp;rsquo;s worth noting that Chromium-based browsers already ignore this header entirely, but some other browsers have yet to follow suit.&lt;/div&gt;
&lt;h3&gt;Content Security Policy Source Updates&lt;/h3&gt;
&lt;p&gt;Validation has been enhanced to support the &lt;strong&gt;&#39;inline-speculation-rules&#39;&lt;/strong&gt;&amp;nbsp;keyword within Content Security Policy (CSP) directives. Speculation rules enable the browser to preload potential navigation targets based on user behavior, improving perceived performance by initiating page loads slightly before user interaction (e.g., clicks).&lt;/p&gt;
&lt;p&gt;The &lt;strong&gt;CSP Edit Source&lt;/strong&gt; modal in Stott Security has also been updated to ensure that special keywords such as &lt;strong&gt;&#39;unsafe-inline&#39;&lt;/strong&gt;, &lt;strong&gt;&#39;unsafe-eval&#39;&lt;/strong&gt;, and &lt;strong&gt;&#39;inline-speculation-rules&#39;&lt;/strong&gt; can only be added to directives where they are valid. These are typically limited to &lt;strong&gt;script-src&lt;/strong&gt;, &lt;strong&gt;style-src&lt;/strong&gt;, and their more specific variants.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/743f539570f24afdaac612ad801523b0.aspx?1744186454614&quot; alt=&quot;Content Security Policy Edit Source modal for Stott Security&quot; width=&quot;2280&quot; height=&quot;850&quot; /&gt;&lt;/p&gt;
&lt;div&gt;
&lt;h3&gt;CMS Editor Gadget Removed&lt;/h3&gt;
&lt;p&gt;In version 2, I introduced a CMS Editor gadget that displayed the HTTP headers generated when rendering a specific page. This was intended to support the feature allowing users to extend the Content Security Policy (CSP) for individual content items by specifying additional sources.&lt;/p&gt;
&lt;p&gt;However, over time it became clear that this approach had several drawbacks:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The gadget was read-only and appeared automatically for all CMS administrators, even if the page-specific CSP feature wasn&#39;t in use. In most cases, this meant the gadget was visible but offered little to no value.&lt;/li&gt;
&lt;li&gt;Some developers experienced installation issues with the AddOn. Specifically, in projects where the &lt;code&gt;.csproj&lt;/code&gt; excluded the &lt;code&gt;modules\_protected&lt;/code&gt; folder; often done to avoid conflicts with other third-party AddOns that add files to the solution multiple times. This resulted in the Stott Security &lt;strong&gt;module.config&lt;/strong&gt; being be removed and not re-added. This in turn causes an error during the start up of the solution that could only be resovled by manually adding the into source control and project.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Given the minimal benefit provided by the gadget and the friction it caused in certain build pipelines, I&amp;rsquo;ve decided to remove it entirely in version 3.&lt;/p&gt;
&lt;div&gt;
&lt;h3&gt;Obsolete Code Removed&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;If your solution has been using Stott Security since version 1 then you may find that you are using some obsoleted code. &amp;nbsp;As part of packaging up version 3, the following items have been removed:&lt;/div&gt;
&lt;br /&gt;
&lt;div&gt;
&lt;ul&gt;
&lt;li&gt;SecurityServiceExtensions.AddCspManager(...)
&lt;ul&gt;
&lt;li&gt;Replaced by SecurityServiceExtensions.AddStottSecurity(...)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;SecurityServiceExtensions.UseCspManager()
&lt;ul&gt;
&lt;li&gt;Replaced by SecurityServiceExtensions.UseStottSecurity()&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;CspReportingViewComponent
&lt;ul&gt;
&lt;li&gt;Replaced by Report-Uri and Report-To within the CSP.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;
&lt;h3&gt;Report To Endpoint Fixes&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;The &lt;strong&gt;report-uri&lt;/strong&gt; directive has been deprecated within the content security policy. &amp;nbsp;The specification for this endpoint was for it to receive a single CSP Report per request with a content type of &lt;code&gt;application/csp-report&lt;/code&gt;. &amp;nbsp;With the introduction of the &lt;strong&gt;report-to&lt;/strong&gt; directive, browsers are now meant to send a collection of CSP Reports in an array with a content type of &lt;code&gt;application/reports+json&lt;/code&gt;. &amp;nbsp;The intent being to reduce the number of requests being made to any reporting endpoint.&lt;/div&gt;
&lt;br /&gt;
&lt;div&gt;Some browsers appear to have simply started sending their &lt;strong&gt;report-uri&lt;/strong&gt; payload to the &lt;strong&gt;report-to&lt;/strong&gt; endpoint on a per report basis instead of matching the specification. &amp;nbsp;The result is a lot of bad requests being returned and a host of reports being lost. &amp;nbsp;I have updated the &lt;strong&gt;report-to&lt;/strong&gt; endpoint so that it can handle both payloads based on their respective content types.&lt;/div&gt;
&lt;h2&gt;Get It Now&lt;/h2&gt;
&lt;div&gt;Stott Security v3.0 is free to use and is available on all the usual nuget feeds:&lt;/div&gt;
&lt;div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://nuget.optimizely.com/?q=stott&amp;amp;s=Popular&amp;amp;r=10&amp;amp;f=All&quot;&gt;https://nuget.optimizely.com&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://api.nuget.optimizely.com/?q=stott&amp;amp;s=Popular&amp;amp;r=10&amp;amp;f=All&quot;&gt;https://api.nuget.optimizely.com&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.nuget.org&quot;&gt;https://www.nuget.org&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;p&gt;You can find all of my content collated on &lt;a href=&quot;https://www.stott.pro/&quot;&gt;https://www.stott.pro/&lt;/a&gt;&amp;nbsp;&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;</description>            <guid>https://world.optimizely.com/blogs/mark-stott/dates/2025/4/announcing-stott-security-version-3.0</guid>            <pubDate>Wed, 09 Apr 2025 08:23:00 GMT</pubDate>           <category>Blog post</category></item><item> <title>Opti ID with Secure Cookies And Third Party AddOns</title>            <link>https://world.optimizely.com/blogs/mark-stott/dates/2024/10/secure-cookies-with-opti-id/</link>            <description>&lt;p&gt;Opti ID has revolutionised access to the Optimizely One suite and is now the preferred authentication method on all PAAS CMS websites that I build. &amp;nbsp;However there are a couple of gotchas that always need to be taken care, outlined below are the solutions to both.&lt;/p&gt;
&lt;h2&gt;Secure Cookies&lt;/h2&gt;
&lt;p&gt;Any penetration test that you perform on your website will always advise that your cookies are set to be Secure, HTTP Only with a SameSite mode of Strict. &amp;nbsp;Setting these is simple enough until you realise that the Opti ID cookies are third party and therefore for need a SameSite mode of None. &amp;nbsp;If you have used Microsoft Entra (Formerly Azure AD) as the authentication method for a CMS, then this problem will be very familiar to you.&lt;br /&gt;&lt;br /&gt;The challenge we have is:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;We don&#39;t want all cookies to have a SameSite mode of None&lt;/li&gt;
&lt;li&gt;Optimizely cookies are not in our direct control&lt;/li&gt;
&lt;li&gt;Writing verbose custom cookie code everywhere is messy&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In the following solution I have used the app.UseCookiePolicy(...) method to set all of the cookies to be Secure, Http Only with a default SameSite mode of None by default. &amp;nbsp;I then provide a handler for the OnAppendCookie event that will set cookies to use a SameSite mode of Strict provided they do not match one of the following cookie name patterns:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;oid-&lt;/strong&gt; : These are cookies which Opti ID uses for authentication&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;.AspNetCore &lt;/strong&gt;: These are cookies NET Core uses for Authentication&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;public static IApplicationBuilder UseSecureCookies(this IApplicationBuilder app)
{
    // Set the default cookie policy
    app.UseCookiePolicy(new CookiePolicyOptions
    {
        HttpOnly = HttpOnlyPolicy.Always,
        Secure = CookieSecurePolicy.Always,
        MinimumSameSitePolicy = SameSiteMode.None,
        OnAppendCookie = context =&amp;gt;
        {
            if (!context.CookieName.StartsWith(&quot;oid-&quot;) &amp;amp;&amp;amp;
                !context.CookieName.StartsWith(&quot;.AspNetCore&quot;))
            {
                // Any cookie that is not an authentication cookie should have a SameSite mode of Strict
                context.CookieOptions.SameSite = SameSiteMode.Strict;
            }
        }
    });

    return app;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Configuring AddOns To Use Opti ID&lt;/h2&gt;
&lt;p&gt;In Optimizely CMS 11, dependency setup relied upon &lt;strong&gt;Initialisation Modules&lt;/strong&gt;. &amp;nbsp;In CMS 12 and .NET core, all of this is now handled in &lt;strong&gt;Startup.cs&lt;/strong&gt;. Some commonly used AddOns include a service extension that is intended to be consumed within a startup.cs. Some go as far as to provide a custom authorization policy so that you can customise access to the AddOn to specific roles rather than granting full admin access to the CMS for users who just need that functionality. Here are a few AddOns and the roles that you might want to use:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Stott Security - CmsAdmins, SecurityAdmins, DataAnalytics&lt;/li&gt;
&lt;li&gt;Stott Robots Handlers - CmsAdmins, SeoAdmins&lt;/li&gt;
&lt;li&gt;Geta NotFound Handler - CmsAdmins, SeoAdmins&lt;/li&gt;
&lt;li&gt;Geta Sitemaps - CmsAdmins, SeoAdmins&lt;/li&gt;
&lt;/ul&gt;
&lt;p class=&quot;code-line&quot;&gt;Here is the challenge: These modules will not allow you to access them when using Opti Id unless you specify the &lt;strong&gt;Opti ID SchemeName&lt;/strong&gt; in the authorizarion policy for each AddOn.&lt;/p&gt;
&lt;p class=&quot;code-line&quot;&gt;Take this configuration for the&amp;nbsp;&lt;strong&gt;Stott Security AddOn&lt;/strong&gt; as an example; this AddOn allows you to segment the data for the AddOn into a separate database and it allows you to define an authorization policy. &amp;nbsp;In this scenario it is a simple matter of making sure the Opti ID Scheme Name is added to the policy:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;public static IServiceCollection AddSecurityAddOn(this IServiceCollection services)
{
    services.AddStottSecurity(
        options =&amp;gt;
        {
            options.ConnectionStringName = &quot;EPiServerDB&quot;;
        },
        authorization =&amp;gt;
        {
            authorization.AddPolicy(CspConstants.AuthorizationPolicy, policy =&amp;gt;
            {
                // Use the Opti ID scheme Name
                policy.AddAuthenticationSchemes(OptimizelyIdentityDefaults.SchemeName);
                policy.RequireRole(Roles.CmsAdmins, &quot;SecurityAdmins&quot;);
            });
        });

    return services;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Service Collection Extension Pattern&lt;/h2&gt;
&lt;p&gt;In both of these examples above, I am following the &lt;a title=&quot;Service Collection Extension Pattern&quot; href=&quot;https://dotnetfullstackdev.medium.com/service-collection-extension-pattern-in-net-core-with-item-services-6db8cf9dcfd6&quot;&gt;Service Collection Extension Pattern&lt;/a&gt;. &amp;nbsp;I highly recomment this pattern as it allows you to modularise your configuration code and to keep your startup.cs clean and easy to understand or rearrange. &amp;nbsp;&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;You can find all of my content collated on &lt;a href=&quot;https://www.stott.pro/&quot;&gt;https://www.stott.pro/&lt;/a&gt;&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/mark-stott/dates/2024/10/secure-cookies-with-opti-id/</guid>            <pubDate>Mon, 09 Dec 2024 13:07:46 GMT</pubDate>           <category>Blog post</category></item><item> <title>Creating an Optimizely Addon - Best Practices</title>            <link>https://world.optimizely.com/blogs/mark-stott/dates/2024/8/creating-an-optimizely-addon---best-practices/</link>            <description>&lt;p&gt;In&amp;nbsp;&lt;a href=&quot;/link/38b1217b7fdc41238ed62f1f31907605.aspx&quot;&gt;Part One&lt;/a&gt;,&amp;nbsp;&lt;a href=&quot;/link/c29176d530e64067b29392113b435547.aspx&quot;&gt;Part Two&lt;/a&gt;&amp;nbsp;and&amp;nbsp;&lt;a href=&quot;/link/59fdc24a290e4471bd32fcbb87d6454d.aspx&quot;&gt;Part Three&lt;/a&gt;, I have outlined the steps required to create an AddOn for Optimizely CMS, from architecture to packaging at as a NuGet package. In this part I will be covering some best practices that will help you succeed as an AddOn developer. You can view examples from across this series within the this&amp;nbsp;&lt;a href=&quot;https://github.com/GeekInTheNorth/OptimizelyAddOnTemplate&quot;&gt;Optimizely AddOn Template&lt;/a&gt;&amp;nbsp;that I have been creating.&lt;/p&gt;
&lt;div class=&quot;markdown-heading&quot;&gt;
&lt;h2&gt;Unit Tests&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;As a solo developer managing multiple AddOns, my ability to release updates regularly relies heavily on having extensive unit tests. For instance,&amp;nbsp;&lt;a href=&quot;https://github.com/GeekInTheNorth/Stott.Security.Optimizely&quot;&gt;Stott Security&lt;/a&gt;&amp;nbsp;includes over 1,500 unit tests that run whenever a pull request is made to merge a feature branch into the develop branch. This level of coverage ensures that functionality remains consistent across releases.&lt;/p&gt;
&lt;p&gt;As well as writing unit tests for your business logic, you can also write additional unit tests that validate the security of your controllers. I would consider adding these tests to be an essential part of ensuring the security of your system as they ensure the following:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Controller actions explicitly allow only the intended HTTP methods, ensuring endpoints respond only to the correct verbs.&lt;/li&gt;
&lt;li&gt;Controller actions are secured with the Authorization attribute or marked with AllowAnonymous if security isn&amp;rsquo;t required. This enforces clear security requirements for each endpoint.&lt;/li&gt;
&lt;li&gt;Controller actions are defined with specific routes, preventing conflicts with other modules or the consuming application&amp;rsquo;s routing.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;[TestFixture]
public sealed class ControllerStandardsTests
{
    [Test]
    [TestCaseSource(typeof(ControllerStandardsTestCases), nameof(ControllerStandardsTestCases.PageControllerActionTestCases))]
    public void ControllersShouldHaveHttpMethodAttributes(string controllerName, string methodName, MethodInfo methodInfo)
    {
        // Act
        var hasHttpMethodAttribute = methodInfo.GetCustomAttributes(typeof(HttpMethodAttribute)).Any();

        // Assert
        // Controllers should only respond in intended Verbs and should respond with method not allowed on unintended verbs.
        // This will prevent posting of malicious payloads to methods not intended to retrieve of deal with these payloads.
        // First raised by a penetration test where an attempt to post files to a content end point returned a 200 instead of a 405
        Assert.That(hasHttpMethodAttribute, Is.True, $&quot;{controllerName}.{methodName} should be decorated with a http method attribute.&quot;);
    }

    [Test]
    [TestCaseSource(typeof(ControllerStandardsTestCases), nameof(ControllerStandardsTestCases.PageControllerActionTestCases))]
    public void ControllerMethodsShouldEitherHaveAuthorizeOrAllowAnonymousAttributes(string controllerName, string methodName, MethodInfo methodInfo)
    {
        // Act
        var hasAuthorizeAttribute = methodInfo.GetCustomAttributes(typeof(AuthorizeAttribute)).Any();
        var hasAllowAnonymousAttribute = methodInfo.GetCustomAttributes(typeof(AllowAnonymousAttribute)).Any();
        var controllerHasAuthorizeAttribute = methodInfo.DeclaringType?.GetCustomAttributes(typeof(AuthorizeAttribute)).Any() ?? false;

        var hasAttribute = hasAuthorizeAttribute || hasAllowAnonymousAttribute || controllerHasAuthorizeAttribute;

        // Assert
        // Controller actions should be protected with an Authorization attribute or an intentional AllowAnonymous attribute.
        // This will ensure your controllers are secure by default and that you have to explicitly allow anonymous access.
        Assert.That(hasAttribute, Is.True, $&quot;{controllerName}.{methodName} should be decorated directly or indirectly with an Authorize or AllowAnonymous attribute.&quot;);
    }

    [Test]
    [TestCaseSource(typeof(ControllerStandardsTestCases), nameof(ControllerStandardsTestCases.PageControllerActionTestCases))]
    public void ControllerMethodsShouldHaveRouteAttributes(string controllerName, string methodName, MethodInfo methodInfo)
    {
        // Act
        var hasRouteAttribute = methodInfo.GetCustomAttributes(typeof(RouteAttribute)).Any();
        var controllerHasRouteAttribute = methodInfo.DeclaringType?.GetCustomAttributes(typeof(RouteAttribute)).Any() ?? false;

        var hasAttribute = hasRouteAttribute || controllerHasRouteAttribute;

        // Assert
        // Controller actions should have a fixed route attribute so as to not have clashes with routes declared by other modules.
        Assert.That(hasAttribute, Is.True, $&quot;{controllerName}.{methodName} should be decorated directly with a Route attribute.&quot;);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;These tests use a Test Case Source method which uses reflection to identify all controllers within your solution. This means as you add new controllers, you will not forget to secure them.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;public static class ControllerStandardsTestCases
{
    public static IEnumerable&amp;lt;TestCaseData&amp;gt; PageControllerActionTestCases
    {
        get
        {
            var assembly = Assembly.GetAssembly(typeof(SettingsLandingPageController));

            if (assembly == null)
            {
                yield break;
            }

            var controllers = assembly.GetTypes()
                                      .Where(t =&amp;gt; (t.BaseType?.Name.StartsWith(&quot;Controller&quot;) ?? false)
                                               || (t.BaseType?.Name.StartsWith(&quot;BaseController&quot;) ?? false))
                                      .ToList();

            foreach (var controller in controllers)
            {
                var actions = controller.GetMethods()
                                        .Where(x =&amp;gt; x.DeclaringType == controller &amp;amp;&amp;amp; x.IsPublic)
                                        .ToList();

                foreach (var methodInfo in actions)
                {
                    if (methodInfo.ReturnType == typeof(IActionResult) || methodInfo.ReturnType == typeof(Task&amp;lt;IActionResult&amp;gt;))
                    {
                        yield return new TestCaseData(controller.Name, methodInfo.Name, methodInfo);
                    }
                }
            }
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;div class=&quot;markdown-heading&quot;&gt;
&lt;h2&gt;Test System&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;If you have followed this series to create an AddOn, you&#39;ll note that the sample site for developing the AddOn does not use nuget to consume the AddOn code and instead uses a project reference. This will allow you to develop efficiently, however it does mean that you are not testing your code in a production-like manner.&lt;/p&gt;
&lt;p&gt;Create a separate repository with it&#39;s own Optimizely CMS solution. Import your AddOn as a NuGet package directly into this solution. This will allow you to test your AddOn in the same way that another developer will be experiencing your AddOn for the first time. If you are able to generate a developer cloud license on&amp;nbsp;&lt;a href=&quot;https://license.episerver.com/&quot;&gt;EPiServer License Centre&lt;/a&gt;, then I would recommend you deploy this test system into an Azure WebApp running on Linux with .NET 6.0 or 8.0 so that you can validate your AddOn inside of a deployed environment.&lt;/p&gt;
&lt;p&gt;As part of my go live cycle I perform the following:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Create a beta version of my nuget package&lt;/li&gt;
&lt;li&gt;Upload the beta package to nuget.org&lt;/li&gt;
&lt;li&gt;Update my test system to use the beta package&lt;/li&gt;
&lt;li&gt;Deploy my test system to Azure&lt;/li&gt;
&lt;li&gt;Delist my beta version on nuget.org&lt;/li&gt;
&lt;li&gt;Test my AddOn in my test system&lt;/li&gt;
&lt;li&gt;Create a production version of my nuget package&lt;/li&gt;
&lt;li&gt;Upload the production package to nuget.optimizely.com&lt;/li&gt;
&lt;li&gt;Wait for the package to be approved by Optimizely&#39;s QA team&lt;/li&gt;
&lt;li&gt;Upload the production package to nuget.org&lt;/li&gt;
&lt;li&gt;Announce the release&lt;/li&gt;
&lt;/ul&gt;
&lt;div class=&quot;markdown-heading&quot;&gt;
&lt;h2&gt;Performance and Caching&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;Optimizely&#39;s default settings for Azure SQL Server typically support around 120 simultaneous database connections, suggesting the use of an S2 SQL Database (or equivelent) for production. This configuration, combined with efficient caching by the Optimizely CMS code allows smaller databases to perform effectively for websites with high traffic.&lt;/p&gt;
&lt;p&gt;If you&#39;re using Microsoft Entity Framework, be aware that each&amp;nbsp;&lt;code&gt;DbContext&lt;/code&gt;&amp;nbsp;instance opens a new database connection. Failing to manage these connections can lead to server instability due to connection limits. Therefore, it&#39;s advisable to follow Optimizely&#39;s approach by extensively using caching. To implement this, consider the following steps:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Use a Custom Cache Wrapper that consumes Optimizely&#39;s&amp;nbsp;&lt;code&gt;ISynchronizedObjectInstanceCache&lt;/code&gt;&amp;nbsp;and uses it&#39;s own master key to allow you to purge your cache effectively.&lt;/li&gt;
&lt;li&gt;Inject your DbContext as a scoped object to limit the number of instances to 1 per request.&lt;/li&gt;
&lt;li&gt;Lazy Load dependencies that require a Db Context so that they are not instantiated if not consumed.&lt;/li&gt;
&lt;li&gt;Handle data loading in the following order:
&lt;ul&gt;
&lt;li&gt;Attempt to retrieve and return data from cache first.&lt;/li&gt;
&lt;li&gt;Attempt to retrieve and return data from the database second.
&lt;ul&gt;
&lt;li&gt;Push the data into a cache before returning it.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The following is an example of a cache wrapper that consumes the&amp;nbsp;&lt;code&gt;ISynchronizedObjectInstanceCache&lt;/code&gt;.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;public sealed class CacheWrapper : ICacheWrapper
{
    private readonly ISynchronizedObjectInstanceCache _cache;

    private const string MasterKey = &quot;My-OptimizelyAddOn-MasterKey&quot;;

    public CacheWrapper(ISynchronizedObjectInstanceCache cache)
    {
        _cache = cache;
    }

    public void Add&amp;lt;T&amp;gt;(string cacheKey, T? objectToCache)
        where T : class
    {
        if (string.IsNullOrWhiteSpace(cacheKey) || objectToCache == null)
        {
            return;
        }

        try
        {
            var evictionPolicy = new CacheEvictionPolicy(
                TimeSpan.FromHours(12),
                CacheTimeoutType.Absolute,
                Enumerable.Empty&amp;lt;string&amp;gt;(),
                new[] { MasterKey });

            _cache.Insert(cacheKey, objectToCache, evictionPolicy);
        }
        catch (Exception exception)
        {
            // Add logging here
        }
    }

    public T? Get&amp;lt;T&amp;gt;(string cacheKey)
        where T : class
    {
        return _cache.TryGet&amp;lt;T&amp;gt;(cacheKey, ReadStrategy.Wait, out var cachedObject) ? cachedObject : default;
    }

    public void RemoveAll()
    {
        try
        {
            _cache.Remove(MasterKey);
        }
        catch (Exception exception)
        {
            // Add logging here
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In order to use Lazy Loaded dependencies, you first need to define the Lazy variant within your service extension method:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;services.AddScoped&amp;lt;IMyDataContext, MyDataContext&amp;gt;();
services.AddScoped&amp;lt;Lazy&amp;lt;IMyDataContext&amp;gt;&amp;gt;(provider =&amp;gt; new Lazy&amp;lt;IMyDataContext&amp;gt;(() =&amp;gt; provider.GetRequiredService&amp;lt;IMyDataContext&amp;gt;()));&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You can then declare your dependencies as Lazy in your constructors and consume them as per the example below. In this scenario, the&amp;nbsp;&lt;code&gt;IMyDataContext&lt;/code&gt;&amp;nbsp;is instantiated once, but that is deferred until it is used by the&amp;nbsp;&lt;code&gt;GetData()&lt;/code&gt;&amp;nbsp;method:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;internal sealed class MyRepository : IMyRepository
{
  private readonly Lazy&amp;lt;IMyDataContext&amp;gt; _context;

  public MyRepository(Lazy&amp;lt;IMyDataContext&amp;gt; context)
  {
    _context = context;
  }

  public async Task&amp;lt;IList&amp;lt;string&amp;gt;&amp;gt; GetData()
  {
    return await _context.Value.MyData.ToListAsync();
  }
} &lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A service can then consume both the&amp;nbsp;&lt;code&gt;IMyRepository&lt;/code&gt;and&amp;nbsp;&lt;code&gt;ICacheWrapper&lt;/code&gt;&amp;nbsp;and make performant calls to retrieve data that has not changed. In the following example we attempt to retrieve the data from the cache first, then if it is null or empty we then attempt to load the data from the repository, push that data into cache before returning it. If the&amp;nbsp;&lt;code&gt;Delete&lt;/code&gt;&amp;nbsp;method is called within the service, we call&amp;nbsp;&lt;code&gt;RemoveAll()&lt;/code&gt;&amp;nbsp;on the cache wrapper to invalidate cache entries based on a master key:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;internal sealed class MyService : IMyService
{
  private readonly IMyRepository _repository;
  private readonly ICacheWrapper _cache;
  private const string CacheKey = &quot;Unique.Cache.Key&quot;;

  public MyService(IMyRepository repository, ICacheWrapper cache)
  {
    _repository = repository;
    _cache = cache;
  }

  public async Task&amp;lt;IList&amp;lt;string&amp;gt;&amp;gt; GetDate()
  {
    var data = _cache.Get&amp;lt;IList&amp;lt;string&amp;gt;&amp;gt;(CacheKey);
    if (data is not { Count: &amp;gt;0 })
    {
      data = await _repository.GetData();
      _cache.Add(CacheKey, data);
    }

    return data;
  }

  public async Task Delete(string data)
  {
    await _repository.Delete(data);

    _cache.RemoveAll();
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Note how we don&#39;t need to lazy load the repository as the database context is already lazy loaded by the repository itself, however you may choose to lazy load the repository as it is not used if the cache is populated.&lt;/p&gt;
&lt;div class=&quot;markdown-heading&quot;&gt;
&lt;h2&gt;Summary&lt;/h2&gt;
&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;Ensure all key business logic is covered by a unit test.&lt;/li&gt;
&lt;li&gt;Use unit tests to enforce standards such as:
&lt;ul&gt;
&lt;li&gt;Controller actions have correct HTTP method attributes.&lt;/li&gt;
&lt;li&gt;Controllers have secure actions with authorization or explicit anonymous access.&lt;/li&gt;
&lt;li&gt;Controllers have defined routes to avoid conflicts.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Test your AddOn in a separate production-like environment.&lt;/li&gt;
&lt;li&gt;Use caching to optimize database access.&lt;/li&gt;
&lt;li&gt;Implement lazy loading and scoped dependencies.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;p&gt;You can find all of my content collated on &lt;a href=&quot;https://www.stott.pro/&quot;&gt;https://www.stott.pro/&lt;/a&gt;&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/mark-stott/dates/2024/8/creating-an-optimizely-addon---best-practices/</guid>            <pubDate>Thu, 24 Oct 2024 12:32:06 GMT</pubDate>           <category>Blog post</category></item><item> <title>Creating an Optimizely Addon - Packaging for NuGet</title>            <link>https://world.optimizely.com/blogs/mark-stott/dates/2024/8/creating-an-optimizely-addon---packaging-for-nuget/</link>            <description>&lt;p&gt;In&amp;nbsp;&lt;a href=&quot;/link/38b1217b7fdc41238ed62f1f31907605.aspx&quot;&gt;Part One&lt;/a&gt;&amp;nbsp;and&amp;nbsp;&lt;a href=&quot;/link/c29176d530e64067b29392113b435547.aspx&quot;&gt;Part Two&lt;/a&gt;&amp;nbsp;of this series; I covered topics from having a great idea, solution structure, extending the menus and adding gadgets to the editor interface. In this part I will be covering the challenges of creating and submitting your AddOn as a NuGet package into the Optimizely NuGet feed. You can view examples from across this series within the this&amp;nbsp;&lt;a href=&quot;https://github.com/GeekInTheNorth/OptimizelyAddOnTemplate&quot;&gt;Optimizely AddOn Template&lt;/a&gt;&amp;nbsp;that I have been creating.&lt;/p&gt;
&lt;div class=&quot;markdown-heading&quot;&gt;
&lt;h2&gt;Defining What to Package&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;Each project within your solution must be created as a NuGet package if it is deemed to be the primary project or a dependency for the primary project. Consider the following solution as an example:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;MySolution.sln
&lt;ul&gt;
&lt;li&gt;MyAddOn.Admin.csproj&lt;/li&gt;
&lt;li&gt;MyAddOn.Core.csproj&lt;/li&gt;
&lt;li&gt;MyAddOn.Test.csproj&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In this scenario, the administrator interface for the AddOn is separated from its core shared functionality. This design enables a consuming site to incorporate MyAddOn.Admin into their Optimizely website project and to reference MyAddOn.Core within any project in their solution structure. Consequently, MyAddOn.Admin has a direct dependency on MyAddOn.Core. To publish MyAddOn.Admin as a NuGet package, MyAddOn.Core must also be published as a NuGet package. It should be noted that MyAddOn.Admin only requires MyAddOn.Core as a project dependency during development; this dependency will be converted into a package dependency during the packaging process.&lt;/p&gt;
&lt;div class=&quot;markdown-heading&quot;&gt;
&lt;h2&gt;Defining NuGet Properties&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;If you are using Visual Studio, right click on the project you want to package and select properties to show the project properties screen. Under the Package section you can define all of the properties for your NuGet package.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/82eb39f7e54044f2944d4c45f7385832.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;I would recommend you complete the following:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Package Id :&lt;/strong&gt; This will need to be globally unique name within nuget.org and nuget.optimizely.com. If you use the&amp;nbsp;&lt;code&gt;$(AssemblyName)&lt;/code&gt;&amp;nbsp;variable, then this will match the name of the project.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Title :&lt;/strong&gt; Visual Studio describes this as the name of the package used in UI displays such as Package Manager, but this largely does not get used.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Package Version : &lt;/strong&gt;This should be a semantic version number with three or four parts and an optional alpha or beta tag. For Example:
&lt;ul&gt;
&lt;li&gt;1.0.0&lt;/li&gt;
&lt;li&gt;1.0.0.0&lt;/li&gt;
&lt;li&gt;0.1.1-alpha&lt;/li&gt;
&lt;li&gt;0.2.2.0-beta&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Authors : &lt;/strong&gt;This should contain the names of the primary people who will own the AddOn / Repository.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Company :&lt;/strong&gt; This should contain the name of the business that is behind creating the Addon. If this is individually owned, then seting this to&amp;nbsp;&lt;code&gt;$(Authors)&lt;/code&gt;&amp;nbsp;will mirror the value from the Authors property.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Description :&lt;/strong&gt; This should be a short description about your Addon, this will be visible within the NuGet package feed and within the Plugin Manager screen within Optimizely CMS.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Copyright : &lt;/strong&gt;This should contain the name of the owner and the year. You get copyright protection automatically when creating software and you do not have to apply or pay a fee. There isn&amp;rsquo;t a register of copyright works in the UK. There are however organisations which will provide extra protection for a fee for validating your copyright. You can read more about copyright here:&amp;nbsp;&lt;a href=&quot;https://www.gov.uk/copyright&quot;&gt;How copyright protects your work&lt;/a&gt;. It is however worth you performing your own research into the matter within the country you live in.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Project Url : &lt;/strong&gt;This should point either to the repository for your Addon or an appropriate project page. Developers will use this to find out more about your Addon or to report issues that may need resolving.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Readme : &lt;/strong&gt;I have set this to the readme.md for my repositories, this will be visible to developers within the NuGet platform.
&lt;ul&gt;
&lt;li&gt;Do ensure assets such as images have absolute paths as this readme will be visible outside of the context of your repository and relative paths will result in images not being found.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Repository Url :&lt;/strong&gt; This should point to the repository for your Addon, assuming that your Addon is Open Source.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tags :&lt;/strong&gt; This is a delimited set of tags that make your package easier to find within the NuGet feeds.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;License File :&lt;/strong&gt; This should reference the license within your repository. Careful consideration should be given to the type of license for your AddOn. Certain licenses may require your users to make their code open source to utilize your package, so think carefully about the permissiveness or restrictiveness of your license. It is noteworthy that some highly popular AddOns employ an MIT or Apache license.
&lt;ul&gt;
&lt;li&gt;I am utilizing an MIT license due to its permissive nature and lack of warranty. While I do engage with my users and address any issues that are raised, my AddOns are free and are maintained in my free time.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Require License Acceptance :&lt;/strong&gt; If you tick this, the consumer will have to accept the license as they install the package. If you are using an MIT license, you may want to tick this to encourage the consumer to accept the warranty free nature of your AddOn.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you are using Visual Studio Code instead of Visual Studio, then you can edit the .csproj directly and add the package properties directly as XML values at the top of the csproj file. You can also add these properties into a .nuspec instead, when you package your project, the values from the .csproj and .nuspec are merged into a new .nuspec that is contained in the root of the compiled .nupkg file. I personnally prefer to put the NuGet properties directly into the .csproj.&lt;/p&gt;
&lt;pre class=&quot;language-markup&quot;&gt;&lt;code&gt;&amp;lt;Project Sdk=&quot;Microsoft.NET.Sdk.Razor&quot;&amp;gt;
  &amp;lt;PropertyGroup&amp;gt;
    &amp;lt;TargetFrameworks&amp;gt;net6.0;net8.0&amp;lt;/TargetFrameworks&amp;gt;
    &amp;lt;AddRazorSupportForMvc&amp;gt;true&amp;lt;/AddRazorSupportForMvc&amp;gt;
    &amp;lt;Version&amp;gt;1.1.0.0&amp;lt;/Version&amp;gt;
    &amp;lt;RepositoryUrl&amp;gt;https://example.com/&amp;lt;/RepositoryUrl&amp;gt;
    &amp;lt;PackageProjectUrl&amp;gt;https://example.com/&amp;lt;/PackageProjectUrl&amp;gt;
    &amp;lt;PackageLicenseFile&amp;gt;LICENSE.txt&amp;lt;/PackageLicenseFile&amp;gt;
    &amp;lt;Authors&amp;gt;Your Name&amp;lt;/Authors&amp;gt;
    &amp;lt;Description&amp;gt;Your Package Summary&amp;lt;/Description&amp;gt;
    &amp;lt;Copyright&amp;gt;Your Name 2024&amp;lt;/Copyright&amp;gt;
    &amp;lt;PackageTags&amp;gt;TagOne TagTwo&amp;lt;/PackageTags&amp;gt;
    &amp;lt;PackageRequireLicenseAcceptance&amp;gt;true&amp;lt;/PackageRequireLicenseAcceptance&amp;gt;
    &amp;lt;RepositoryType&amp;gt;git&amp;lt;/RepositoryType&amp;gt;
    &amp;lt;PackageReadmeFile&amp;gt;README.md&amp;lt;/PackageReadmeFile&amp;gt;
    &amp;lt;AssemblyVersion&amp;gt;1.1.0.0&amp;lt;/AssemblyVersion&amp;gt;
    &amp;lt;GeneratePackageOnBuild&amp;gt;True&amp;lt;/GeneratePackageOnBuild&amp;gt;
    &amp;lt;PackageReleaseNotes&amp;gt;A short release summary.&amp;lt;/PackageReleaseNotes&amp;gt;
    &amp;lt;Nullable&amp;gt;enable&amp;lt;/Nullable&amp;gt;
    &amp;lt;Title&amp;gt;Package Name&amp;lt;/Title&amp;gt;
  &amp;lt;/PropertyGroup&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;div class=&quot;markdown-heading&quot;&gt;
&lt;h2&gt;NuGet Package Structure&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;A NuGet Package is simply a zip file containing a structured set of files. If you rename a .nupkg to a .zip, you can extract it and explore it&#39;s structure. This will have a structure similar to the following:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;package
&lt;ul&gt;
&lt;li&gt;services
&lt;ul&gt;
&lt;li&gt;metadata
&lt;ul&gt;
&lt;li&gt;core-properties&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;build
&lt;ul&gt;
&lt;li&gt;project.name.targets&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;contentFiles
&lt;ul&gt;
&lt;li&gt;additional.file.txt&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;lib
&lt;ul&gt;
&lt;li&gt;net6.0
&lt;ul&gt;
&lt;li&gt;my.project.dll&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;net8.0
&lt;ul&gt;
&lt;li&gt;my.project.dll&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;my.project.nuspec&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;_rels&lt;/li&gt;
&lt;li&gt;[Content_Types].xml&lt;/li&gt;
&lt;li&gt;readme.md&lt;/li&gt;
&lt;li&gt;license.txt&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Folders such as build, contentFiles and the target folders under lib will vary depending on your code and deployable files. The readme.md and license.txt files referenced in your .csproj or .nuspec are copied to the root of the NuGet package.&lt;/p&gt;
&lt;div class=&quot;markdown-heading&quot;&gt;
&lt;h2&gt;Packaging for Multiple Frameworks&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;.NET Core is backwards compatible, meaning that if you build your package for .NET 6, it can be installed into .NET 6, 7, and 8. For most AddOns, compiling directly for .NET 6 ensures maximum compatibility.&lt;/p&gt;
&lt;p&gt;However, there may be instances where you need to compile your application in multiple framework versions. For example, if you are using Entity Framework and Migrations, there is a breaking change between .NET 6 and .NET 8. Fortunately, no code changes are required, but you will need to set your dependencies separately for .NET 6 and .NET 8. To accomplish this, you must make two modifications.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Change the&amp;nbsp;&lt;code&gt;TargetFramework&lt;/code&gt;&amp;nbsp;node in your .csproj to be&amp;nbsp;&lt;code&gt;TargetFrameworks&lt;/code&gt;&amp;nbsp;and separate your target frameworks with a semicolon. e.g.&amp;nbsp;&lt;code&gt;net6.0;net8.0&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Add a separate&amp;nbsp;&lt;code&gt;ItemGroup&lt;/code&gt;&amp;nbsp;per framework version to contain framework specific dependencies and add a condition to the ItemGroup to target the specific framework. e.g.&amp;nbsp;&lt;code&gt;Condition=&quot;&#39;$(TargetFramework)&#39; == &#39;net6.0&#39;&quot;&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;language-markup&quot;&gt;&lt;code&gt;&amp;lt;Project Sdk=&quot;Microsoft.NET.Sdk.Razor&quot;&amp;gt;
  &amp;lt;PropertyGroup&amp;gt;
    &amp;lt;TargetFrameworks&amp;gt;net6.0;net8.0&amp;lt;/TargetFrameworks&amp;gt;
    &amp;lt;AddRazorSupportForMvc&amp;gt;true&amp;lt;/AddRazorSupportForMvc&amp;gt;
    &amp;lt;Nullable&amp;gt;enable&amp;lt;/Nullable&amp;gt;
  &amp;lt;/PropertyGroup&amp;gt;

  &amp;lt;ItemGroup Condition=&quot;&#39;$(TargetFramework)&#39; == &#39;net6.0&#39;&quot;&amp;gt;
    &amp;lt;PackageReference Include=&quot;Microsoft.EntityFrameworkCore.SqlServer&quot; Version=&quot;6.0.6&quot; /&amp;gt;
  &amp;lt;/ItemGroup&amp;gt;

  &amp;lt;ItemGroup Condition=&quot;&#39;$(TargetFramework)&#39; == &#39;net8.0&#39;&quot;&amp;gt;
    &amp;lt;PackageReference Include=&quot;Microsoft.EntityFrameworkCore.SqlServer&quot; Version=&quot;8.0.1&quot; /&amp;gt;
  &amp;lt;/ItemGroup&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This will double the size of your NuGet package as it will contain separate folders for each target framework containing your code compiled for that framework.&lt;/p&gt;
&lt;div class=&quot;markdown-heading&quot;&gt;
&lt;h2&gt;Packaging Additional Files For The Protected Modules Folder&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;If your package contains an&amp;nbsp;&lt;code&gt;IFrameComponent&lt;/code&gt;&amp;nbsp;or other files needed to extend the Editor Interface. A&amp;nbsp;&lt;code&gt;module.config&lt;/code&gt;&amp;nbsp;file and those files will need to be deployed to the&amp;nbsp;&lt;code&gt;modules/_protected/my_project&lt;/code&gt;&amp;nbsp;folder within the target website.&lt;/p&gt;
&lt;p&gt;First you will need to tell the .csproj file that we want to copy these files into the&amp;nbsp;&lt;code&gt;contentFiles&lt;/code&gt;&amp;nbsp;folder of the NuGet package. This is as simple as setting the build output for those files to be&amp;nbsp;&lt;code&gt;None&lt;/code&gt;&amp;nbsp;and to set the&amp;nbsp;&lt;code&gt;PackagePath&lt;/code&gt;&amp;nbsp;to be inside of the&amp;nbsp;&lt;code&gt;contentFiles&lt;/code&gt;&amp;nbsp;folder.&lt;/p&gt;
&lt;pre class=&quot;language-markup&quot;&gt;&lt;code&gt;&amp;lt;ItemGroup&amp;gt;
  &amp;lt;None Include=&quot;module.config&quot;&amp;gt;
    &amp;lt;Pack&amp;gt;true&amp;lt;/Pack&amp;gt;
    &amp;lt;PackagePath&amp;gt;contentFiles\module.config&amp;lt;/PackagePath&amp;gt;
  &amp;lt;/None&amp;gt;
&amp;lt;/ItemGroup&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You will then need to create a .targets file that instructs the NuGet package installer how to handle those files. The example below is taken straight from my own Addons where I am doing the same thing.&lt;/p&gt;
&lt;p&gt;The&amp;nbsp;&lt;code&gt;ItemGroup&lt;/code&gt;&amp;nbsp;tells the .targets file where the specific files are within the NuGet package structure. The&amp;nbsp;&lt;code&gt;$(MSBuildThisFileDirectory)&lt;/code&gt;&amp;nbsp;variable in this case is a reference to the directory the .targets file sits in. As this is in a build folder, I have used the&amp;nbsp;&lt;code&gt;$(MSBuildThisFileDirectory)&lt;/code&gt;&amp;nbsp;variable in combination with the relative path to my module.config file.&lt;/p&gt;
&lt;p&gt;The&amp;nbsp;&lt;code&gt;Target&lt;/code&gt;&amp;nbsp;node is then performing an action that is configured to execute on&amp;nbsp;&lt;code&gt;BeforeBuild&lt;/code&gt;. This then performs a&amp;nbsp;&lt;code&gt;Copy&lt;/code&gt;&amp;nbsp;action that will take my module.config file from the contentFiles folder in the nuget package to the&amp;nbsp;&lt;code&gt;modules\_protected\my_project&lt;/code&gt;&amp;nbsp;folder within the target website. This means that when you first install the package, the module.config file and folder will not exist within the protected modules folder. When you first build the solution they will be copied into this location.&lt;/p&gt;
&lt;pre class=&quot;language-markup&quot;&gt;&lt;code&gt;&amp;lt;?xml version=&quot;1.0&quot; encoding=&quot;utf-8&quot;?&amp;gt;
&amp;lt;Project xmlns=&quot;http://schemas.microsoft.com/developer/msbuild/2003&quot; ToolsVersion=&quot;4.0&quot;&amp;gt;
  &amp;lt;ItemGroup&amp;gt;
    &amp;lt;MyFiles Include=&quot;$(MSBuildThisFileDirectory)..\contentFiles\module.config&quot; /&amp;gt;
  &amp;lt;/ItemGroup&amp;gt;
  
  &amp;lt;Target Name=&quot;CopyFiles&quot; BeforeTargets=&quot;BeforeBuild&quot;&amp;gt;
        &amp;lt;Copy SourceFiles=&quot;@(MyFiles)&quot; DestinationFolder=&quot;$(MSBuildProjectDirectory)\modules\_protected\my_project\&quot; /&amp;gt;
    &amp;lt;/Target&amp;gt;
&amp;lt;/Project&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In order to make sure the .targets file can be executed, we also need to make sure that it is copied into the NuGet package file. This is as simple as editing your .csproj file and configuring the build output for the .targets file to be &lt;code&gt;None&lt;/code&gt;&amp;nbsp;and to set the&amp;nbsp;&lt;code&gt;PackagePath&lt;/code&gt;&amp;nbsp;to be inside of the&amp;nbsp;&lt;code&gt;build&lt;/code&gt;&amp;nbsp;folder.&lt;/p&gt;
&lt;pre class=&quot;language-markup&quot;&gt;&lt;code&gt;&amp;lt;ItemGroup&amp;gt;
  &amp;lt;None Include=&quot;msbuild\copyfiles.targets&quot;&amp;gt;
    &amp;lt;Pack&amp;gt;true&amp;lt;/Pack&amp;gt;
    &amp;lt;PackagePath&amp;gt;build\$(MSBuildProjectName).targets&amp;lt;/PackagePath&amp;gt;
  &amp;lt;/None&amp;gt;
&amp;lt;/ItemGroup&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;div class=&quot;markdown-heading&quot;&gt;
&lt;h2&gt;Submitting Your Package&lt;/h2&gt;
&lt;p&gt;Before submitting your AddOn to the Optimizely NuGet package feed, it is essential to ensure that your package installs successfully both in your local environment and within a CI/CD pipeline. To expedite this process, consider publishing your package as an alpha or beta build to &lt;a href=&quot;https://www.nuget.org/&quot;&gt;nuget.org&lt;/a&gt; first. After publishing, your package will be indexed and available for retrieval within a few minutes.&lt;/p&gt;
&lt;p&gt;To designate your package as an alpha or beta release, you should modify the&amp;nbsp;&lt;code&gt;version&lt;/code&gt;&amp;nbsp;property within your `.csproj` file to include a trailing &lt;code&gt;-alpha&lt;/code&gt;&amp;nbsp;or&amp;nbsp;&lt;code&gt;-beta&lt;/code&gt;. NuGet will automatically recognize this as a pre-release version and will generally filter these versions out by default. Developers can view these pre-release versions by selecting the option to display pre-release versions within their IDE&amp;rsquo;s NuGet package tool.&lt;/p&gt;
&lt;pre class=&quot;language-markup&quot;&gt;&lt;code&gt;&amp;lt;Version&amp;gt;2.0.0.2-beta&amp;lt;/Version&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Upon publishing the alpha or beta version of your package to nuget.org and confirming its successful installation both locally and in a CI/CD pipeline, you will be prepared to submit the live version of your package to Optimizely.&lt;/p&gt;
&lt;p&gt;Ensure that you have an &lt;a href=&quot;/link/6c9478a8761c41d88dfc32e9ef56e714.aspx&quot;&gt;Optimizely World&lt;/a&gt; account. You can create a new account by visiting&amp;nbsp;&lt;a href=&quot;/link/6c9478a8761c41d88dfc32e9ef56e714.aspx&quot;&gt;Optimizely World&lt;/a&gt; and following the registration link located in the top right corner. This account will also provide access to the Optimizely NuGet feeds. Optimizely maintains two NuGet feeds:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://nuget.optimizely.com/&quot;&gt;https://nuget.optimizely.com&lt;/a&gt; (v2 NuGet feed)&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://api.nuget.optimizely.com/&quot;&gt;https://api.nuget.optimizely.com&lt;/a&gt; (v3 NuGet feed)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Packages uploaded to the v2 NuGet feed are automatically synchronized to the v3 NuGet feed. Therefore, it is advisable to upload your packages to the v2 NuGet feed. Once Optimizely receives your package, it will undergo an approval process conducted by Optimizely&#39;s QA team. During this process, the QA team will verify that your AddOn functions correctly with the CMS. Including test guidance in the readme for your repository can be very beneficial for the QA team. This review process may take one or more business days, and there is currently no feedback mechanism to inform you of the status or outcome of the testing. You may periodically check the NuGet feed to determine if your package has been accepted. Given that Optimizely validates all packages uploaded to their NuGet feed, it is recommended to download AddOn updates directly from Optimizely and distribute your own package in this manner. Should you need to release a hotfix promptly, you may consider uploading it to nuget.org.&lt;/p&gt;
&lt;p&gt;It is advisable to upload your package to nuget.org at least once in addition to the Optimizely NuGet feed. This ensures that the package name is reserved on nuget.org, avoiding potential conflicts in package names across the main feeds that could affect your consumers.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Please note that as of the time of writing, there was an issue with packages uploaded directly to the v3 NuGet feed not being synchronized back to the v2 NuGet feed. Until this issue is resolved, the Upload link on the v3 NuGet feed redirects users to the v2 NuGet feed. Optimizely is actively working to resolve this issue.&lt;/em&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class=&quot;markdown-heading&quot;&gt;
&lt;h2&gt;Summary&lt;/h2&gt;
&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;Build your package for .NET 6 for maximum compatability
&lt;ul&gt;
&lt;li&gt;Build your package for both .NET 6 &amp;amp; 8 if you have compatability issues between both frameworks.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Use a Razor Class Library so you can package your UI and C# code together.&lt;/li&gt;
&lt;li&gt;Think very carefully about which license you will use for your package.&lt;/li&gt;
&lt;li&gt;Use a build targets file to put files into specific folders within a consuming application.&lt;/li&gt;
&lt;li&gt;Test your package installs and works as an alpha/beta on nuget.org before submitting to the Optimizely NuGet feed.&lt;/li&gt;
&lt;li&gt;Upload your package to&amp;nbsp;&lt;a href=&quot;https://nuget.optimizely.com/&quot;&gt;nuget.optimizely.com&lt;/a&gt;&amp;nbsp;when it is ready.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;p&gt;You can find all of my content collated on &lt;a href=&quot;https://www.stott.pro/&quot;&gt;https://www.stott.pro/&lt;/a&gt;&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/mark-stott/dates/2024/8/creating-an-optimizely-addon---packaging-for-nuget/</guid>            <pubDate>Mon, 16 Sep 2024 08:17:11 GMT</pubDate>           <category>Blog post</category></item><item> <title>Creating an Optimizely CMS Addon - Adding an Editor Interface Gadget</title>            <link>https://world.optimizely.com/blogs/mark-stott/dates/2024/8/creating-an-optimizely-cms-addon---adding-an-editor-interface-gadget/</link>            <description>&lt;p&gt;In&amp;nbsp;&lt;a href=&quot;/link/38b1217b7fdc41238ed62f1f31907605.aspx&quot;&gt;Part One&lt;/a&gt;&amp;nbsp;of this series, I covered getting started with creating your own AddOn for Optimizely CMS 12. This covered what I consider to be an ideal solution structure, best practices for your JavaScript and Styles, extending the menu interface and authentication. In Part Two, I will be covering adding an additional editor interface gadget. You can view examples from across this series within the this&amp;nbsp;&lt;a href=&quot;https://github.com/GeekInTheNorth/OptimizelyAddOnTemplate&quot;&gt;Optimizely AddOn Template&lt;/a&gt;&amp;nbsp;that I have been creating.&lt;/p&gt;
&lt;div class=&quot;markdown-heading&quot;&gt;
&lt;h2&gt;Adding a Gadget to the Editor Interface&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;You can easily turn a standard MVC Controller into a CMS Editor Gadget by decorating it with the&amp;nbsp;&lt;code&gt;[IFrameComponent]&lt;/code&gt;&amp;nbsp;attribute. The primary properties of the attributes are as follows:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Title &lt;/strong&gt;: The name of the gadget, visible in the Gadget selector.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Description &lt;/strong&gt;: A brief description of the gadget, also visible in the Gadget selector. It is recommended to keep this to one sentence.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Categories &lt;/strong&gt;: The appropriate category for the gadget. For content-specific gadgets, this should be set to &quot;content&quot; or &quot;cms&quot;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Url &lt;/strong&gt;: The route corresponding to your controller action.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PlugInAreas &lt;/strong&gt;: The location within the system where the component should be made available.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;ReloadOnContextChange &lt;/strong&gt;: Enables the UI to reload the gadget each time a different content item is selected within the CMS interface.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Optimizely will automatically identify these controllers and use the properties of the &lt;code&gt;[IFrameComponent]&lt;/code&gt;&amp;nbsp;attribute to populate the Add Gadgets window within the CMS Editor interface:&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/4e76e725f84b403881ffa86520d11db1.aspx?1725023534237&quot; alt=&quot;Gadget Selector in Optimizely CMS 12 Editor Interface&quot; width=&quot;1308&quot; height=&quot;1013&quot; /&gt;&lt;/p&gt;
&lt;p&gt;When the editor interface loads your gadget, it will include an&amp;nbsp;&lt;code&gt;id&lt;/code&gt;&amp;nbsp;query string parameter containing a versioned content reference in string format (e.g., 123_456). The number on the left represents the permanent identity of the content, while the number on the right denotes the specific version of that content item. This information can be used to load the specific version of a content item and incorporate it into your gadget&#39;s model.&lt;/p&gt;
&lt;p&gt;Below is an example of the minimum required setup for your controller. Note the inclusion of the [Authorize] attribute on the controller and the [HttpGet] attribute on the action. These ensure that the user is authenticated and that the interface cannot be accessed using an unexpected HTTP verb. Note how I use the &lt;code&gt;id&lt;/code&gt;&amp;nbsp;query string parameter to load the specific version of the page the user has selected within the CMS Editor interface to provide context specific information to the user.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;[Authorize(Policy = OptimizelyAddOnConstants.AuthorizationPolicy)]
[IFrameComponent(
    Url = &quot;/optimizely-addon/gadget/index/&quot;,
    Title = &quot;Example Gadget&quot;,
    Description = &quot;An example gadget for the CMS Editor Interface.&quot;,
    Categories = &quot;content&quot;,
    PlugInAreas = &quot;/episerver/cms/assets&quot;,
    MinHeight = 200,
    MaxHeight = 800,
    ReloadOnContextChange = true)]
public sealed class GadgetController : Controller
{
    private readonly IContentLoader _contentLoader;

    public GadgetController(IContentLoader contentLoader)
    {
        _contentLoader = contentLoader;
    }

    [HttpGet]
    [Route(&quot;~/optimizely-addon/gadget/index&quot;)]
    public IActionResult Index()
    {
        var model = new GadgetViewModel
        {
            Page = GetPageData(Request),
            ContentId = Request.Query[&quot;Id&quot;].ToString()
        };

        return View(&quot;~/Views/OptimizelyAddOn/Gadget/Index.cshtml&quot;, model);
    }

    private PageData? GetPageData(HttpRequest request)
    {
        var contentReferenceValue = request.Query[&quot;Id&quot;].ToString() ?? string.Empty;
        if (string.IsNullOrWhiteSpace(contentReferenceValue))
        {
            return null;
        }

        var contentReference = new ContentReference(contentReferenceValue);
        if (_contentLoader.TryGet&amp;lt;PageData&amp;gt;(contentReference, out var pageData))
        {
            return pageData;
        }

        return null;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In the gadget I developed for&amp;nbsp;&lt;a href=&quot;https://github.com/GeekInTheNorth/Stott.Security.Optimizely&quot;&gt;Stott Security&lt;/a&gt;, I focus exclusively on rendering a preview of the security headers for the currently selected page.&amp;nbsp; I added this feature as Stott Security supports extending the Content Security Policy for any given page.&amp;nbsp; To ensure the user can be clear on the context for the header preview, I load the page using the same helper method and add it to the view model for my Gadget.&lt;/p&gt;
&lt;div class=&quot;markdown-heading&quot;&gt;
&lt;h2&gt;&lt;img src=&quot;/link/4d30cc76c02042a19babb4b0a96e6982.aspx&quot; /&gt;&lt;/h2&gt;
&lt;h2&gt;Extending IFrameComponent&lt;/h2&gt;
&lt;p&gt;When your user logs into the CMS, they will be given your new Gadget by default.&amp;nbsp; Now you can ensure that only specific roles have access to the Gadget by setting the &amp;nbsp;&lt;code&gt;AllowedRoles&lt;/code&gt;&amp;nbsp;property within the &amp;nbsp;&lt;code&gt;[IFrameComponent]&lt;/code&gt;&amp;nbsp; declaration.&amp;nbsp; If your AddOn allows the developer to define a custom security policy for accessing your module, you cannot simply specify the roles within the attribute.&amp;nbsp; For next version of the Stott Security AddOn, I have created a &lt;code&gt;SecureIFrameComponentAttribute&lt;/code&gt; that inherits the &lt;code&gt;IFrameComponentAttribute&lt;/code&gt; and dynamically resolves the roles that are allowed access to the Gadget based on that security profile.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;[AttributeUsage(AttributeTargets.Class)]
public sealed class SecureIFrameComponentAttribute : IFrameComponentAttribute
{
    public SecureIFrameComponentAttribute() : base()
    {
        try
        {
            var authorizationOptions = ServiceLocator.Current.GetService(typeof(IOptions&amp;lt;AuthorizationOptions&amp;gt;)) as IOptions&amp;lt;AuthorizationOptions&amp;gt;;
            var policy = authorizationOptions?.Value?.GetPolicy(CspConstants.AuthorizationPolicy);
            var roles = policy?.Requirements?.OfType&amp;lt;RolesAuthorizationRequirement&amp;gt;().ToList();
            var roleNames = roles?.SelectMany(x =&amp;gt; x.AllowedRoles).ToList() ?? new List&amp;lt;string&amp;gt; { Roles.WebAdmins, Roles.CmsAdmins, Roles.WebAdmins };

            AllowedRoles = string.Join(&#39;,&#39;, roleNames);
        }
        catch(Exception)
        {
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Telling the CMS Editor Interface About Our AddOn&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;There are two steps to enable the Editor Interface to recognize our AddOn.&amp;nbsp; The first step is to declare our assembly in a&amp;nbsp;&lt;code&gt;module.config&lt;/code&gt;&amp;nbsp;file.&amp;nbsp; Personnally this doesn&#39;t feel like it should be a requirement as all of the information is provided in the&amp;nbsp;&lt;code&gt;[IFrameComponent]&lt;/code&gt;&amp;nbsp;attribute, though it appears that a validation during application startup mandates that this configuration file exists.&amp;nbsp; I suspect this is a requirement tied to much deeper integrations with the UI.&amp;nbsp; E.g. custom DOJO editor code etc.&lt;/p&gt;
&lt;p&gt;Below is an example of a&amp;nbsp;&lt;code&gt;module.config&lt;/code&gt;&amp;nbsp;file. Note the inclusion of an Authorization Policy as an attribute of the module node; this should correspond to the policy required by your AddOn. Additionally, ensure that the full name of the assembly containing your gadget is listed within the assemblies node.&lt;/p&gt;
&lt;pre class=&quot;language-markup&quot;&gt;&lt;code&gt;&amp;lt;?xml version=&quot;1.0&quot; encoding=&quot;utf-8&quot; ?&amp;gt;
&amp;lt;module loadFromBin=&quot;true&quot; clientResourceRelativePath=&quot;&quot; viewEngine=&quot;Razor&quot; authorizationPolicy=&quot;MyAddOn:Policy&quot; moduleJsonSerializerType=&quot;None&quot; prefferedUiJsonSerializerType=&quot;Net&quot;&amp;gt;
  &amp;lt;assemblies&amp;gt;
    &amp;lt;add assembly=&quot;MyAddOnAssemblyName&quot; /&amp;gt;
  &amp;lt;/assemblies&amp;gt;

  &amp;lt;clientModule&amp;gt;
    &amp;lt;moduleDependencies&amp;gt;
      &amp;lt;add dependency=&quot;CMS&quot; /&amp;gt;
    &amp;lt;/moduleDependencies&amp;gt;
  &amp;lt;/clientModule&amp;gt;
&amp;lt;/module&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;div class=&quot;markdown-heading&quot;&gt;
&lt;p&gt;If you are simply adding a gadget to a specific Optimizely CMS build, the assembly declaration can be included in the&amp;nbsp;&lt;code&gt;module.config&lt;/code&gt;&amp;nbsp;file located at the root of your website application. However, in the context of an AddOn, this declaration should be placed within a protected modules folder, using a path such as&amp;nbsp;&lt;code&gt;[MyCmsWebsite]/modules/_protected/[MyAddOn]/module.config&lt;/code&gt;. There are some extra steps required to achieve this when creating a NuGet package and I address these in Part Three of this series which is focused entirely on the NuGet package process.&lt;/p&gt;
&lt;p&gt;The second step that is needed to inform the CMS of our AddOn is to ensure that is included within the &lt;code&gt;ProtectedModuleOptions&lt;/code&gt;.&amp;nbsp; This can be achieved within a service extensions method that you call within your &lt;code&gt;startup.cs&lt;/code&gt;&amp;nbsp;as follows:&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;public static class OptimizelyAddOnServiceExtensions
{
    public static IServiceCollection AddOptimizelyAddOn(this IServiceCollection services)
    {
        services.Configure&amp;lt;ProtectedModuleOptions&amp;gt;(
            options =&amp;gt;
            {
                if (!options.Items.Any(x =&amp;gt; string.Equals(x.Name, &quot;MyAddOnAssemblyName&quot;, StringComparison.OrdinalIgnoreCase)))
                {
                    options.Items.Add(new ModuleDetails { Name = &quot;MyAddOnAssemblyName&quot; });
                }
            });

        return services;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Summary&lt;/h2&gt;
&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;Add the&amp;nbsp;&lt;code&gt;[IFrameComponent]&lt;/code&gt;&amp;nbsp;attribute to your controller to define the gadget for the CMS Editor Interface.&lt;/li&gt;
&lt;li&gt;Add the&amp;nbsp;&lt;code&gt;[Authorize]&lt;/code&gt;&amp;nbsp;attribute to your controller to secure it.&lt;/li&gt;
&lt;li&gt;Add HTTP verb attributes such as&amp;nbsp;&lt;code&gt;[HttpGet]&lt;/code&gt;&amp;nbsp;to your controller actions to prevent unexpected access attempts.&lt;/li&gt;
&lt;li&gt;Add a&amp;nbsp;&lt;code&gt;module.config&lt;/code&gt;&amp;nbsp;file to the&amp;nbsp;&lt;code&gt;[MyCmsWebsite]/modules/_protected/[MyAddOn]/module.config&lt;/code&gt;&amp;nbsp;folder so that CMS can validate your gadget assembly.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;p&gt;You can find all of my content collated on &lt;a href=&quot;https://www.stott.pro/&quot;&gt;https://www.stott.pro/&lt;/a&gt;&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/mark-stott/dates/2024/8/creating-an-optimizely-cms-addon---adding-an-editor-interface-gadget/</guid>            <pubDate>Fri, 30 Aug 2024 15:43:33 GMT</pubDate>           <category>Blog post</category></item></channel>
</rss>