<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom"><title type="text">Blogs</title><link href="http://world.optimizely.com" /><updated>2026-08-07T02:53:49.0000000Z</updated><id>https://world.optimizely.com/blogs/</id> <generator uri="http://world.optimizely.com" version="2.0">Optimizely World</generator> <entry><title>Optimizely Commerce 14 Now Supports .NET 10</title><link href="https://world.optimizely.com/blogs/bien-nguyen/dates/2026/8/optimizely-commerce-14-now-supports-.net-10" /><id>&lt;div&gt;
&lt;p&gt;In my previous post, &lt;a href=&quot;/link/d15848f947264cc59c3d86b9c7d55c95.aspx&quot;&gt;&lt;em&gt;Optimizely CMS 12 Now Fully Supports .NET 10&lt;/em&gt;&lt;/a&gt;, I noted one remaining limitation: &lt;strong&gt;Commerce 14 (Commerce Connect)&lt;/strong&gt; did not yet support .NET 10, and Commerce customers were advised to stay on .NET 8.&lt;/p&gt;
&lt;p&gt;I&#39;m happy to report that this gap has now been closed. &lt;strong&gt;Commerce 14 officially supports .NET 10 starting with version 14.46.0. &lt;/strong&gt;It&#39;s also mentioned in the &lt;a href=&quot;https://support.optimizely.com/hc/en-us/articles/23973422587405-2026-Commerce-Connect-release-notes#h_01KECT5AZ1XKGXHK59KB1XF3WT&quot;&gt;release notes&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;What&#39;s New&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;EPiServer.Commerce 14.46.0&lt;/strong&gt; &amp;ndash; adds official .NET 10 support.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;EPiServer.Find.Commerce 12.3.0&lt;/strong&gt; &amp;ndash; supports Commerce 14 running on .NET 10.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you&#39;re using &lt;strong&gt;Optimizely Search &amp;amp; Navigation (Find)&lt;/strong&gt; with Commerce, upgrade both packages together to ensure full compatibility.&lt;/p&gt;
&lt;div class=&quot;___1dmoc29 f10pi13n ftgm304 f1enuhaj fdclmfp f1nbblvp fat0sn4 f1ov4xf1 fekwl8i f1lmfglv f1oz7aqm f1abmfm4 f1w619qj f16h0jq8&quot;&gt;
&lt;table class=&quot;___1vyiefv f1ddd56o f16vktn6 f1ahpp82 f11qra4b f1uinfot fibjyge fvueend f9yszdx f1fu4s3n f3l3pb3 f10ghnd0 f8fmt76 fjvbh62 f1qrqxae f1vw5qpk fc02sbz fxawf59 fymf513 f1aoyrul f1el8yx3 f1pymoxg f1ofu761 fe6itr f7coize f1794535 f1o0pw0q fbjjl9v fk1v6el f16pyhcb f1ixlhx9 f12zef0i flu5r5u f19haqzy f1owmcxx f1oddm8q f1004tna fcoaxci fh0ee9u f15v23i2 f1dmj53 f1r1gcv9 f14z1veh ffufd3x f1ypplot f1660cg&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;th&gt;Package&lt;/th&gt;
&lt;th&gt;Minimum Version&lt;/th&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EPiServer.Commerce&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://nuget.optimizely.com/packages/episerver.commerce/14.46.0&quot;&gt;14.46.0&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EPiServer.Find.Commerce&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://nuget.optimizely.com/packages/episerver.find.commerce/12.3.0&quot;&gt;12.3.0&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p&gt;This means the full Optimizely stack, including both &lt;strong&gt;CMS 12 and Commerce 14&lt;/strong&gt;, can now run on &lt;strong&gt;.NET 10&lt;/strong&gt;.&lt;/p&gt;
&lt;h2&gt;Why Upgrade?&lt;/h2&gt;
&lt;p&gt;With &lt;strong&gt;.NET 8 and .NET 9 reaching end of support on November 10, 2026&lt;/strong&gt;, .NET 10 is now the recommended target. As the current LTS release, it provides support through &lt;strong&gt;November 2028&lt;/strong&gt;, along with performance improvements, security updates, and a longer support horizon.&lt;/p&gt;
&lt;h2&gt;Recommendations&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Upgrade &lt;strong&gt;EPiServer.Commerce&lt;/strong&gt; to &lt;strong&gt;14.46.0&lt;/strong&gt; or later before moving to .NET 10.&lt;/li&gt;
&lt;li&gt;Upgrade &lt;strong&gt;EPiServer.Find.Commerce&lt;/strong&gt; to &lt;strong&gt;12.3.0&lt;/strong&gt; or later if you use Find with Commerce.&lt;/li&gt;
&lt;li&gt;Verify that any additional add-ons or integrations are compatible with .NET 10.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For upgrade guidance and system requirements, refer to the &lt;a href=&quot;https://docs.developers.optimizely.com/content-management-system/docs/system-requirements-for-optimizely&quot;&gt;Optimizely Developer Documentation&lt;/a&gt; or contact &lt;a href=&quot;https://support.optimizely.com/hc/en-us&quot;&gt;Optimizely Support&lt;/a&gt; for assistance.&lt;/p&gt;
&lt;/div&gt;</id><updated>2026-08-07T02:53:49.0000000Z</updated><summary type="html">Blog post</summary></entry> <entry><title>The Top 10 Things I&#39;m Actually Using AI For</title><link href="https://world.optimizely.com/blogs/kennyg/dates/2026/8/the-top-10-things-im-actually-using-ai-for/" /><id>&lt;div&gt;
&lt;p&gt;A while back I wrote about ReviewPR, an Azure Function that uses AI to review Azure DevOps pull requests. That was one specific use case, but over the last year AI has become part of my day-to-day workflow in a lot of other ways.&lt;/p&gt;
&lt;p&gt;For context, we run Optimizely CMS 12 and Commerce Connect 14 on DXP for a national homebuilder. It&#39;s a large platform with a lot of moving parts, years of history, and a relatively small team responsible for keeping everything running.&lt;/p&gt;
&lt;p&gt;The biggest thing that changed for me is that I stopped thinking of AI as a coding tool. The real value has been giving it access to the systems I already use every day, then letting it help connect information across them. Logs, telemetry, deployments, databases, source code, configuration, and documentation all tell part of the story. Having something that can look across all of them at once has saved me a surprising amount of time.&lt;/p&gt;
&lt;p&gt;Here are the areas where I&#39;ve gotten the most value from it.&lt;/p&gt;
&lt;h2&gt;1. Troubleshooting Production Issues&lt;/h2&gt;
&lt;p&gt;This is the one that changed how I work.&lt;/p&gt;
&lt;p&gt;Most production incidents aren&#39;t caused by a single obvious failure. They show up as symptoms scattered across several systems, and a lot of the effort goes into figuring out which signals actually matter.&lt;/p&gt;
&lt;p&gt;One recurring pattern has been discovering that the original assumption was wrong. Problems that looked like database issues turned out to be networking issues. Stability problems ended up being scaling rules watching the wrong metric. Intermittent exceptions came back to application design problems that had been hiding for months. Looking at telemetry, deployments, infrastructure metrics, and source code together tends to surface connections that are easy to miss when you&#39;re investigating one system at a time.&lt;/p&gt;
&lt;p&gt;Just as importantly, it&#39;s been useful for ruling things out. Finding out a theory is wrong in an hour is often more valuable than finding the right answer after three days.&lt;/p&gt;
&lt;h2&gt;2. Answering the Questions Nobody Has Time For&lt;/h2&gt;
&lt;p&gt;Every long-running Optimizely implementation develops a backlog of questions that never quite make it onto a sprint.&lt;/p&gt;
&lt;p&gt;Which scheduled jobs are still needed? Which old integrations can be removed? Are there migrations that never completed correctly? How much data is sitting around because nobody ever cleaned it up?&lt;/p&gt;
&lt;p&gt;These are all answerable questions, but they&#39;re usually more tedious than difficult. AI has been particularly useful for this kind of investigative work because it can sift through the details much faster than I can manually.&lt;/p&gt;
&lt;h2&gt;3. Building Small Projects That Cross Too Many Boundaries&lt;/h2&gt;
&lt;p&gt;Some of my favorite uses have been side projects that touch several different technologies at once.&lt;/p&gt;
&lt;p&gt;Usually these aren&#39;t difficult projects. They just require learning a new API, figuring out an authentication model, understanding an unfamiliar library, and wiring everything together. That&#39;s often enough friction to keep a good idea from ever getting finished.&lt;/p&gt;
&lt;p&gt;Having AI help bridge those gaps has made it much easier to take an idea from concept to something working without spending days context-switching between documentation sites.&lt;/p&gt;
&lt;h2&gt;4. Arguing With My Own Code&lt;/h2&gt;
&lt;p&gt;I&#39;ve gotten into the habit of asking for a deliberately skeptical review before opening a pull request.&lt;/p&gt;
&lt;p&gt;Not &quot;does this look okay?&quot; but &quot;explain why this is wrong.&quot;&lt;/p&gt;
&lt;p&gt;That tends to produce much better feedback. It&#39;s caught fixes that only suppressed warnings, tests that weren&#39;t actually validating behavior, and assumptions that looked safe until somebody challenged them. Even when I disagree with the feedback, forcing myself to defend the implementation usually improves the final result.&lt;/p&gt;
&lt;h2&gt;5. Writing Better Tests&lt;/h2&gt;
&lt;p&gt;This is one area where I&#39;ve learned to be careful.&lt;/p&gt;
&lt;p&gt;Left alone, AI tends to write tests around implementation details rather than behavior. Those tests often pass, but they don&#39;t necessarily prove anything valuable.&lt;/p&gt;
&lt;p&gt;The best results have come from treating it like a partner in test design rather than a test generator. I want help identifying scenarios, edge cases, and requirements. The actual test is much less important than proving the behavior we&#39;re trying to protect.&lt;/p&gt;
&lt;h2&gt;6. Making Sense of Telemetry&lt;/h2&gt;
&lt;p&gt;Modern systems generate more telemetry than most developers can realistically consume.&lt;/p&gt;
&lt;p&gt;One thing AI does well is help summarize what changed between two periods of time and identify where an investigation should start. Sometimes the answer is obvious after the fact, but getting to that point can require digging through thousands of events, metrics, and traces.&lt;/p&gt;
&lt;p&gt;The biggest value isn&#39;t necessarily the answer itself. It&#39;s reducing the amount of time spent looking in the wrong place.&lt;/p&gt;
&lt;h2&gt;7. Finding the Bugs Nobody Sees&lt;/h2&gt;
&lt;p&gt;Some problems are obvious once they&#39;re found and almost invisible before that.&lt;/p&gt;
&lt;p&gt;I&#39;ve seen issues caused by a single unexpected character, subtle data inconsistencies, and small assumptions that quietly affected behavior without ever generating an obvious failure.&lt;/p&gt;
&lt;p&gt;These are the kinds of things humans can find, but they&#39;re also the kinds of things we tend to overlook because we&#39;re focused on larger problems. AI is surprisingly good at noticing details that don&#39;t stand out during normal troubleshooting.&lt;/p&gt;
&lt;h2&gt;8. Capturing Institutional Knowledge&lt;/h2&gt;
&lt;p&gt;Every team has information that exists mostly in people&#39;s heads.&lt;/p&gt;
&lt;p&gt;Environment-specific quirks, deployment lessons, platform limitations, and the odd exceptions that nobody remembers until something breaks.&lt;/p&gt;
&lt;p&gt;I&#39;ve started being much more intentional about documenting those lessons. AI has been useful for organizing and retrieving that information, especially when it spans years of projects and multiple systems.&lt;/p&gt;
&lt;h2&gt;9. Hardware Troubleshooting&lt;/h2&gt;
&lt;p&gt;The same approach works surprisingly well outside software development.&lt;/p&gt;
&lt;p&gt;Most hardware troubleshooting comes down to collecting evidence. Event logs, diagnostics, performance data, firmware versions, and error messages all provide clues, but pulling them together takes time.&lt;/p&gt;
&lt;p&gt;Being able to feed in the evidence and get a structured analysis has made the process significantly faster.&lt;/p&gt;
&lt;h2&gt;10. Home Networking and Security&lt;/h2&gt;
&lt;p&gt;I&#39;ve also used AI extensively for home networking and security projects.&lt;/p&gt;
&lt;p&gt;The biggest value hasn&#39;t been creating configurations. It&#39;s been reviewing them. Understanding what a rule actually allows, identifying unnecessary exposure, and validating assumptions turns out to be just as useful at home as it is in production systems.&lt;/p&gt;
&lt;p&gt;One lesson that carries across both worlds is that asking &quot;what could this reach?&quot; is often more useful than asking &quot;is this secure?&quot;&lt;/p&gt;
&lt;h2&gt;A Few Things to Watch&lt;/h2&gt;
&lt;p&gt;AI gets things wrong. Regularly.&lt;/p&gt;
&lt;p&gt;The failure mode isn&#39;t that it doesn&#39;t know the answer. The failure mode is that it gives a clean, confident explanation that sounds reasonable and happens to be completely wrong.&lt;/p&gt;
&lt;p&gt;That&#39;s why I always want to see the underlying evidence. The logs, the query, the deployment, the code, or whatever data led to the conclusion.&lt;/p&gt;
&lt;p&gt;I&#39;ve also found that context matters far more than prompts. The more access it has to the relevant information, the more useful it becomes. A model that can read logs, telemetry, source code, and configuration is a very different tool from one that can only answer questions in a chat window.&lt;/p&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;If I had to summarize where AI has helped me most, it comes down to three things:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Investigating problems faster.&lt;/li&gt;
&lt;li&gt;Connecting information across systems.&lt;/li&gt;
&lt;li&gt;Challenging assumptions before I spend time acting on them.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;It hasn&#39;t replaced experience, judgment, or critical thinking.&lt;/p&gt;
&lt;p&gt;Mostly, it&#39;s removed a lot of the manual digging that used to sit between noticing a problem and understanding it.&lt;/p&gt;
&lt;/div&gt;</id><updated>2026-08-05T19:24:01.0000000Z</updated><summary type="html">Blog post</summary></entry> <entry><title>Drag-and-Drop Reordering for Commerce Media Collection in Optimizely Commerce Connect</title><link href="https://world.optimizely.com/blogs/linh-doan-cuu/dates/2026/8/drag-and-drop-reordering-for-commerce-media-collection-in-optimizely-commerce-connect/" /><id>&lt;p&gt;Optimizely Commerce Connect ships a polished asset editor for the &lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;CommerceMediaCollection&amp;nbsp;&lt;/span&gt;property on catalog entries. It lets editors add, remove, and reorder media assets directly in the edit view &amp;mdash; a solid baseline for most projects. Reordering is done via &quot;Move Up&quot; and &quot;Move Down&quot; buttons in the grid, which is perfectly fine when you have a handful of assets.&lt;/p&gt;
&lt;p&gt;For some projects, though, editors need to manage dozens of images per product &amp;mdash; product shots, lifestyle images, detail crops, downloads &amp;mdash; and clicking a button 30 times to move an asset to the top becomes a real workflow problem. Drag-and-drop row reordering is the natural solution. This post walks through how to add it by extending the built-in Commerce editor rather than replacing it.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;The Property&lt;/h2&gt;
&lt;p&gt;The&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;CommerceMediaCollection&amp;nbsp;&lt;/span&gt;property is declared on&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;EntryContentBase&amp;nbsp;&lt;/span&gt;and typed as&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;ItemCollection&amp;lt;CommerceMedia&amp;gt;&lt;/span&gt;. Each&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;CommerceMedia&amp;nbsp;&lt;/span&gt;item carries a&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;SortOrder&amp;nbsp;&lt;/span&gt;integer that controls display priority on the front end &amp;mdash; carousels, image galleries, download lists. Whatever order the editor sees in the CMS is what the front end is supposed to render.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;[UIHint(&quot;commercemediacollection&quot;)]
public virtual ItemCollection&amp;lt;CommerceMedia&amp;gt; CommerceMediaCollection { get; set; }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The built-in editor already renders this as a dgrid with drag-handle affordances &amp;mdash; the visual infrastructure for drag-and-drop is present. Wiring it up for internal row reordering and making sure&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;SortOrder&amp;nbsp;&lt;/span&gt;is written correctly afterward is the implementation work.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Approach: Extend, Don&#39;t Replace&lt;/h2&gt;
&lt;p&gt;Optimizely Commerce Connect Asset Collection&#39;s editor descriptor sets up column definitions, thumbnail formatters, item converters, and the dialog flow for adding assets. Rather than reimplementing all of that, the approach is to register a custom descriptor that runs last, inherits everything the Commerce descriptor configured, and only swaps out the client-side widget class.&lt;/p&gt;
&lt;p&gt;The widget itself extends&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;CommerceMediaCollectionEditor&amp;nbsp;&lt;/span&gt;and overrides the minimum needed: how the grid&#39;s DnD layer is wired, how sort order is written after a drag, and whether columns are user-sortable.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Step 1 &amp;mdash; Register a Custom Editor Descriptor&lt;/h2&gt;
&lt;p&gt;Optimizely CMS resolves editor descriptors by&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;(TargetType, UIHint)&lt;/span&gt;&amp;nbsp;pair. The&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;CommerceMediaCollection&lt;/span&gt;&amp;nbsp;property carries&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;[UIHint(&quot;commercemediacollection&quot;)]&lt;/span&gt;, so the custom descriptor must declare the same UIHint.&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;EditorDescriptorBehavior.PlaceLast&lt;/span&gt;&amp;nbsp;ensures it runs after Commerce&#39;s built-in descriptor, so all Commerce-specific metadata is already applied before we override just the widget class name.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;using EPiServer.Commerce.SpecializedProperties;
using EPiServer.Shell.ObjectEditing;
using EPiServer.Shell.ObjectEditing.EditorDescriptors;

[EditorDescriptorRegistration(
    TargetType = typeof(ItemCollection&amp;lt;CommerceMedia&amp;gt;),
    UIHint = &quot;commercemediacollection&quot;,
    EditorDescriptorBehavior = EditorDescriptorBehavior.PlaceLast)]
public class CommerceMediaDndEditorDescriptor : EditorDescriptor
{
    public override void ModifyMetadata(
        ExtendedMetadata metadata,
        IEnumerable&amp;lt;Attribute&amp;gt; attributes)
    {
        base.ModifyMetadata(metadata, attributes);
        metadata.ClientEditingClass = &quot;myproject/editors/CommerceMediaDndEditor&quot;;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;Step 2 &amp;mdash; Create a Protected Shell Module&lt;/h2&gt;
&lt;p&gt;The Dojo AMD path&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;myproject/editors/CommerceMediaDndEditor&lt;/span&gt;&amp;nbsp;must resolve to a real file. That means registering a Dojo package called&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;myproject&amp;nbsp;&lt;/span&gt;via a protected shell module.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;modules/_protected/MyProject.Commerce.UI/module.config:&lt;/span&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;&amp;lt;?xml version=&quot;1.0&quot; encoding=&quot;utf-8&quot;?&amp;gt;
&amp;lt;module name=&quot;MyProject.Commerce.UI&quot; clientResourceRelativePath=&quot;&quot;&amp;gt;
    &amp;lt;dojo&amp;gt;
        &amp;lt;packages&amp;gt;
            &amp;lt;add name=&quot;myproject&quot; location=&quot;ClientResources&quot; /&amp;gt;
        &amp;lt;/packages&amp;gt;
    &amp;lt;/dojo&amp;gt;
    &amp;lt;clientModule&amp;gt;
        &amp;lt;moduleDependencies&amp;gt;
            &amp;lt;add dependency=&quot;CMS&quot; /&amp;gt;
            &amp;lt;add dependency=&quot;Commerce&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;p&gt;&lt;strong&gt;Source file tracking:&lt;/strong&gt;&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;modules/_protected/&amp;nbsp;&lt;/span&gt;is typically gitignored because NuGet restores add-on packages there. Keep your source in a separate tracked directory (e.g.&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;ShellModules/_protected/&lt;/span&gt;) and copy it at build time:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;&amp;lt;ItemGroup&amp;gt;
    &amp;lt;ShellModuleSource Include=&quot;ShellModules\_protected\**\*&quot; /&amp;gt;
    &amp;lt;Content Remove=&quot;ShellModules\_protected\**\*&quot; /&amp;gt;
    &amp;lt;Content Include=&quot;@(ShellModuleSource)&quot;&amp;gt;
        &amp;lt;Link&amp;gt;modules\_protected\%(RecursiveDir)%(FileName)%(Extension)&amp;lt;/Link&amp;gt;
        &amp;lt;CopyToOutputDirectory&amp;gt;PreserveNewest&amp;lt;/CopyToOutputDirectory&amp;gt;
        &amp;lt;CopyToPublishDirectory&amp;gt;PreserveNewest&amp;lt;/CopyToPublishDirectory&amp;gt;
    &amp;lt;/Content&amp;gt;
&amp;lt;/ItemGroup&amp;gt;

&amp;lt;Target Name=&quot;CopyCustomShellModules&quot; BeforeTargets=&quot;Build&quot;&amp;gt;
    &amp;lt;Copy
        SourceFiles=&quot;@(ShellModuleSource)&quot;
        DestinationFolder=&quot;$(MSBuildProjectDirectory)\modules\_protected\%(RecursiveDir)&quot;
        SkipUnchangedFiles=&quot;true&quot; /&amp;gt;
&amp;lt;/Target&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Explicit module registration:&lt;/strong&gt;&amp;nbsp;EPiServer Shell 12.x auto-discovery matches module directories to assemblies by name. A module named&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;MyProject.Commerce.UI&lt;/span&gt;&amp;nbsp;with no correspondingly-named assembly is skipped. Register it explicitly via&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;IConfigurableModule&lt;/span&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;using EPiServer.Framework;
using EPiServer.Framework.Initialization;
using EPiServer.ServiceLocation;
using EPiServer.Shell.Modules;
using Microsoft.Extensions.DependencyInjection;

[InitializableModule]
[ModuleDependency(typeof(EPiServer.Shell.UI.InitializationModule))]
public class CommerceUiModuleRegistration : IConfigurableModule
{
    public void ConfigureContainer(ServiceConfigurationContext context)
    {
        context.Services.Configure&amp;lt;ProtectedModuleOptions&amp;gt;(options =&amp;gt;
        {
            if (options.Items.Any(x =&amp;gt; x.Name == &quot;MyProject.Commerce.UI&quot;))
                return;

            options.Items.Add(new ModuleDetails
            {
                Name = &quot;MyProject.Commerce.UI&quot;
            });
        });
    }

    public void Initialize(InitializationEngine context) { }
    public void Uninitialize(InitializationEngine context) { }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt;&amp;nbsp;Do not set&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;Assemblies&amp;nbsp;&lt;/span&gt;in&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;ModuleDetails&amp;nbsp;&lt;/span&gt;to your main web assembly. This module is purely client-side (JavaScript +&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;module.config&lt;/span&gt;) &amp;mdash; no C# shell controllers. Pointing&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;Assemblies&lt;/span&gt;&amp;nbsp;at the main project assembly causes EPiServer Shell to re-process it under its own application-part rules, which conflicts with ASP.NET Core&#39;s existing registration of that assembly and breaks ViewComponent discovery. Omit&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;Assemblies&amp;nbsp;&lt;/span&gt;for client-only modules.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h2&gt;Step 3 &amp;mdash; The Custom Dojo Widget&lt;/h2&gt;
&lt;p&gt;The widget has three jobs:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Enable internal row DnD&lt;/strong&gt;&amp;nbsp;&amp;mdash;&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;epi/shell/dnd/Source&lt;/span&gt;&amp;nbsp;(the DnD source class used by the grid) does not self-accept by default when&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;accept&lt;/span&gt;&amp;nbsp;type strings are configured. Commerce media items don&#39;t carry a recognized&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;typeIdentifier&lt;/span&gt;, so the type-matching check fails for same-source drops. Wrapping&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;checkAcceptance&amp;nbsp;&lt;/span&gt;with&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;dojo/aspect&lt;/span&gt;&#39;s&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;around&amp;nbsp;&lt;/span&gt;restores the standard behaviour for internal drags while leaving external drop handling (adding assets from the DAM) completely unchanged.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Write sequential&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;SortOrder&lt;/span&gt;&amp;nbsp;values&lt;/strong&gt;&amp;nbsp;&amp;mdash; After a drag, every item needs a&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;SortOrder&amp;nbsp;&lt;/span&gt;that matches its new visual position. The custom model override handles the move and renumbers all items 1, 2, 3, &amp;hellip; in one atomic update, firing&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;itemsChanged&amp;nbsp;&lt;/span&gt;exactly once with the final correct state.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Disable column header sorting&lt;/strong&gt;&amp;nbsp;&amp;mdash; Clicking a column header in the grid would re-sort rows by that column&#39;s data without saving, creating a mismatch between what the editor sees and what is stored. Marking all columns&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;sortable: false&lt;/span&gt;&amp;nbsp;prevents this.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;define([
  &quot;dojo/_base/declare&quot;,
  &quot;dojo/aspect&quot;,
  &quot;epi-ecf-ui/contentediting/editors/CommerceMediaCollectionEditor&quot;,
  &quot;epi-ecf-ui/contentediting/editors/model/CommerceMediaCollectionEditorModel&quot;,
], function (
  declare,
  aspect,
  CommerceMediaCollectionEditor,
  CommerceMediaCollectionEditorModel,
) {
  // Extended model: moves the item in the array and assigns sequential
  // SortOrder values across all items in a single atomic update.
  var ShiftReorderModel = declare([CommerceMediaCollectionEditorModel], {
    moveItem: function (item, target, before) {
      // Suppress itemsChanged from the first splice (remove)
      this._itemsUnchanged = true;
      var fromIdx = this._itemModels.indexOf(item);
      this._itemModels.splice(fromIdx, 1);

      // Suppress itemsChanged from the second splice (insert)
      this._itemsUnchanged = true;
      var toIdx = this._itemModels.indexOf(target);
      toIdx =
        toIdx === -1
          ? this._itemModels.length // dropped past last row &amp;rarr; append
          : before
            ? toIdx
            : toIdx + 1;
      this._itemModels.splice(toIdx, 0, item);

      // Renumber: SortOrder 1, 2, 3, &amp;hellip; in array order
      this._itemModels.forEach(function (m, i) {
        m.sortOrder = i + 1;
      });

      // Fire once with the final correct state
      this.emit(&quot;itemsChanged&quot;, this.get(&quot;items&quot;));
    },
  });

  return declare([CommerceMediaCollectionEditor], {
    modelType: ShiftReorderModel,

    // Disable column header sorting so grid order always reflects stored order
    _getGridDefinition: function () {
      var columns = this.inherited(arguments);
      for (var col in columns) {
        if (columns[col]) {
          columns[col].sortable = false;
        }
      }
      return columns;
    },

    // Wire internal DnD and restore self-acceptance for same-source drops
    _setupDnD: function () {
      this.inherited(arguments);

      var dndSrc = this.grid.dndSource;
      this.own(
        aspect.around(dndSrc, &quot;checkAcceptance&quot;, function (original) {
          return function (source, nodes) {
            // Allow reordering within the same grid
            if (source === this) {
              return true;
            }
            return original.apply(this, arguments);
          };
        }),
      );
    },
  });
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Place this at&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;ShellModules/_protected/MyProject.Commerce.UI/ClientResources/editors/CommerceMediaDndEditor.js&lt;/span&gt;&amp;nbsp;(the MSBuild target copies it to&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;modules/_protected/&lt;/span&gt;&amp;nbsp;at build time).&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;End Result&lt;/h2&gt;
&lt;p&gt;Editors open a catalog entry, switch to the Assets tab, and drag rows to reorder media. The grid updates immediately. On save, each asset&#39;s&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;SortOrder&amp;nbsp;&lt;/span&gt;reflects its position in the grid &amp;mdash; 1 for the first row, 2 for the second, and so on. No separate admin page, no property changes, no base class modifications.&lt;/p&gt;
&lt;p&gt;The solution can be applied to any&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;ItemCollection&amp;lt;CommerceMedia&amp;gt;&lt;/span&gt;&amp;nbsp;property in the codebase with the correct UIHint.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;File Checklist&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;CommerceMediaDndEditorDescriptor.cs&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;Registers the custom widget for&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;ItemCollection&amp;lt;CommerceMedia&amp;gt;&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;CommerceUiModuleRegistration.cs&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;Registers the shell module explicitly so auto-discovery doesn&#39;t skip it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;ShellModules/_protected/MyProject.Commerce.UI/module.config&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;Declares the&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;myproject&amp;nbsp;&lt;/span&gt;Dojo package&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;ShellModules/_protected/MyProject.Commerce.UI/ClientResources/editors/CommerceMediaDndEditor.js&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;The custom editor widget&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;MyProject.csproj&lt;/span&gt;&amp;nbsp;(MSBuild target)&lt;/td&gt;
&lt;td&gt;Copies shell module source to&amp;nbsp;&lt;span style=&quot;font-family: &#39;courier new&#39;, courier, monospace;&quot;&gt;modules/_protected/ &lt;/span&gt;at build time&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;</id><updated>2026-08-05T09:48:55.0000000Z</updated><summary type="html">Blog post</summary></entry> <entry><title>Order tabs with drag and drop V2</title><link href="https://world.optimizely.com/blogs/Per-Nergard/Dates/2026/8/order-tabs-with-drag-and-drop-v2/" /><id>&lt;p&gt;I earlier did a very simple Blazor version to be able to sort tabs with drag and drop.&lt;/p&gt;
&lt;p&gt;I wanted to update it a bit and also display how the tabs are actually ordered on the content types to spot inconsistencys or make it easier to fine tune placement by being able to drag and drop in the tab layout directly instead of adjusting in a potential long vertical list.&lt;br /&gt;&lt;br /&gt;Nothing changes until you hit save. Have som export / import buttons but I havent tested that yet.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;You can find the code over at my &lt;a href=&quot;https://github.com/PNergard/Nergard.Opti.TabSorter&quot;&gt;GitHub&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/476714de6566474da9deacd2b66a120d.aspx&quot; alt=&quot;&quot; width=&quot;759&quot; height=&quot;627&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;</id><updated>2026-08-04T09:56:57.0000000Z</updated><summary type="html">Blog post</summary></entry> <entry><title>Optimizely : Missing Language Manager Gadget Fix in CMS 12 After Upgrading to .NET 10</title><link href="https://madhuanbalagan.com/?p=5042" /><id>&lt;p&gt;Recently, while upgrading an Optimizely CMS solution to .NET 10, we came across an issue where the Language Manager gadget completely disappeared from CMS Edit&amp;#46;&amp;#46;&amp;#46;&lt;/p&gt;
&lt;p&gt;The post &lt;a href=&quot;https://madhuanbalagan.com/optimizely-missing-language-manager-gadget-fix-in-cms-12-after-upgrading-to-net-10&quot;&gt;Optimizely : Missing Language Manager Gadget Fix in CMS 12 After Upgrading to .NET 10&lt;/a&gt; appeared first on &lt;a href=&quot;https://madhuanbalagan.com&quot;&gt;Madhu Anbalagan&amp;#039;s Blog&lt;/a&gt;.&lt;/p&gt;
</id><updated>2026-08-02T16:15:41.0000000Z</updated><summary type="html">Blog post</summary></entry> <entry><title>Flat or Nested? Weighing Your Content Modeling Options in Optimizely SaaS CMS</title><link href="https://world.optimizely.com/blogs/vipin-banka--learnings--insights/dates/2026/7/flat-or-nested-weighing-your-content-modeling-options-in-optimizely-saas-cms/" /><id>&lt;p&gt;When building a headless site on Optimizely SaaS CMS, one of the earliest and most critical design milestones your team will face is defining your &lt;strong&gt;content model&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Unlike traditional monolithic setups where visual layout often dictates data structure, headless modeling requires you to design a clear, reusable contract between two very different groups of people: your &lt;strong&gt;content authors&lt;/strong&gt; (working in the CMS UI) and your &lt;strong&gt;developers&lt;/strong&gt; (writing front-end components).&lt;/p&gt;
&lt;p&gt;Because developers are trained to eliminate duplicate code (the &quot;DRY&quot; principle), they often instinctively design highly complex, nested, or consolidated data structures. Authors, on the other hand, require flat, intuitive, and self-explanatory editing interfaces.&lt;/p&gt;
&lt;p&gt;When these two instincts collide, tension arises.&lt;/p&gt;
&lt;p&gt;Below, we&amp;rsquo;ll walk through a common, real-world scenario&amp;mdash;modeling standard videos versus gated lead-generation videos&amp;mdash;and lay out three valid modeling options, complete with their architectural trade-offs, so your team can make a well-informed choice.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;The Scenario: One Concept, Two Realities&lt;/h2&gt;
&lt;p&gt;Let&#39;s say your site needs to support videos in two very different business contexts:&lt;/p&gt;
&lt;ol class=&quot;tight&quot;&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Standard Video:&lt;/strong&gt; An editorial or informational video with autoplay, looping, and visual player controls.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Gated Video:&lt;/strong&gt; A lead-generation asset that plays a brief preview, pauses at a specific timestamp, and displays an email capture form synced automatically with an external Marketing Automation Platform (MAP).&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; border-width: 1px; border-style: solid;&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;padding: 10px; text-align: left; vertical-align: top; border-width: 1px; border-style: solid;&quot;&gt;
&lt;p&gt;&lt;strong&gt;Feature&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;padding: 10px; text-align: left; vertical-align: top; border-width: 1px; border-style: solid;&quot;&gt;
&lt;p&gt;&lt;strong&gt;Standard Video&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;padding: 10px; text-align: left; vertical-align: top; border-width: 1px; border-style: solid;&quot;&gt;
&lt;p&gt;&lt;strong&gt;Gated Video&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top;&quot;&gt;
&lt;p&gt;&lt;strong&gt;Author sets&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top;&quot;&gt;
&lt;p&gt;Video file, Poster image, Title, Autoplay toggle, Loop toggle, Show controls&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top;&quot;&gt;
&lt;p&gt;Video file, Poster image, Title&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top;&quot;&gt;
&lt;p&gt;&lt;strong&gt;Sourced from MAP&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top;&quot;&gt;
&lt;p&gt;Not applicable&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top;&quot;&gt;
&lt;p&gt;Form ID, Gate Timestamp (seconds to trigger form)&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top;&quot;&gt;
&lt;p&gt;&lt;strong&gt;Front-end behavior&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top;&quot;&gt;
&lt;p&gt;Continuous playback, native player controls&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top;&quot;&gt;
&lt;p&gt;Pauses playback at Gate time and overlays lead capture form&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;To model this, your team has three distinct structural approaches to consider. Let&#39;s look at the trade-offs of each.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Option 1: The Consolidated &quot;Mega-Block&quot; (All Fields in One Type)&lt;/h2&gt;
&lt;p&gt;This approach creates a single, highly flexible content type&amp;mdash;such as VideoComponent&amp;mdash;containing every possible property. You then rely on the author&#39;s training (or inline help text) to know which fields to fill.&lt;/p&gt;
&lt;pre class=&quot;code-block&quot;&gt;&lt;code&gt;Consolidated Block: VideoComponent
├── VideoFile
├── PosterImage
├── Title
├── Autoplay         (Fill only for Standard)
├── Loop             (Fill only for Standard)
├── ShowControls     (Fill only for Standard)
├── FormId           (Fill only for Gated)
└── GateTimestamp    (Fill only for Gated)&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;The Trade-Offs&lt;/h3&gt;
&lt;ul class=&quot;tight&quot;&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt;&lt;/p&gt;
&lt;ul class=&quot;tight&quot;&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Simple Setup:&lt;/strong&gt; Only one content type to define, register, and sync to the CMS.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Single Component:&lt;/strong&gt; Developers maintain just one straightforward React component with simple conditional rendering.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Cons:&lt;/strong&gt;&lt;/p&gt;
&lt;ul class=&quot;tight&quot;&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Author Cognitive Load:&lt;/strong&gt; Authors are forced to look at &quot;dead fields.&quot; An editor adding a gated marketing video must mentally ignore Autoplay, Loop, and Controls.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Validation Gaps:&lt;/strong&gt; You cannot easily make fields like FormId required for gated videos without making them required for standard videos too, risking incomplete data.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;Option 2: The Nested &quot;Wrapper Block&quot; (Composition via Content Area)&lt;/h2&gt;
&lt;p&gt;This approach seeks to avoid database field duplication by pulling the shared properties (&lt;strong&gt;VideoFile&lt;/strong&gt;, &lt;strong&gt;PosterImage&lt;/strong&gt;, and &lt;strong&gt;Title&lt;/strong&gt;) into a parent &lt;strong&gt;VideoItem &lt;/strong&gt;block. This block then contains a nested Content Area where the author inserts a secondary &quot;extension block&quot; containing the specific properties.&lt;/p&gt;
&lt;pre class=&quot;code-block&quot;&gt;&lt;code&gt;Parent Block: VideoItem
├── VideoFile
├── PosterImage
├── Title
└── Content Area (Allows only StandardProperties OR GatedProperties)&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;The Trade-Offs&lt;/h3&gt;
&lt;ul class=&quot;tight&quot;&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt;&lt;/p&gt;
&lt;ul class=&quot;tight&quot;&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Strictly &quot;DRY&quot; Database Schema:&lt;/strong&gt; The base fields are declared exactly once, preventing schema duplication.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Modularity:&lt;/strong&gt; You can reuse the &quot;Standard&quot; or &quot;Gated&quot; property blocks in other components across the system.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Configurable guardrails:&lt;/strong&gt; Content Areas are not a free-for-all. Built-in validation fields let you constrain them declaratively&amp;mdash;&lt;strong&gt;allowedTypes &lt;/strong&gt;/ &lt;strong&gt;restrictedTypes &lt;/strong&gt;restrict exactly which extension types can be dropped in, and &lt;strong&gt;maxLength: 1&lt;/strong&gt; caps the area to a single item&amp;mdash;so wrong-type and duplicate insertions are preventable without any custom code.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Cons:&lt;/strong&gt;&lt;/p&gt;
&lt;ul class=&quot;tight&quot;&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Editing Friction:&lt;/strong&gt; Authors must execute a two-step process to build a single video. They fill out the parent, click &quot;Create New Block&quot; inside the Content Area, and then fill out the child block&amp;mdash;slower than picking one flat type up front.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;The &quot;empty wrapper&quot; gap:&lt;/strong&gt; While &lt;strong&gt;allowedTypes &lt;/strong&gt;and &lt;strong&gt;maxLength &lt;/strong&gt;handle type and cardinality, enforcing a strict &lt;em&gt;minimum&lt;/em&gt; of one item is not cleanly supported for content areas&amp;mdash;so an author can still save an empty wrapper with no extension inside.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;GraphQL Query Noise:&lt;/strong&gt; Even when correctly constrained, every query to fetch a video must navigate a nested fragment structure, increasing the payload and query complexity compared to a flat type.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;Option 3: The Split-Type &quot;Symmetrical&quot; Pattern (Recommended)&lt;/h2&gt;
&lt;p&gt;This approach splits the concept into two distinct, flat content types: &lt;strong&gt;StandardVideo &lt;/strong&gt;and &lt;strong&gt;GatedVideo&lt;/strong&gt;. The author&#39;s choice of which component to insert in their page layout &lt;em&gt;is&lt;/em&gt; the disambiguation.&lt;/p&gt;
&lt;p&gt;To keep things clean, we use Optimizely&#39;s built-in features to resolve the duplication: &lt;strong&gt;Property Groups&lt;/strong&gt; for authors, and &lt;strong&gt;SDK Contracts&lt;/strong&gt; for developers.&lt;/p&gt;
&lt;pre class=&quot;code-block&quot;&gt;&lt;code&gt;Standard Video (Type 1)
├── [Video Settings Group]   &amp;larr; via SDK Contract
│     ├── VideoFile
│     ├── PosterImage
│     └── Title
└── [Player Options Group]
      ├── Autoplay, Loop, ShowControls

Gated Video (Type 2)
├── [Video Settings Group]   &amp;larr; via SDK Contract
│     ├── VideoFile
│     ├── PosterImage
│     └── Title
└── [Lead Capture Group]
      ├── FormId, GateTimestamp&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;The Trade-Offs&lt;/h3&gt;
&lt;ul class=&quot;tight&quot;&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt;&lt;/p&gt;
&lt;ul class=&quot;tight&quot;&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Excellent Author UX:&lt;/strong&gt; Editors see only the fields that are directly relevant to what they are creating. No noise, no confusion, and the choice happens up front in a single step.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Precise Validation:&lt;/strong&gt; You can make &lt;strong&gt;FormId &lt;/strong&gt;strictly required on Gated Videos and &lt;strong&gt;VideoFile &lt;/strong&gt;required on both, with no overlaps&amp;mdash;and no &quot;empty wrapper&quot; edge case to guard against.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Evolvable Schemas:&lt;/strong&gt; If you need to add an &quot;External Tracker ID&quot; to Gated Videos later, you add it to the Gated type. The Standard Video type remains completely untouched, preventing regressions.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Flat GraphQL queries:&lt;/strong&gt; Each type resolves directly, with no nested content-area fragment to traverse.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Cons:&lt;/strong&gt;&lt;/p&gt;
&lt;ul class=&quot;tight&quot;&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Minor Code Duplication:&lt;/strong&gt; In some architectures, this might mean registering two types instead of one (though Optimizely&#39;s SDK makes this incredibly clean, as shown below).&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;Implementing Option 3 Cleanly with the Content JS SDK&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Implementation Disclaimer:&lt;/strong&gt; &lt;em&gt;The following code snippets are illustrative implementations demonstrating the structure and APIs of the &lt;/em&gt;&lt;strong&gt;@optimizely/cms-sdk&lt;/strong&gt;&lt;em&gt;. Depending on your active SDK version, environment settings, and target packages, always verify the exact properties, imports, and field parameters in your sandbox prior to deploying content types to production.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;1. Define a Shared Contract in Code&lt;/h3&gt;
&lt;p&gt;The SDK&#39;s &lt;strong&gt;contract()&lt;/strong&gt; function lets you declare the shared fields once. It keeps your codebase DRY, but merges the properties directly into each schema under the hood so authors get a flat, non-nested editor form:&lt;/p&gt;
&lt;pre class=&quot;code-block&quot;&gt;&lt;code class=&quot;language-ts&quot;&gt;// src/contracts/VideoBaseContract.ts
import { contract } from &#39;@optimizely/cms-sdk&#39;;

export const VideoBaseContract = contract({
  key: &#39;VideoBase&#39;,
  properties: {
    videoFile: { type: &#39;contentReference&#39;, allowedTypes: [&#39;_video&#39;], required: true },
    posterImage: { type: &#39;contentReference&#39;, allowedTypes: [&#39;_image&#39;], required: true },
    title: { type: &#39;string&#39;, required: true },
  },
});&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2. Extend the Contract in Your Content Types&lt;/h3&gt;
&lt;p&gt;Both content types simply extend our base contract:&lt;/p&gt;
&lt;pre class=&quot;code-block&quot;&gt;&lt;code class=&quot;language-ts&quot;&gt;// src/components/StandardVideo.ts
export const StandardVideoContentType = contentType({
  key: &#39;StandardVideo&#39;,
  baseType: &#39;_component&#39;,
  extends: VideoBaseContract, // &amp;lt;-- Merges shared fields
  properties: {
    autoplay: { type: &#39;boolean&#39;, group: &#39;playerOptions&#39; },
    loop: { type: &#39;boolean&#39;, group: &#39;playerOptions&#39; },
    showControls: { type: &#39;boolean&#39;, group: &#39;playerOptions&#39; },
  },
});

// src/components/GatedVideo.ts
export const GatedVideoContentType = contentType({
  key: &#39;GatedVideo&#39;,
  baseType: &#39;_component&#39;,
  extends: VideoBaseContract, // &amp;lt;-- Merges shared fields
  properties: {
    formId: { type: &#39;string&#39;, group: &#39;leadCapture&#39; },
    gateTimestamp: { type: &#39;integer&#39;, group: &#39;leadCapture&#39; },
  },
});&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;3. Resolve to a Single React Component&lt;/h3&gt;
&lt;p&gt;Here is the developer&#39;s favorite trick: even though we have two schemas in the CMS, &lt;strong&gt;we can map them both to a single React component.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;In our registry setup, we point both type keys to our &lt;strong&gt;&amp;lt;VideoItem /&amp;gt;&lt;/strong&gt; component. The component inspects &lt;strong&gt;__typename&lt;/strong&gt; (always supplied by Optimizely Graph) to decide how to render:&lt;/p&gt;
&lt;pre class=&quot;code-block&quot;&gt;&lt;code class=&quot;language-ts&quot;&gt;// src/app/layout.tsx &amp;mdash; Map both keys to the same component
initReactComponentRegistry({
  resolver: {
    StandardVideo: VideoItem,
    GatedVideo:    VideoItem,
  },
});

// src/components/VideoItem.tsx &amp;mdash; One entry, two layouts
export default function VideoItem({ content }: Props) {
  if (content.__typename === &#39;GatedVideo&#39;) {
    return &amp;lt;GatedVideoView content={content} /&amp;gt;; // Typed as GatedVideoProps
  }
  return &amp;lt;StandardVideoView content={content} /&amp;gt;; // Typed as StandardVideoProps
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;Conclusion: Choosing What&#39;s Right for Your Team&lt;/h2&gt;
&lt;p&gt;There is rarely a single &quot;correct&quot; answer in software architecture&amp;mdash;only choices and their consequences.&lt;/p&gt;
&lt;ul class=&quot;tight&quot;&gt;
&lt;li&gt;
&lt;p&gt;If your team is very small, your content needs are highly fluid, and you prioritize a rapid setup above all else, &lt;strong&gt;Option 1 (The Consolidated Block)&lt;/strong&gt; might serve you well.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;If your design system relies heavily on composable, modular sub-assemblies and your editors are comfortable with multi-step nesting, &lt;strong&gt;Option 2 (The Nested Wrapper)&lt;/strong&gt; offers high structural reuse.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;If you prioritize editor productivity, robust validation, and clean GraphQL queries, &lt;strong&gt;Option 3 (The Split-Type Pattern)&lt;/strong&gt; combined with the SDK&#39;s single component routing gives you the best of both worlds: flat schemas for authors, and type-safe, DRY code for developers.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;By laying these options on the table, your team can align on a modeling philosophy that matches your technical maturity and authoring workflows.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;Resources:&amp;nbsp;&lt;/em&gt;&lt;a href=&quot;https://github.com/episerver/content-js-sdk/tree/main/docs&quot;&gt;&lt;em&gt;Content JS SDK documentation&lt;/em&gt;&lt;/a&gt;&lt;em&gt; &amp;middot; &lt;/em&gt;&lt;a href=&quot;https://docs.developers.optimizely.com/content-management-system/v1.0.0-CMS-SaaS/docs/content-modeling-saas&quot;&gt;&lt;em&gt;Optimizely content modeling principles&lt;/em&gt;&lt;/a&gt;&lt;em&gt; &amp;middot; &lt;/em&gt;&lt;a href=&quot;https://docs.developers.optimizely.com/content-management-system/v1.0.0-CMS-SaaS/docs/content-base-types-saas&quot;&gt;&lt;em&gt;Content base types&lt;/em&gt;&lt;/a&gt;&lt;/p&gt;</id><updated>2026-07-31T03:15:37.0000000Z</updated><summary type="html">Blog post</summary></entry> <entry><title>From SDK to Core: Modernizing Optimizely Configured Commerce for .NET 8 and .NET 10</title><link href="https://world.optimizely.com/blogs/vaibhav/dates/2026/7/from-sdk-to-core-modernizing-optimizely-configured-commerce-for-.net-8-and-.net-10" /><id>&lt;h1&gt;&lt;img src=&quot;/link/a035a4fbeac940bbbbf3f09b0fb3d14f.aspx&quot; alt=&quot;&quot; /&gt;&lt;br /&gt;&lt;br /&gt;&lt;/h1&gt;
&lt;p&gt;&lt;em&gt;The .NET Framework 4.8 clock is running out for Configured Commerce customizations. Handled well, this deadline is not a chore it is the cleanest opportunity in years to shed technical debt, unlock modern performance, and future-proof your storefront. Here is what the migration really involves, and how an automation-first accelerator turns a months-long rewrite into a managed, low-risk upgrade.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;The deadline you cannot keep deferring&lt;/h2&gt;
&lt;p&gt;For years, Optimizely Configured Commerce customizations have lived comfortably on the .NET Framework 4.8 SDK. It worked, it was stable, and there was rarely a pressing reason to touch it. That era is ending. Optimizely has set an end-of-support timeline for .NET 4.8 within Configured Commerce and is moving the platform onto modern .NET, and the runway is short.&lt;/p&gt;
&lt;p&gt;The wider .NET picture makes the urgency concrete. Microsoft froze .NET Framework as a feature platform years ago there have been no new capabilities since version 4.8.1 in August 2022, and it now ships only as a component of Windows. Meanwhile the modern line moves fast: &lt;strong&gt;.NET 8&lt;/strong&gt;, the current long-term-support release many teams are targeting, reaches &lt;strong&gt;end of support on November 10, 2026&lt;/strong&gt;, and &lt;strong&gt;.NET 10&lt;/strong&gt;, released in November 2025, carries long-term support through November 2028. Standing still is no longer neutral; it is a decision to fall further behind with every passing quarter.&lt;/p&gt;
&lt;p&gt;The good news is that this is a well-understood, finite piece of work provided you approach it as an engineering program rather than a scramble. The rest of this article explains what the migration actually entails, why it is trickier than a routine framework bump, and how Royal Cyber compresses the effort and the risk with a purpose-built migration accelerator.&lt;/p&gt;
&lt;h2&gt;What &amp;ldquo;SDK to Core&amp;rdquo; actually means&lt;/h2&gt;
&lt;p&gt;Configured Commerce ships as a managed platform with defined extension points. Your team&amp;rsquo;s investment does not live in the platform&amp;rsquo;s own code; it lives in the customization projects layered on top the extensions, custom modules, integrations, and accelerator libraries that make the storefront yours. Historically those projects were built against the .NET Framework 4.8 SDK. &amp;ldquo;SDK to Core&amp;rdquo; migration means recompiling and re-shaping that custom code so it runs on modern, cross-platform .NET (the .NET 8.0+ line, the successor to what many still call &amp;ldquo;.NET Core&amp;rdquo;).&lt;/p&gt;
&lt;p&gt;Crucially, the platform-owned projects are Optimizely&amp;rsquo;s responsibility and are upgraded through the normal version-upgrade process. Your migration scope is the &lt;strong&gt;custom code you own&lt;/strong&gt;. Optimizely&amp;rsquo;s guidance is to first move to a recent platform build (release 5.2.2508 or newer), set up a modern local development environment, and then migrate your extension projects to .NET 8.0+. The target framework depends on your platform build: releases in the 5.2.2512&amp;ndash;5.2.2604 range pair with .NET 8, while 5.2.2605 and later move to .NET 10.&lt;/p&gt;
&lt;p&gt;In practice, then, this is a focused, bounded job: take the projects you built, carry them across a genuine framework boundary, and make sure they still behave the same way against the modernized platform. Bounded does not mean trivial as the next section shows.&lt;/p&gt;
&lt;h2&gt;The real cost of standing still&lt;/h2&gt;
&lt;p&gt;It is tempting to treat an aging framework as a problem for &amp;ldquo;later.&amp;rdquo; But the cost of remaining on .NET Framework 4.8 is real and compounding, even while the application appears to run fine.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Security exposure.&lt;/strong&gt; A frozen framework receives only critical operating-system-level patches. Modern .NET receives active security hardening, and the ecosystem&amp;rsquo;s newest package versions increasingly target it exclusively.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Performance left on the table.&lt;/strong&gt; Each modern .NET release has delivered substantial throughput and memory improvements. Staying on 4.8 means paying for infrastructure to do work that newer runtimes do faster and cheaper.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Innovation lockout.&lt;/strong&gt; New Configured Commerce capabilities, language features, and libraries are built for modern .NET. On 4.8 you are increasingly unable to adopt them and unable to take advantage of platform enhancements that assume the new runtime.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A shrinking talent pool.&lt;/strong&gt; Engineers want to work on current stacks. Legacy frameworks make hiring harder and slow every future change.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Compounding technical debt.&lt;/strong&gt; The longer the gap between your code and the supported platform, the larger and riskier the eventual jump. Deferring does not remove the work; it inflates it.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;There is also a hard dependency at play: once Optimizely&amp;rsquo;s support window for .NET 4.8 closes, continued platform upgrades and hotfixes assume you are on the modern runtime. Migration stops being an optimization and becomes a prerequisite for staying current and supported.&lt;/p&gt;
&lt;h2&gt;Why this is not a routine framework upgrade&lt;/h2&gt;
&lt;p&gt;Teams sometimes assume a framework bump is a matter of changing a target version and rebuilding. For Configured Commerce customizations, several factors make it materially harder.&lt;/p&gt;
&lt;h3&gt;A platform boundary you must not cross&lt;/h3&gt;
&lt;p&gt;Certain projects are owned by the platform and must never be modified during migration. Editing them breaks upgradeability and support. A safe migration has to positively identify those projects and leave them alone, migrating only the code you own a distinction that is easy to get wrong by hand in a large solution.&lt;/p&gt;
&lt;h3&gt;Breaking API and behavior changes&lt;/h3&gt;
&lt;p&gt;The move from the old web stack to ASP.NET Core is not a rename. Controller base classes, action-result types, attribute routing, model binding, and dependency-injection patterns all change. Some shifts are mechanical; others are behavioral for example, how a null response is treated, or how optional collection parameters bind. A find-and-replace approach will compile and still be subtly wrong.&lt;/p&gt;
&lt;h3&gt;Entity Framework and data access&lt;/h3&gt;
&lt;p&gt;Legacy Entity Framework mapping classes must be reshaped into the EF Core pattern. Beyond the mechanical conversion, some query shapes that ran happily before will no longer translate to SQL, and lazy-loading assumptions can quietly change. These need a human eye, not a blind rewrite.&lt;/p&gt;
&lt;h3&gt;The NuGet and packaging maze&lt;/h3&gt;
&lt;p&gt;Modern platform packages pull in a large graph of dependencies at versions higher than the old direct references, producing downgrade errors. If your repository uses central package management, a naive migration also breaks version resolution. Getting a migrated project to simply restore and build cleanly is often where unaided efforts stall for days.&lt;/p&gt;
&lt;h3&gt;Removed features with no drop-in replacement&lt;/h3&gt;
&lt;p&gt;Some capabilities are gone in the modern platform. Search rebuild Version 1 is unsupported and must move to Version 2; certain legacy integration endpoints, media-editing features, and admin experiments are removed. These require a plan, not a patch and discovering them late is how projects slip.&lt;/p&gt;
&lt;h2&gt;Where the manual approach breaks down&lt;/h2&gt;
&lt;p&gt;Handed to a team without tooling, this migration tends to follow a predictable and painful arc. Engineers open project after project, chase compiler errors one at a time, and lose days untangling package conflicts before a single line of business logic is even reviewed. Because the mechanical and the judgment-heavy changes are tackled together, genuinely risky decisions get rushed while trivial edits soak up attention. Worst of all, the work is hard to audit: at the end, no one can say cleanly which changes were safe and automatic versus which ones altered behavior and deserve testing.&lt;/p&gt;
&lt;p&gt;The result is slow, inconsistent, and stressful and it scales badly across the dozens of customization projects a mature storefront accumulates. The mechanical majority of the work is exactly the kind of thing software should do; the human minority is exactly the kind of thing software should never guess at. That insight is the foundation of Royal Cyber&amp;rsquo;s approach.&lt;/p&gt;
&lt;h2&gt;A different approach: automate the mechanical, spotlight the judgment&lt;/h2&gt;
&lt;p&gt;Royal Cyber built a migration accelerator for exactly this problem. Its guiding principle is a strict separation into two tiers.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Automatic.&lt;/strong&gt; Changes that are mechanical and semantically safe the routing rewrites, base-class swaps, result-type changes, mapping conversions, and package reconciliations. These are applied for you, consistently, every time.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Review.&lt;/strong&gt; Changes that need human judgment behavioral edge cases, queries that may not translate, removed features. These are never auto-&amp;ldquo;fixed.&amp;rdquo; They are detected and reported with context, because a wrong automatic fix here is worse than a clear flag.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Two design decisions make the accelerator safe to run early and often. First, it is non-destructive by default: it produces upgraded copies of your projects in a separate folder, keeping the original names and namespaces, so your existing codebase is never modified until you choose to adopt the result. Second, it defaults to a preview (dry-run) mode nothing is written to disk until you explicitly approve it. You can see exactly what a migration would do before committing to any of it.&lt;/p&gt;
&lt;h2&gt;Inside the accelerator: how it works, end to end&lt;/h2&gt;
&lt;p&gt;The tool is a single migration engine offered through several front-ends, so it fits however a team prefers to work a command-line tool for power users and CI pipelines, a browser-based app for a guided click-through, an automated agent for hands-off runs, and an integration that lets AI assistants drive the migration conversationally. Whichever entry point you choose, the underlying process is the same.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. Scan and select.&lt;/strong&gt; The engine reads the solution, inventories every project with its current framework, and automatically locks the platform-owned projects so they cannot be migrated by mistake. You choose which of your customization projects to migrate.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. Analyze and transform.&lt;/strong&gt; It runs Microsoft&amp;rsquo;s upgrade analysis for a baseline, then applies the full catalog of automatic transforms project-file modernization, namespace and API rewrites, EF Core mapping conversion, dependency and package-management reconciliation to the copies.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. Build, fix, and repeat.&lt;/strong&gt; This is the standout capability. In agent mode the tool builds the migrated project, reads the compiler errors, applies a library of safe fixes, and rebuilds looping automatically until the project compiles. If it cannot finish on its own within a set number of attempts, it pauses and asks whether to continue rather than guessing. The tedious build-error grind that consumes manual migrations is largely automated away.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;4. Report everything.&lt;/strong&gt; Every run produces a written migration report: what was changed automatically, and what still needs a person, organized by category with file and line references. It is an audit trail and a to-do list in one, and it is what lets a lead scope the remaining human work precisely.&lt;/p&gt;
&lt;h2&gt;Automatic versus human: a concrete example&lt;/h2&gt;
&lt;p&gt;The difference is easiest to see in code. Below is a typical Web API controller before and after migration. Every change on the right the routing attribute, the base class, the result types, the response attribute, the explicit body binding is applied automatically, because each is mechanical and safe.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; border: 1px solid #d6dbe0;&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;background: #003963; color: #ffffff; text-align: left; padding: 8px 12px; border: 1px solid #d6dbe0; width: 50%;&quot;&gt;Before .NET Framework 4.8 (SDK)&lt;/th&gt;
&lt;th style=&quot;background: #003963; color: #ffffff; text-align: left; padding: 8px 12px; border: 1px solid #d6dbe0; width: 50%;&quot;&gt;After .NET 8 / .NET 10 (Core)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;vertical-align: top; padding: 0; border: 1px solid #d6dbe0;&quot;&gt;
&lt;pre style=&quot;margin: 0; background: #f3f5f8; border-left: 4px solid #003963; padding: 12px; font-family: Consolas,Monaco,monospace; font-size: 13px; line-height: 1.5; white-space: pre; overflow: auto;&quot;&gt;[RoutePrefix(&quot;api/v1/sample&quot;)]
public class SampleController
    : ApiController
{
  [ResponseType(typeof(OrderDto))]
  public IHttpActionResult GetOrder(int id)
  { return this.Ok(dto); }

  [HttpPost]
  public IHttpActionResult CreateOrder(
      OrderDto order) { ... }&lt;/pre&gt;
&lt;/td&gt;
&lt;td style=&quot;vertical-align: top; padding: 0; border: 1px solid #d6dbe0;&quot;&gt;
&lt;pre style=&quot;margin: 0; background: #f3f5f8; border-left: 4px solid #003963; padding: 12px; font-family: Consolas,Monaco,monospace; font-size: 13px; line-height: 1.5; white-space: pre; overflow: auto;&quot;&gt;[Route(&quot;api/v1/sample&quot;)]
public class SampleController
    : ControllerBase
{
  [ProducesResponseType(typeof(OrderDto),200)]
  public IActionResult GetOrder(int id)
  { return this.Ok(dto); }

  [HttpPost]
  public IActionResult CreateOrder(
      [FromBody] OrderDto order) { ... }&lt;/pre&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;What the tool will not do silently is change behavior. If that same controller returned a value that might be null, or accepted an optional collection parameter, those are flagged for review because in ASP.NET Core they can behave differently, and only a developer who knows the intent should decide. The table below summarizes how the work divides across the migration.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; border: 1px solid #d6dbe0;&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;background: #003963; color: #ffffff; text-align: left; padding: 8px 12px; border: 1px solid #d6dbe0;&quot;&gt;Migration area&lt;/th&gt;
&lt;th style=&quot;background: #003963; color: #ffffff; text-align: left; padding: 8px 12px; border: 1px solid #d6dbe0;&quot;&gt;Handled automatically&lt;/th&gt;
&lt;th style=&quot;background: #003963; color: #ffffff; text-align: left; padding: 8px 12px; border: 1px solid #d6dbe0;&quot;&gt;Flagged for a human&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;padding: 8px 12px; border: 1px solid #d6dbe0; vertical-align: top;&quot;&gt;&lt;strong&gt;Project files&lt;/strong&gt;&lt;/td&gt;
&lt;td style=&quot;padding: 8px 12px; border: 1px solid #d6dbe0; vertical-align: top;&quot;&gt;Target framework set to a single modern .NET; legacy project format modernized; package references reconciled.&lt;/td&gt;
&lt;td style=&quot;padding: 8px 12px; border: 1px solid #d6dbe0; vertical-align: top;&quot;&gt;Rare custom build targets or Windows-only assembly references.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;padding: 8px 12px; border: 1px solid #d6dbe0; vertical-align: top;&quot;&gt;&lt;strong&gt;Web APIs &amp;amp; routing&lt;/strong&gt;&lt;/td&gt;
&lt;td style=&quot;padding: 8px 12px; border: 1px solid #d6dbe0; vertical-align: top;&quot;&gt;Old attribute routing, action-result types and controller base classes rewritten to ASP.NET Core equivalents.&lt;/td&gt;
&lt;td style=&quot;padding: 8px 12px; border: 1px solid #d6dbe0; vertical-align: top;&quot;&gt;Behavioral edge cases such as null-response handling and model binding differences.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;padding: 8px 12px; border: 1px solid #d6dbe0; vertical-align: top;&quot;&gt;&lt;strong&gt;Data access&lt;/strong&gt;&lt;/td&gt;
&lt;td style=&quot;padding: 8px 12px; border: 1px solid #d6dbe0; vertical-align: top;&quot;&gt;Entity Framework mapping classes converted to the EF Core pattern.&lt;/td&gt;
&lt;td style=&quot;padding: 8px 12px; border: 1px solid #d6dbe0; vertical-align: top;&quot;&gt;Complex queries that may not translate to SQL, and lazy-loading assumptions.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;padding: 8px 12px; border: 1px solid #d6dbe0; vertical-align: top;&quot;&gt;&lt;strong&gt;Dependencies (NuGet)&lt;/strong&gt;&lt;/td&gt;
&lt;td style=&quot;padding: 8px 12px; border: 1px solid #d6dbe0; vertical-align: top;&quot;&gt;Version conflicts and central-package-management wiring resolved so the project restores cleanly.&lt;/td&gt;
&lt;td style=&quot;padding: 8px 12px; border: 1px solid #d6dbe0; vertical-align: top;&quot;&gt;Cases where an intentionally older package version must be kept.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;padding: 8px 12px; border: 1px solid #d6dbe0; vertical-align: top;&quot;&gt;&lt;strong&gt;Removed platform features&lt;/strong&gt;&lt;/td&gt;
&lt;td style=&quot;padding: 8px 12px; border: 1px solid #d6dbe0; vertical-align: top;&quot;&gt;&amp;nbsp;&lt;/td&gt;
&lt;td style=&quot;padding: 8px 12px; border: 1px solid #d6dbe0; vertical-align: top;&quot;&gt;Search rebuild to Version 2, dropped legacy integration endpoints, and other features with no drop-in replacement.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;This division is the whole point. Engineers stop spending their expensive judgment on mechanical edits, and start spending it only where judgment is actually required with a report that tells them exactly where that is.&lt;/p&gt;
&lt;h2&gt;What the numbers look like in practice&lt;/h2&gt;
&lt;p&gt;The accelerator is not a theoretical exercise. Run against a real, production Configured Commerce customization solution, it produces results like these:&lt;/p&gt;
&lt;div style=&quot;border: 1px solid #E87722; border-left: 6px solid #E87722; background: #fbf4ec; padding: 16px 20px;&quot;&gt;
&lt;p style=&quot;margin: 0 0 8px 0; color: #003963; font-weight: bold;&quot;&gt;From one real accelerator codebase&lt;/p&gt;
&lt;p style=&quot;margin: 0;&quot;&gt;Run against a production Configured Commerce customization solution, the tool scanned &lt;strong&gt;207 files&lt;/strong&gt; across the custom modules, applied &lt;strong&gt;137 automatic, semantically-safe changes&lt;/strong&gt;, and surfaced &lt;strong&gt;184 review items&lt;/strong&gt; for an engineer to judge everything from a chatbot integration and a Power BI connector to a Teams module and a B2B accelerator library. The platform&amp;rsquo;s own projects were never touched.&lt;/p&gt;
&lt;/div&gt;
&lt;p&gt;The ratio is telling. The large majority of changes were handled automatically and consistently, while the review items the genuine engineering decisions were isolated and documented rather than buried. That is what converts an open-ended rewrite into a scoped, estimable, testable piece of work. It also means the same migration standard is applied identically across every project, instead of varying with whichever engineer happened to touch it.&lt;/p&gt;
&lt;h2&gt;Choosing your target: .NET 8 or .NET 10&lt;/h2&gt;
&lt;p&gt;A common early question is which modern framework to target. The honest answer is that your platform build largely decides for you, but the support calendar should shape the plan. Targeting &lt;strong&gt;.NET 8&lt;/strong&gt; is the right move for platform releases in the 5.2.2512&amp;ndash;5.2.2604 range but remember its support ends in November 2026, so it is best treated as a stepping stone rather than a destination. Newer platform builds (5.2.2605+) move to &lt;strong&gt;.NET 10&lt;/strong&gt;, which carries long-term support through November 2028 and is the better place to land if your platform version allows it.&lt;/p&gt;
&lt;p&gt;The strategic implication is straightforward: align your Configured Commerce version-upgrade roadmap with your framework target so you make the jump once, to a runtime with a long support runway, rather than migrating to .NET 8 now and repeating the exercise a year later. Royal Cyber&amp;rsquo;s accelerator supports both targets and enforces a single modern framework in the output, so the migrated projects are clean and unambiguous either way.&lt;/p&gt;
&lt;h2&gt;A pragmatic migration playbook&lt;/h2&gt;
&lt;p&gt;Based on real engagements, a low-drama migration tends to follow this shape:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Inventory and align.&lt;/strong&gt; Catalog your customization projects and confirm your target platform build and framework. Establish which projects are platform-owned and out of scope.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Preview before you commit.&lt;/strong&gt; Run the accelerator in dry-run mode to produce a full report of automatic changes and review items with no risk to the existing codebase and use it to scope the human effort accurately.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Automate the mechanical majority.&lt;/strong&gt; Let the tool generate the migrated copies, run the build-fix loop to green, and reconcile packages, so engineers start from a compiling project rather than a wall of errors.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Work the review list deliberately.&lt;/strong&gt; Address the flagged items behavior changes, untranslatable queries, removed features such as the search rebuild move to Version 2 with proper testing behind each decision.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Regression-test as a first-class step.&lt;/strong&gt; Mechanical migration is not a substitute for testing. Validate the storefront&amp;rsquo;s critical paths against the modernized platform before you adopt the result.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Handled this way, the migration becomes a series of visible, reviewable steps with a clear finish line not an open-ended risk hanging over the roadmap.&lt;/p&gt;
&lt;h2&gt;The bottom line&lt;/h2&gt;
&lt;p&gt;The end of .NET Framework 4.8 support for Configured Commerce is not a problem to survive; it is an opportunity to modernize on your own terms. The work is real, and parts of it genuinely require experienced judgment. But the mechanical majority the part that historically eats weeks and introduces inconsistency can and should be automated, leaving your engineers to focus only on the decisions that matter and giving your stakeholders a clear, auditable trail of what changed and why.&lt;/p&gt;
&lt;p&gt;That is precisely what Royal Cyber&amp;rsquo;s migration accelerator delivers: automatic where it is safe, transparent where it is not, non-destructive by default, and fast where manual effort stalls. If your Configured Commerce storefront is still on the .NET Framework SDK, the smartest move is to start with a no-risk assessment a dry-run migration report that shows exactly what your modernization will involve, before you commit a single change.&lt;/p&gt;
&lt;h3&gt;Ready to see your migration report?&lt;/h3&gt;
&lt;p&gt;Royal Cyber&amp;rsquo;s Optimizely practice can run an assessment against your solution and walk you through the results. Explore our Optimizely services at&lt;/p&gt;
&lt;p&gt;&lt;a style=&quot;color: blue;&quot; href=&quot;https://www.royalcyber.com/technologies/optimizely/&quot;&gt;https://www.royalcyber.com/technologies/optimizely/&lt;/a&gt;, or reach out to start the conversation.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;</id><updated>2026-07-29T18:16:57.0000000Z</updated><summary type="html">Blog post</summary></entry> <entry><title>Parallel Development in Optimizely CMS SaaS: Shifting to a Schema Migration Mindset</title><link href="https://world.optimizely.com/blogs/vipin-banka--learnings--insights/dates/2026/7/parallel-development-in-optimizely-cms-saas-shifting-to-a-schema-migration-mindset/" /><id>&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Part 3 of the Parallel Development series.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;The branch-scoped push script from &lt;a href=&quot;/link/60050e9906014446bb28143b0ca1b0c2.aspx&quot;&gt;Part 1&lt;/a&gt; works. Run it on a feature branch where you&#39;ve touched a handful of independent content types and it does exactly what it promises &amp;mdash; pushes only your changes, leaves your teammate&#39;s work untouched, no &lt;strong&gt;--force&lt;/strong&gt; required.&lt;/p&gt;
&lt;p&gt;For most of your day-to-day work, that&#39;s enough. The majority of content type changes are leaf changes: you added a field to a page, you tweaked a block, you updated a template. The script handles all of those cleanly.&lt;/p&gt;
&lt;p&gt;But as soon as you start moving past basic, isolated components and build toward a real content model&amp;mdash;with custom contracts, shared property groups, and complex validation rules&amp;mdash;the landscape changes. The script still works, but running selective push against a full, production-grade schema teaches you a few things worth mapping out before you hit them cold. None of this is a flaw in the selective push approach. These dependency cases exist in any code-first workflow with the CLI, whether you push selectively or all at once. What changes is whether you walk into them knowing what&#39;s happening, or whether you spend an afternoon confused at an API rejection.&lt;/p&gt;
&lt;p&gt;This post maps the five hidden dependency cases, shows what each looks like in code, and proposes a repo structure plus a reviewable push workflow that makes them more manageable. These are starting points and open questions &amp;mdash; not a finished solution. We&#39;d genuinely like to hear how other teams are thinking about this.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;First: &quot;Why not just always full push?&quot;&lt;/h2&gt;
&lt;p&gt;It&#39;s the fair question, and worth answering before anything else &amp;mdash; because if full push were always fine, none of this would matter.&lt;/p&gt;
&lt;p&gt;Full push is authoritative and safe &lt;em&gt;against a clean, canonical branch&lt;/em&gt;. That&#39;s exactly why it&#39;s the right tool for your CI/CD pipeline pushing from &lt;strong&gt;main&lt;/strong&gt;. But on a &lt;strong&gt;shared development instance where several people have unmerged work&lt;/strong&gt;, full push is the opposite of safe: it syncs your &lt;em&gt;entire&lt;/em&gt; local model, including stale or half-built types, over whatever your teammates have pushed. That&#39;s the collision Part 1 set out to solve.&lt;/p&gt;
&lt;p&gt;And when the CLI suggests &lt;strong&gt;--force&lt;/strong&gt;? That&#39;s not a fix. It&#39;s the CLI telling you the remote already has changes you&#39;re about to overwrite. The correct response is to &lt;strong&gt;sync with your team&lt;/strong&gt;, not to force through. Force is how one developer silently clobbers another&#39;s content types.&lt;/p&gt;
&lt;p&gt;So the real trade-off isn&#39;t &quot;selective push (hard) vs full push (easy).&quot; It&#39;s:&lt;/p&gt;
&lt;ul class=&quot;tight&quot;&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Independent leaf changes (the 80%)&lt;/strong&gt; &amp;mdash; selective push is fully automatic and strictly better. No contest.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;The five dependency cases&lt;/strong&gt; &amp;mdash; selective push &lt;em&gt;still&lt;/em&gt; beats full push on a shared instance, because a scoped-but-complete push includes what&#39;s needed and touches nothing else. The catch is you have to know what &quot;complete&quot; means. That&#39;s what this article tries to make knowable.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Key renames and deletions&lt;/strong&gt; &amp;mdash; these genuinely are &quot;stop and coordinate&quot; moments. The honest value here isn&#39;t a clever script; it&#39;s &lt;em&gt;recognising&lt;/em&gt; you&#39;re in one, so you sync with the team and run a deliberate full push instead of forcing blindly.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That&#39;s the frame for everything below. This isn&#39;t about avoiding full push forever &amp;mdash; it&#39;s about knowing which situation you&#39;re in.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;The Five Hidden Dependencies&lt;/h2&gt;
&lt;p&gt;In Optimizely CMS SaaS, we&#39;ve identified five distinct cases where a change to one file creates a dependency that requires other files to be pushed in the same payload. Before we look at how to handle them, here is the quick map of the territory:&lt;/p&gt;
&lt;ol class=&quot;tight&quot;&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Contract changes&lt;/strong&gt; &amp;mdash; Modifying an interface pulls in all content types implementing it.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Property group changes&lt;/strong&gt; &amp;mdash; Renaming or removing a group key affects every content type assigned to it.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Component embeds&lt;/strong&gt; &amp;mdash; Schema shifts on a component affect any content type using them as typed properties.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Validation constraints&lt;/strong&gt; &amp;mdash; Changes to referenced type keys break &lt;strong&gt;allowedTypes&lt;/strong&gt; or &lt;strong&gt;restrictedTypes&lt;/strong&gt; in other files.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Key renames&lt;/strong&gt; &amp;mdash; A key value change is a destructive delete-and-create that orphans content and breaks external references.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;For each case, the &quot;how to handle it&quot; notes reflect our own thinking and what seems reasonable &amp;mdash; not tested playbooks. Your project&#39;s structure and conventions will change what actually works.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Case 1 &amp;mdash; Contract change &amp;rarr; all implementing types&lt;/h2&gt;
&lt;p&gt;Contracts define a shared set of properties that multiple content types implement. Add a field to a contract, and every implementing type has a stale resolved schema on the server &amp;mdash; because that field is now part of their schema too, whether they declared it explicitly or not.&lt;/p&gt;
&lt;p&gt;This is transitive. If Contract A is implemented by Contract B, and Contract B is implemented by five page types, a change to Contract A means all six need to be in the same push.&lt;/p&gt;
&lt;h3&gt;Why isolated contract pushes fail&lt;/h3&gt;
&lt;p&gt;The temptation is to slice pushes into layers &amp;mdash; a &quot;foundation&quot; config that covers contracts, a &quot;types&quot; config that covers pages and blocks. Different concerns, different cadence. The logic is sound.&lt;/p&gt;
&lt;p&gt;Then you try it on a live instance where types already implement the contract:&lt;/p&gt;
&lt;pre class=&quot;code-block&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;# Fails if any content type already implements the contract on the target instance
npx @optimizely/cms-cli@latest push --config optimizely.config.foundation.mjs&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The CLI errors out. The API rejects the payload.&lt;/p&gt;
&lt;p&gt;Based on observed behaviour, when a contract changes on the server, the CMS appears to immediately recompile the fully resolved schema of every implementing type. If those types aren&#39;t in the same push payload, the API rejects the whole operation rather than leave the instance in a half-compiled state. This is the right call &amp;mdash; it&#39;s protecting you. But it means contracts and their implementers can&#39;t travel separately once the contract is applied.&lt;/p&gt;
&lt;p&gt;&#128214; &lt;a href=&quot;https://docs.developers.optimizely.com/content-management-system/v1.0.0-CMS-SaaS/docs/create-contracts&quot;&gt;Create contracts&lt;/a&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note on code examples:&lt;/strong&gt; The JSON and TypeScript shapes shown throughout this article are illustrative of the concepts. Always check the current CLI &lt;strong&gt;--help&lt;/strong&gt; output and the &lt;a href=&quot;https://docs.developers.optimizely.com/content-management-system/v1.0.0-CMS-SaaS/docs/manage-content-types-using-the-rest-api&quot;&gt;REST API reference&lt;/a&gt; for exact syntax on your version.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre class=&quot;code-block&quot;&gt;&lt;code class=&quot;language-json&quot;&gt;// Contract &amp;mdash; POST https://api.cms.optimizely.com/v1/contenttypes
{
  &quot;key&quot;: &quot;categorizable&quot;,
  &quot;displayName&quot;: &quot;Categorizable&quot;,
  &quot;properties&quot;: {
    &quot;category&quot;: { &quot;type&quot;: &quot;string&quot;, &quot;displayName&quot;: &quot;Category&quot; },
    &quot;tags&quot;:     { &quot;type&quot;: &quot;array&quot;,  &quot;displayName&quot;: &quot;Tags&quot;, &quot;items&quot;: { &quot;type&quot;: &quot;string&quot; } }
  }
}

// Content type implementing the contract
// Adding a field to &#39;categorizable&#39; above means this type must be in the same push
{
  &quot;key&quot;: &quot;articlePage&quot;,
  &quot;baseType&quot;: &quot;_page&quot;,
  &quot;contracts&quot;: [&quot;categorizable&quot;],
  &quot;properties&quot;: {
    &quot;title&quot;: { &quot;type&quot;: &quot;string&quot;,   &quot;displayName&quot;: &quot;Title&quot; },
    &quot;body&quot;:  { &quot;type&quot;: &quot;richText&quot;, &quot;displayName&quot;: &quot;Body&quot; }
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;One possible approach:&lt;/strong&gt; Scan all content-type files for any import or string reference to the changed contract&#39;s file name, and include matches in the push set. Whether that works reliably depends on how consistently your codebase names and imports contracts &amp;mdash; a loosely structured project may need a more explicit dependency map. We haven&#39;t found a clean universal answer here.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h2&gt;Case 2 &amp;mdash; Property group change &amp;rarr; all types referencing that group&lt;/h2&gt;
&lt;p&gt;Property groups are defined in &lt;strong&gt;optimizely.config.mjs&lt;/strong&gt; via &lt;strong&gt;buildConfig&lt;/strong&gt; and referenced by &lt;strong&gt;key&lt;/strong&gt; inside property definitions. Change or remove that key, and any type that assigns properties to it fails validation.&lt;/p&gt;
&lt;p&gt;&#128214; &lt;a href=&quot;https://docs.developers.optimizely.com/content-management-system/v1.0.0-CMS-SaaS/docs/property-groups-saas&quot;&gt;Property groups&lt;/a&gt;&lt;/p&gt;
&lt;pre class=&quot;code-block&quot;&gt;&lt;code class=&quot;language-javascript&quot;&gt;// optimizely.config.mjs
import { buildConfig } from &#39;@optimizely/cms-sdk&#39;;

export default buildConfig({
  components: [&#39;./src/content-model/**/*.tsx&#39;],
  propertyGroups: [
    { key: &#39;seo&#39;, displayName: &#39;SEO Settings&#39;, sortOrder: 10 }
    // Rename &#39;seo&#39; here &amp;rarr; every type referencing this key must be re-pushed
  ]
});&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;code-block&quot;&gt;&lt;code class=&quot;language-json&quot;&gt;// PATCH https://api.cms.optimizely.com/v1/contenttypes/articlePage
{
  &quot;key&quot;: &quot;articlePage&quot;,
  &quot;baseType&quot;: &quot;_page&quot;,
  &quot;properties&quot;: {
    &quot;metaTitle&quot;:       { &quot;type&quot;: &quot;string&quot;, &quot;displayName&quot;: &quot;Meta Title&quot;,       &quot;group&quot;: &quot;seo&quot; },
    &quot;metaDescription&quot;: { &quot;type&quot;: &quot;string&quot;, &quot;displayName&quot;: &quot;Meta Description&quot;, &quot;group&quot;: &quot;seo&quot; }
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Rename the group key without re-pushing &lt;strong&gt;articlePage&lt;/strong&gt; and its &lt;strong&gt;metaTitle&lt;/strong&gt; / &lt;strong&gt;metaDescription&lt;/strong&gt; properties now point to a group that doesn&#39;t exist on the instance.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;One possible approach:&lt;/strong&gt; Diff &lt;strong&gt;optimizely.config.mjs&lt;/strong&gt;, extract changed group keys, and search content-type files for those strings. Since the config change is usually deliberate and visible in review, this might be something you handle at review time rather than automating &amp;mdash; though what&#39;s right depends entirely on your team&#39;s workflow and how often these keys change.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h2&gt;Case 3 &amp;mdash; Component used as a typed property &amp;rarr; the embedding type&lt;/h2&gt;
&lt;p&gt;A &lt;strong&gt;_component&lt;/strong&gt; content type can be declared as a fixed typed property on another content type &amp;mdash; not just dropped into a content area at runtime, but &lt;em&gt;declared&lt;/em&gt; in the schema as a property of that specific type. Optimizely Graph registers both &lt;strong&gt;TeaserBlock&lt;/strong&gt; (standalone) and &lt;strong&gt;TeaserBlockProperty&lt;/strong&gt; (when embedded as a schema property) to support this.&lt;/p&gt;
&lt;p&gt;When the component&#39;s schema changes, the type that embeds it is resolving a shape that has shifted.&lt;/p&gt;
&lt;p&gt;&#128214; &lt;a href=&quot;https://docs.developers.optimizely.com/content-management-system/v1.0.0-CMS-SaaS/docs/content-modeling-saas-graph&quot;&gt;Content modeling &amp;mdash; content properties&lt;/a&gt; &amp;middot; &lt;a href=&quot;https://docs.developers.optimizely.com/content-management-system/v1.0.0-CMS-SaaS/docs/content-types-saas&quot;&gt;Define content types&lt;/a&gt;&lt;/p&gt;
&lt;pre class=&quot;code-block&quot;&gt;&lt;code class=&quot;language-json&quot;&gt;// The component &amp;mdash; POST https://api.cms.optimizely.com/v1/contenttypes
{
  &quot;key&quot;: &quot;teaserBlock&quot;,
  &quot;baseType&quot;: &quot;_component&quot;,
  &quot;properties&quot;: {
    &quot;heading&quot;: { &quot;type&quot;: &quot;string&quot;,           &quot;displayName&quot;: &quot;Heading&quot; },
    &quot;image&quot;:   { &quot;type&quot;: &quot;contentReference&quot;, &quot;displayName&quot;: &quot;Image&quot; }
    // Add a new required field here &amp;rarr; homePage below must be in the same push
  }
}

// A page declaring the component as a typed property
{
  &quot;key&quot;: &quot;homePage&quot;,
  &quot;baseType&quot;: &quot;_page&quot;,
  &quot;properties&quot;: {
    &quot;hero&quot;: { &quot;type&quot;: &quot;content&quot;, &quot;displayName&quot;: &quot;Hero Teaser&quot;, &quot;contentType&quot;: &quot;teaserBlock&quot; }
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;One possible approach:&lt;/strong&gt; Scan content-type files for references to the changed component&#39;s name or key. The tricky part is distinguishing a typed schema embed from a regular rendering import &amp;mdash; a flat folder makes this ambiguous, which is part of why the folder structure below is worth considering.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h2&gt;Case 4 &amp;mdash; allowedTypes / restrictedTypes references &amp;rarr; the referencing type&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;allowedTypes&lt;/strong&gt; and &lt;strong&gt;restrictedTypes&lt;/strong&gt; are validation constraints on &lt;strong&gt;content&lt;/strong&gt; and &lt;strong&gt;contentReference&lt;/strong&gt; properties. They name other types &lt;strong&gt;by their key&lt;/strong&gt;. That reference is invisible to a git diff.&lt;/p&gt;
&lt;p&gt;&#128214; &lt;a href=&quot;https://docs.developers.optimizely.com/content-management-system/v1.0.0-CMS-SaaS/docs/content-property&quot;&gt;Content property &amp;mdash; allowedTypes&lt;/a&gt; &amp;middot; &lt;a href=&quot;https://docs.developers.optimizely.com/content-management-system/v1.0.0-CMS-SaaS/docs/content-types-saas&quot;&gt;Define content types&lt;/a&gt;&lt;/p&gt;
&lt;pre class=&quot;code-block&quot;&gt;&lt;code class=&quot;language-json&quot;&gt;// PATCH https://api.cms.optimizely.com/v1/contenttypes/landingPage
{
  &quot;key&quot;: &quot;landingPage&quot;,
  &quot;baseType&quot;: &quot;_page&quot;,
  &quot;properties&quot;: {
    &quot;relatedContent&quot;: {
      &quot;type&quot;: &quot;contentReference&quot;,
      &quot;displayName&quot;: &quot;Related Content&quot;,
      &quot;allowedTypes&quot;: [&quot;articlePage&quot;, &quot;blogPage&quot;]
    }
  }
}

// If &#39;blogPage&#39; is renamed to &#39;blogPostPage&#39; &amp;mdash;
// git diff shows BlogPage.tsx changed
// git diff does NOT show landingPage.tsx changed
// but landingPage now references a key that doesn&#39;t exist&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Adding a &lt;em&gt;new&lt;/em&gt; type to &lt;strong&gt;allowedTypes&lt;/strong&gt; is completely safe &amp;mdash; it&#39;s in your diff and selective push handles it automatically.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;One possible approach:&lt;/strong&gt; A pre-push step that reads all &lt;strong&gt;allowedTypes&lt;/strong&gt; / &lt;strong&gt;restrictedTypes&lt;/strong&gt; arrays and verifies every key they name still exists in the codebase could act as an early warning. Whether that&#39;s worth building depends on how often your model changes and how much a broken shared environment costs your team.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h2&gt;Case 5 &amp;mdash; Key rename: unavoidable sometimes, never trivial&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Design your keys well upfront and you likely won&#39;t face this. But if you do, know exactly what happens.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The &lt;strong&gt;key&lt;/strong&gt; of a content type, contract, or property is its permanent identity on the CMS instance. There is no rename operation &amp;mdash; what actually happens is a &lt;strong&gt;delete of the old + create of the new&lt;/strong&gt;. The CMS treats them as two completely different entities.&lt;/p&gt;
&lt;p&gt;Consequences:&lt;/p&gt;
&lt;ul class=&quot;tight&quot;&gt;
&lt;li&gt;
&lt;p&gt;All content items created under the old key are orphaned&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Every type that names the old key &amp;mdash; in &lt;strong&gt;allowedTypes&lt;/strong&gt;, &lt;strong&gt;restrictedTypes&lt;/strong&gt;, typed &lt;strong&gt;contentType&lt;/strong&gt; properties, &lt;strong&gt;contracts&lt;/strong&gt; arrays &amp;mdash; is now broken&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Git diff shows only the file where the key string changed, nothing about files that reference it elsewhere&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&#128214; &lt;a href=&quot;https://docs.developers.optimizely.com/content-management-system/v1.0.0-CMS-SaaS/docs/manage-content-types-using-the-rest-api&quot;&gt;Manage content types &amp;mdash; key field&lt;/a&gt;&lt;/p&gt;
&lt;pre class=&quot;code-block&quot;&gt;&lt;code class=&quot;language-json&quot;&gt;// BEFORE: &#39;blogPage&#39; is the live key
{ &quot;key&quot;: &quot;blogPage&quot;, &quot;baseType&quot;: &quot;_page&quot; }
{ &quot;allowedTypes&quot;: [&quot;articlePage&quot;, &quot;blogPage&quot;] }   // another type, referencing it

// AFTER: key renamed to &#39;blogPostPage&#39;
{ &quot;key&quot;: &quot;blogPostPage&quot;, &quot;baseType&quot;: &quot;_page&quot; }     // BlogPage.tsx &amp;mdash; in your diff
{ &quot;allowedTypes&quot;: [&quot;articlePage&quot;, &quot;blogPage&quot;] }   // LandingPage.tsx &amp;mdash; NOT in your diff, now broken&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;The realistic minimum when a rename is unavoidable:&lt;/strong&gt;&lt;/p&gt;
&lt;ol class=&quot;tight&quot;&gt;
&lt;li&gt;
&lt;p&gt;Search the full codebase for the old key string (&lt;strong&gt;grep -r &quot;blogPage&quot; src/&lt;/strong&gt; is the starting point)&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Update every file that references it in the same branch&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Run a &lt;strong&gt;coordinated full push&lt;/strong&gt; &amp;mdash; not a selective push&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Plan content migration separately &amp;mdash; existing content under the old key is not auto-migrated&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;displayName&lt;/strong&gt; is free to rename at any time. &lt;strong&gt;key&lt;/strong&gt; is permanent the moment it lands on an instance.&lt;/p&gt;
&lt;h3&gt;One direction worth exploring: detect the rename at diff time&lt;/h3&gt;
&lt;p&gt;Git has the information you need &amp;mdash; you can pull the &lt;em&gt;old&lt;/em&gt; version of any changed file and compare key values:&lt;/p&gt;
&lt;pre class=&quot;code-block&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;# Get the old content of a file as it was on the base branch
git show origin/main:src/content-model/pages/BlogPage.tsx&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For each modified content-type file: extract the old &lt;strong&gt;key&lt;/strong&gt; and the new &lt;strong&gt;key&lt;/strong&gt;. If they differ, search the codebase for the old key and surface every file that still references it. That turns a silent, easy-to-miss breakage into a loud early warning. You still need the coordinated full push &amp;mdash; but at least you know why before you push, rather than after.&lt;/p&gt;
&lt;p&gt;We haven&#39;t battle-tested this in a complex repo. It&#39;s a direction that seems worth exploring, not a recommendation.&lt;/p&gt;
&lt;h4&gt;The Safe Alternative: The Editorial Deprecation Pattern&lt;/h4&gt;
&lt;p&gt;Instead of performing a destructive key rename, the industry-standard best practice is to &lt;strong&gt;deprecate&lt;/strong&gt; the old field and introduce the new one alongside it. While CMS SaaS doesn&#39;t have a native &quot;deprecated&quot; JSON flag in the schema, you can enforce this through an &lt;strong&gt;editorial convention&lt;/strong&gt; in your code:&lt;/p&gt;
&lt;ol class=&quot;tight&quot;&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Remove constraints:&lt;/strong&gt; Set &lt;strong&gt;&quot;required&quot;: false&lt;/strong&gt; on the old field so editors are never blocked.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Warn editors in the UI:&lt;/strong&gt; Update its &lt;strong&gt;displayName&lt;/strong&gt; to include &lt;strong&gt;(Obsolete)&lt;/strong&gt; and use the &lt;strong&gt;description&lt;/strong&gt; to point to the new property.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Warn developers in the IDE:&lt;/strong&gt; Add a JSDoc &lt;strong&gt;@deprecated&lt;/strong&gt; comment to the TypeScript interface.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;code-block&quot;&gt;&lt;code class=&quot;language-javascript&quot;&gt;// src/content-model/components/TeaserBlock.tsx

export interface TeaserBlockType extends ContentType {
  heading: string;
  /** 
   * @deprecated Use &#39;heroImage&#39; instead. This field is scheduled for removal in Q4.
   */
  imageLink?: string; // Suffix with ? to make it optional in TS
}

export const TeaserBlockSchema = {
  key: &#39;teaserBlock&#39;,
  baseType: &#39;_component&#39;,
  displayName: &#39;Teaser Block&#39;,
  properties: {
    heading: { type: &#39;string&#39;, displayName: &#39;Heading&#39;, required: true },
    imageLink: {
      type: &#39;string&#39;,
      displayName: &#39;Image Link (Obsolete)&#39;,
      description: &#39;Deprecated. Use the Hero Image property instead.&#39;,
      required: false, // Must be optional to prevent editor lock-out
    },
  },
};&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This keeps the old schema active, preserves historical data on production, and gives both developers and editors loud warnings. Only after several release cycles (when audit logs confirm no content remains in the old field) do you coordinate with your team to delete the key.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Our suggested approach:&lt;/strong&gt; Treat renames as deliberate, planned events requiring a full codebase audit before any push. The git-blob detection above is a reasonable middle ground &amp;mdash; cheap to build, fails loudly. But no script makes a key rename safe on its own.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h2&gt;The Repo Structure That Makes All of This More Tractable&lt;/h2&gt;
&lt;p&gt;Every detection approach above &amp;mdash; scanning for contract references, extracting group keys, comparing old and new key values &amp;mdash; relies on being able to tell &lt;em&gt;what kind of thing&lt;/em&gt; a changed file is from where it lives. In a flat folder where contracts, pages, blocks, and templates all coexist, that falls apart immediately.&lt;/p&gt;
&lt;p&gt;A deliberate folder structure is what makes any of this tractable. It won&#39;t solve the dependency problem on its own, but it gives your scripts &amp;mdash; and your reviewers &amp;mdash; something to reason from.&lt;/p&gt;
&lt;pre class=&quot;code-block&quot;&gt;&lt;code&gt;src/
└── content-model/           # Everything the CLI pushes as schema
    ├── contracts/             # Shared interfaces &amp;mdash; Case 1 blast radius
    │   ├── Categorizable.ts   # category + tags fields shared across types
    │   └── IHeroImage.ts      # heroImage + heroAlt fields
    ├── pages/                 # _page base type
    │   ├── ArticlePage.tsx
    │   ├── LandingPage.tsx
    │   └── BlogPage.tsx
    ├── components/            # _component base type (blocks AND elements)
    │   ├── TeaserBlock.tsx
    │   ├── HeroBlock.tsx
    │   ├── HeadingElement.tsx # an &quot;element&quot; is just a component with restricted config
    │   └── ParagraphElement.tsx
    ├── experiences/           # _experience base type
    │   └── BlankExperience.tsx
    └── sections/              # _section base type
        └── BlankSection.tsx

src/
└── templates/               # Display templates &amp;mdash; decoupled, own push lane
    ├── ArticlePage.style.ts   # Wide / Narrow display variants for ArticlePage
    ├── TeaserBlock.style.ts   # Card / Banner display variants for TeaserBlock
    └── section-grid.style.ts  # 1-col / 2-col / 3-col section layout options&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A note on &lt;strong&gt;elements&lt;/strong&gt;: in the JavaScript SDK, an element is not a separate base type &amp;mdash; it&#39;s a &lt;strong&gt;_component&lt;/strong&gt; with restricted configuration (it&#39;s meant to be used as a Visual Builder building block rather than a standalone block). Because it&#39;s still a component, it lives in the same folder and behaves identically for push-dependency purposes. Group by base type, not by editorial role.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Property groups&lt;/strong&gt; don&#39;t get their own files either &amp;mdash; they live in &lt;strong&gt;optimizely.config.mjs&lt;/strong&gt; as the propertyGroups array. If your config grows large you can split it into an imported module, but the source of truth stays the config file.&lt;/p&gt;
&lt;h3&gt;What templates actually are&lt;/h3&gt;
&lt;p&gt;A template file registers display variations that editors can apply to content nodes in Visual Builder. They are pure style/visual configuration &amp;mdash; they carry no content schema. This is why they can be pushed on their own lane without any dependency scanning.&lt;/p&gt;
&lt;p&gt;A display template applies to a content type (or base type, or structural node) and exposes editor-selectable settings:&lt;/p&gt;
&lt;pre class=&quot;code-block&quot;&gt;&lt;code class=&quot;language-typescript&quot;&gt;// src/templates/TeaserBlock.style.ts
// POST https://api.cms.optimizely.com/v1/displaytemplates

export const teaserBlockCardStyle = {
  key: &#39;TeaserBlockCard&#39;,
  displayName: &#39;Card&#39;,
  contentType: &#39;teaserBlock&#39;,     // applies to this specific content type
  settings: {
    ImagePosition: {
      displayName: &#39;Image Position&#39;,
      editor: &#39;select&#39;,
      choices: {
        Left:  { displayName: &#39;Left&#39;,  sortOrder: 1 },
        Right: { displayName: &#39;Right&#39;, sortOrder: 2 },
        Top:   { displayName: &#39;Top&#39;,   sortOrder: 3 }
      }
    }
  }
};&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;code-block&quot;&gt;&lt;code class=&quot;language-typescript&quot;&gt;// src/templates/section-grid.style.ts
// A structural node template &amp;mdash; applies to all sections

export const sectionGridStyle = {
  key: &#39;SectionGrid&#39;,
  displayName: &#39;Grid&#39;,
  nodeType: &#39;section&#39;,            // applies to all section nodes
  settings: {
    Columns: {
      displayName: &#39;Columns&#39;,
      editor: &#39;select&#39;,
      choices: {
        One:   { displayName: &#39;1 column&#39;,  sortOrder: 1 },
        Two:   { displayName: &#39;2 columns&#39;, sortOrder: 2 },
        Three: { displayName: &#39;3 columns&#39;, sortOrder: 3 }
      }
    }
  }
};&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&#128214; &lt;a href=&quot;https://docs.developers.optimizely.com/content-management-system/v1.0.0-CMS-SaaS/docs/configure-visual-builder&quot;&gt;Configure Visual Builder&lt;/a&gt; &amp;middot; &lt;a href=&quot;https://docs.developers.optimizely.com/content-management-system/v1.0.0-CMS-SaaS/docs/work-with-styles&quot;&gt;Manage styles&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;The folder pays off in the script&lt;/h3&gt;
&lt;p&gt;With this structure, a script can classify a changed file from its path alone:&lt;/p&gt;
&lt;ul class=&quot;tight&quot;&gt;
&lt;li&gt;
&lt;p&gt;A file under &lt;strong&gt;content-model/contracts/&lt;/strong&gt; &amp;rarr; run the Case 1 scan&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;A file under &lt;strong&gt;content-model/pages/&lt;/strong&gt; or &lt;strong&gt;content-model/components/&lt;/strong&gt; &amp;rarr; run the Case 3 scan&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;optimizely.config.mjs&lt;/strong&gt; changed &amp;rarr; run the Case 2 check&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;A file under &lt;strong&gt;templates/&lt;/strong&gt; &amp;rarr; push on the templates lane, no schema scan needed&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;It also opens the door to something more responsive: a file watcher during development that flags dependency-affecting changes the moment you save, before you ever run a push. We haven&#39;t built that &amp;mdash; it&#39;s a direction the structure makes possible.&lt;/p&gt;
&lt;p&gt;The honest caveat: no folder convention survives a team that doesn&#39;t follow it. The structure only works if it&#39;s agreed, documented, and enforced.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Shifting Our Mindset: Content Modeling as Schema Migration&lt;/h2&gt;
&lt;p&gt;When you build on a headless, code-first content platform, your content type files are not just React rendering files. They represent your &lt;strong&gt;live database schema, your API contract, and your editorial UI layout all at once.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;In traditional software engineering, we treat database schemas with extreme discipline. You would never let a CI/CD build run a migration that drops database columns containing live customer data. You write versioned migrations, test them locally, and deploy them sequentially.&lt;/p&gt;
&lt;p&gt;Content modeling in CMS SaaS requires the exact same engineering discipline.&lt;/p&gt;
&lt;p&gt;This realization completely changes how we govern our code-first pipelines and why selective pushes exist:&lt;/p&gt;
&lt;ol class=&quot;tight&quot;&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Selective push is your local iteration tool.&lt;/strong&gt; It is how developers move quickly on a shared sandbox without overwriting other unmerged work.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;The script is your diagnostic blueprint.&lt;/strong&gt; When a build fails or conflicts occur, the branch-diff script is what you run to instantly map out the exact footprint of your changes and see which contracts or components are dragging dependents in.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;CI/CD is your schema safety gate.&lt;/strong&gt; It must run strictly, never forcing, and failing loudly if anything is out of sync.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Let&amp;rsquo;s look at how to structure your CI/CD pipeline to act as this hard firewall.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;CI/CD Guardrails: The &quot;Fail Loudly, Never Force&quot; Principle&lt;/h2&gt;
&lt;p&gt;In lower environments (like individual developer sandboxes), selective pushes and the propose-review-push workflow provide rapid feedback loops. But as your code travels up to shared environments, the governance must tighten.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; border-width: 1px; border-style: solid;&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top;&quot;&gt;
&lt;p&gt;&lt;strong&gt;Environment&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top;&quot;&gt;
&lt;p&gt;&lt;strong&gt;Purpose&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top;&quot;&gt;
&lt;p&gt;&lt;strong&gt;Push Strategy&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top;&quot;&gt;
&lt;p&gt;&lt;strong&gt;CLI Flags&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top;&quot;&gt;
&lt;p&gt;&lt;strong&gt;Local Sandbox&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top;&quot;&gt;
&lt;p&gt;Individual dev space&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top;&quot;&gt;
&lt;p&gt;Scoped / Selective push&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top;&quot;&gt;
&lt;p&gt;Normal&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top;&quot;&gt;
&lt;p&gt;&lt;strong&gt;Shared QA / Stage&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top;&quot;&gt;
&lt;p&gt;Team integration &amp;amp; content entry&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top;&quot;&gt;
&lt;p&gt;Scoped or Full Push&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top;&quot;&gt;
&lt;p&gt;&lt;strong&gt;Strictly no --force&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top;&quot;&gt;
&lt;p&gt;&lt;strong&gt;Production&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top;&quot;&gt;
&lt;p&gt;Live consumer experience&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top;&quot;&gt;
&lt;p&gt;Authoritative Full Push&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top;&quot;&gt;
&lt;p&gt;&lt;strong&gt;Strictly no --force&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;Enforcing the Safety Gate&lt;/h3&gt;
&lt;p&gt;The &lt;strong&gt;@optimizely/cms-cli&lt;/strong&gt; is designed to fail loudly. If there is a schema conflict &amp;mdash; such as a missing implementing type for a modified contract, or an invalid group key reference &amp;mdash; the command exits with a non-zero exit code (typically &lt;strong&gt;1&lt;/strong&gt;).&lt;/p&gt;
&lt;p&gt;Any standard CI/CD engine (GitHub Actions, GitLab CI, Azure DevOps) will intercept this non-zero exit code and immediately fail the build step.&lt;/p&gt;
&lt;p&gt;By running your staging and production pipelines &lt;strong&gt;without the --force flag&lt;/strong&gt;, you ensure that if a developer checked in a breaking change without coordination, the build halts before a single production schema is modified or live data is orphaned.&lt;/p&gt;
&lt;h3&gt;Diagnostic Recovery Workflow&lt;/h3&gt;
&lt;p&gt;When the build fails, the team doesn&#39;t have to guess what broke. A developer runs the &lt;strong&gt;push-branch-changes.mjs&lt;/strong&gt; script locally comparing the release branch to the target base (e.g., &lt;strong&gt;origin/main&lt;/strong&gt;):&lt;/p&gt;
&lt;pre class=&quot;code-block&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;npm run cms:push:types release/q3 origin/main&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The script outputs the precise dependency tree of the changed files. The team can instantly pinpoint: &lt;em&gt;&quot;Ah! Someone updated the contract, which dragged in ArticlePage. But ArticlePage on Stage has custom fields that are still locked in another draft. That&#39;s our conflict.&quot;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;It turns a black-box build failure into an actionable, reviewable transaction map.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;The Workflow: Propose, Review, Push&lt;/h2&gt;
&lt;p&gt;The script below doesn&#39;t push for you automatically. It detects, proposes, and hands you a reviewable config. You decide whether to push it.&lt;/p&gt;
&lt;p&gt;The loop:&lt;/p&gt;
&lt;ol class=&quot;tight&quot;&gt;
&lt;li&gt;
&lt;p&gt;Run one command against your branch.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;The script diffs your branch, classifies each changed file by its folder, and runs the relevant dependency scans for Cases 1&amp;ndash;3.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;It &lt;strong&gt;writes a temporary config&lt;/strong&gt; listing the proposed push set &amp;mdash; your changed files plus every dependent it found &amp;mdash; and prints the list.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;You review the list. Does it look right? Anything surprising, or anything missing you expected? For Cases 4&amp;ndash;5 it flags what needs a human decision.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;If you&#39;re satisfied, you run the push command it hands you.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This isn&#39;t overhead instead of a solution. It&#39;s a gate that makes a scoped push reviewable before it lands on a shared instance &amp;mdash; something a blind full push can&#39;t give you.&lt;/p&gt;
&lt;p&gt;Two commands, split by concern:&lt;/p&gt;
&lt;pre class=&quot;code-block&quot;&gt;&lt;code class=&quot;language-json&quot;&gt;// package.json
&quot;scripts&quot;: {
  &quot;cms:push:templates&quot;: &quot;npx @optimizely/cms-cli@latest push --config optimizely.config.templates.mjs&quot;,
  &quot;cms:push:types&quot;:     &quot;node push-branch-changes.mjs&quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Templates lane &amp;mdash;&lt;/strong&gt; display templates and Visual Builder styles. Zero schema risk. No review needed.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Types lane &amp;mdash;&lt;/strong&gt; content types and contracts together, via the propose-review-push script below.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;One Rough Starting Point for the Script&lt;/h2&gt;
&lt;p&gt;This extends the script from Part 1 to attempt dependency detection for Cases 1&amp;ndash;3. We&#39;re sharing it as a concept, not a drop-in solution. It makes simplifying assumptions &amp;mdash; consistent folder structure, filename-based detection, flat directory scanning &amp;mdash; that won&#39;t hold in every project. Treat it as something to adapt, not something to copy.&lt;/p&gt;
&lt;p&gt;Cases 4 and 5 (key renames and deletions) are not automatable in any reliable way. The script blocks on file deletions to at least surface the problem. Key value renames it cannot detect at all without the git-blob comparison described in Case 5.&lt;/p&gt;
&lt;p&gt;&#128214; &lt;a href=&quot;https://docs.developers.optimizely.com/content-management-system/v1.0.0-CMS-SaaS/docs&quot;&gt;JavaScript SDK docs&lt;/a&gt;&lt;/p&gt;
&lt;pre class=&quot;code-block&quot;&gt;&lt;code class=&quot;language-javascript&quot;&gt;// push-branch-changes.mjs &amp;mdash; adapt to your folder structure
import { execSync } from &#39;child_process&#39;;
import fs from &#39;fs&#39;;
import path from &#39;path&#39;;

const baseBranch = process.argv[2] || &#39;origin/main&#39;;
const tempConfig = &#39;optimizely.config.temp.mjs&#39;;

// Match your actual folder structure. Note: elements live under components.
const PAGES_DIR      = &#39;src/content-model/pages&#39;;
const COMPONENTS_DIR = &#39;src/content-model/components&#39;;
const CONTRACTS_DIR  = &#39;src/content-model/contracts&#39;;
const ALL_TYPES_DIRS = [PAGES_DIR, COMPONENTS_DIR, CONTRACTS_DIR];

try {
  console.log(`Diffing against ${baseBranch}...`);

  const gitOutput = execSync(`git diff ${baseBranch} --name-only`, { encoding: &#39;utf8&#39; });
  const changedFiles = gitOutput
    .split(&#39;\n&#39;).map(f =&amp;gt; f.trim())
    .filter(f =&amp;gt; f.length &amp;gt; 0 &amp;amp;&amp;amp; fs.existsSync(f));

  if (changedFiles.length === 0) {
    console.log(&#39;No changes. Nothing to push.&#39;);
    process.exit(0);
  }

  // ── Block on deletions &amp;mdash; requires human audit before any push
  const deletedFiles = execSync(
    `git diff ${baseBranch} --name-only --diff-filter=D`, { encoding: &#39;utf8&#39; }
  ).split(&#39;\n&#39;).map(f =&amp;gt; f.trim()).filter(Boolean);

  const deletedTypes = deletedFiles.filter(f =&amp;gt; ALL_TYPES_DIRS.some(d =&amp;gt; f.startsWith(d)));
  if (deletedTypes.length &amp;gt; 0) {
    console.error(&#39;\n[BLOCKED] Deleted content type or contract files:&#39;);
    deletedTypes.forEach(f =&amp;gt; console.error(` - ${f}`));
    console.error(&#39;\nAudit all referencing files before running a coordinated full push.\n&#39;);
    process.exit(1);
  }

  const pushRegistry = new Set();
  changedFiles
    .filter(f =&amp;gt; ALL_TYPES_DIRS.some(d =&amp;gt; f.startsWith(d)))
    .forEach(f =&amp;gt; pushRegistry.add(f));

  // All content-model files &amp;mdash; used for scanning
  const allTypeFiles = ALL_TYPES_DIRS.flatMap(dir =&amp;gt;
    fs.existsSync(dir)
      ? fs.readdirSync(dir, { recursive: true })
          .filter(f =&amp;gt; f.endsWith(&#39;.tsx&#39;) || f.endsWith(&#39;.ts&#39;))
          .map(f =&amp;gt; path.join(dir, f))
      : []
  );

  // ── Case 1: Contract changes &amp;rarr; scan for implementing types
  const changedContracts = changedFiles.filter(f =&amp;gt; f.startsWith(CONTRACTS_DIR));
  if (changedContracts.length &amp;gt; 0) {
    console.log(&#39;\n[Case 1] Contract changes &amp;mdash; scanning for implementing types...&#39;);
    const names = changedContracts.map(f =&amp;gt; path.basename(f, path.extname(f)));
    expandDependents(allTypeFiles, names, pushRegistry, &#39;implements contract&#39;);
  }

  // ── Case 2: Property group key changes &amp;rarr; scan for referencing types
  const changedConfig = changedFiles.find(f =&amp;gt; f === &#39;optimizely.config.mjs&#39;);
  if (changedConfig) {
    const src = fs.readFileSync(changedConfig, &#39;utf8&#39;);
    const groupKeys = [...src.matchAll(/key:\s*[&#39;&quot;](\w[\w-]*)[&#39;&quot;]\s*,\s*displayName/g)].map(m =&amp;gt; m[1]);
    if (groupKeys.length &amp;gt; 0) {
      console.log(`\n[Case 2] Property group keys (${groupKeys.join(&#39;, &#39;)}) &amp;mdash; scanning referencing types...`);
      expandDependents(allTypeFiles, groupKeys, pushRegistry, &#39;references group key&#39;);
    }
  }

  // ── Case 3: Component/page changes &amp;rarr; scan for types that embed them
  const changedComponents = changedFiles.filter(f =&amp;gt;
    f.startsWith(COMPONENTS_DIR) || f.startsWith(PAGES_DIR)
  );
  if (changedComponents.length &amp;gt; 0) {
    const names = changedComponents.map(f =&amp;gt; path.basename(f, path.extname(f)));
    console.log(&#39;\n[Case 3] Component/page changes &amp;mdash; scanning for embedding types...&#39;);
    expandDependents(allTypeFiles, names, pushRegistry, &#39;embeds as typed property&#39;);
  }

  const finalPushList = Array.from(pushRegistry);
  if (finalPushList.length === 0) {
    console.log(&#39;No pushable schema changes found.&#39;);
    process.exit(0);
  }

  // ── Propose: write the temp config and print it for review
  console.log(`\nProposed push set (${finalPushList.length} files) &amp;mdash; review before confirming:`);
  finalPushList.forEach(f =&amp;gt; console.log(` - ${f}`));

  const filePaths = finalPushList.map(f =&amp;gt; `&#39;./${f}&#39;`).join(&#39;,\n    &#39;);
  fs.writeFileSync(tempConfig,
    `import { buildConfig } from &#39;@optimizely/cms-sdk&#39;;
export default buildConfig({ components: [\n    ${filePaths}\n  ] });
`, &#39;utf8&#39;);

  console.log(`\nWrote ${tempConfig}. Review it, then push with:`);
  console.log(`  npx @optimizely/cms-cli@latest push --config ${tempConfig}\n`);
  // The script stops here and hands you the command rather than pushing automatically.
  // If you&#39;re confident in the detection for your project, you can execSync the push instead.

} catch (err) {
  console.error(&#39;Failed:&#39;, err.message);
  if (fs.existsSync(tempConfig)) fs.unlinkSync(tempConfig);
  process.exit(1);
}

function expandDependents(allFiles, names, registry, reason) {
  for (const filePath of allFiles) {
    if (registry.has(filePath)) continue;
    const source = fs.readFileSync(filePath, &#39;utf8&#39;);
    const matched = names.find(name =&amp;gt; source.includes(name));
    if (matched) {
      console.log(` -&amp;gt; Including ${filePath} (${reason}: &#39;${matched}&#39;)`);
      registry.add(filePath);
    }
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Known limitations worth being honest about:&lt;/strong&gt; String scanning produces false positives in loosely structured projects &amp;mdash; a rendering import looks identical to a schema embed reference. The property group regex is fragile if your config format differs. Key renames inside a still-present file are invisible to this script entirely. These aren&#39;t edge cases to dismiss &amp;mdash; they&#39;re reasons to test this against your actual project before leaning on it.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Forward Looking: Maturing to a Shared Package&lt;/h2&gt;
&lt;p&gt;For larger enterprise projects or teams handling multiple concurrent front-end applications, a final maturity step is to &lt;strong&gt;extract the content model layer into its own npm package&lt;/strong&gt; (such as &lt;strong&gt;@yourorg/content-model&lt;/strong&gt;).&lt;/p&gt;
&lt;p&gt;This moves your schema definitions into their own independent repository with strict versioning and ownership:&lt;/p&gt;
&lt;ul class=&quot;tight&quot;&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Strict Review Gates:&lt;/strong&gt; The package can have its own &lt;strong&gt;package.json&lt;/strong&gt;, its own CI validation, and its own &lt;strong&gt;CODEOWNERS&lt;/strong&gt; file &amp;mdash; meaning no developer can alter a contract or change a schema key without explicit approval from a content architect.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Semantic Versioning:&lt;/strong&gt; Schema changes are published as versioned releases. A breaking key rename is mapped to a Major version bump, acting as a clear, standard signal to every consuming front-end application.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Isolate the Pipeline:&lt;/strong&gt; The CI/CD push logic lives with the package that defines the models. The client applications simply consume the published TypeScript interfaces.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;While a separate package adds publishing overhead that might be overkill for smaller single-team projects, it represents the logical north star for teams managing Optimizely CMS SaaS at true scale.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Design-Time Guidelines&lt;/h2&gt;
&lt;p&gt;The structure and script address what happens. These habits reduce how many cases happen in the first place.&lt;/p&gt;
&lt;ul class=&quot;tight&quot;&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Treat contracts like a public API.&lt;/strong&gt; Get their shape right before multiple types implement them. Every field you add later drags every implementer into the same push. Small, focused contracts are cheaper to change than broad ones.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Freeze keys once a type lands on any shared instance.&lt;/strong&gt; Key rename = delete + create. Change &lt;strong&gt;displayName&lt;/strong&gt; freely &amp;mdash; it&#39;s cosmetic. Treat &lt;strong&gt;key&lt;/strong&gt; as permanent from day one.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Prefer additive changes.&lt;/strong&gt; Adding a property is safe and localised. Deprecate and add rather than rename or delete.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Separate your content-model layer from your app code and templates.&lt;/strong&gt; The folder structure is what makes any detection approach tractable.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Push in dependency order on fresh environments.&lt;/strong&gt; Property groups first, then contracts, then types that implement them.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Route true breaking changes through a full push.&lt;/strong&gt; Key renames, deletions, dropping a contract &amp;mdash; these are coordination events. Merge, let CI/CD do the authoritative full push, plan content migration separately.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;Summary&lt;/h2&gt;
&lt;table style=&quot;border-collapse: collapse; border-width: 1px; border-style: solid; width: 100%;&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top; background-color: rgb(206, 212, 217);&quot;&gt;
&lt;p&gt;&lt;strong&gt;What changed&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top; background-color: rgb(206, 212, 217);&quot;&gt;
&lt;p&gt;&lt;strong&gt;Dependency created&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top; background-color: rgb(206, 212, 217);&quot;&gt;
&lt;p&gt;&lt;strong&gt;Direction to explore&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top;&quot;&gt;
&lt;p&gt;Field added to a &lt;strong&gt;contract&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top;&quot;&gt;
&lt;p&gt;All implementing types (transitive)&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top;&quot;&gt;
&lt;p&gt;Scan by contract folder + name &amp;mdash; reliability varies&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top;&quot;&gt;
&lt;p&gt;&lt;strong&gt;Property group&lt;/strong&gt; key changed&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top;&quot;&gt;
&lt;p&gt;All types using that group key&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top;&quot;&gt;
&lt;p&gt;Diff config + scan, or manual review at PR time&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top;&quot;&gt;
&lt;p&gt;&lt;strong&gt;Component&lt;/strong&gt; schema changed&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top;&quot;&gt;
&lt;p&gt;All types embedding it as a typed property&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top;&quot;&gt;
&lt;p&gt;Scan by component folder + name&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top;&quot;&gt;
&lt;p&gt;Type added to &lt;strong&gt;allowedTypes&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top;&quot;&gt;
&lt;p&gt;None &amp;mdash; stays in your diff&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top;&quot;&gt;
&lt;p&gt;No extra action needed&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top;&quot;&gt;
&lt;p&gt;Referenced type key &lt;strong&gt;renamed or deleted&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top;&quot;&gt;
&lt;p&gt;All types naming it anywhere&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top;&quot;&gt;
&lt;p&gt;Git-blob compare to detect + coordinated full push&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; border-width: 1px; border-style: solid; width: 100%;&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 47.6px;&quot;&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top; height: 47.6px; background-color: rgb(206, 212, 217);&quot;&gt;
&lt;p&gt;&lt;strong&gt;Environment&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top; height: 47.6px; background-color: rgb(206, 212, 217);&quot;&gt;
&lt;p&gt;&lt;strong&gt;Templates&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top; height: 47.6px; background-color: rgb(206, 212, 217);&quot;&gt;
&lt;p&gt;&lt;strong&gt;Content types + contracts&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top; height: 47.6px; background-color: rgb(206, 212, 217);&quot;&gt;
&lt;p&gt;&lt;strong&gt;Key renames&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 47.6px;&quot;&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top; height: 47.6px;&quot;&gt;
&lt;p&gt;&lt;strong&gt;Developer instances&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top; height: 47.6px;&quot;&gt;
&lt;p&gt;Full push of template config&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top; height: 47.6px;&quot;&gt;
&lt;p&gt;Propose-review-push (adapted to your project)&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top; height: 47.6px;&quot;&gt;
&lt;p&gt;Never selective&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 47.6px;&quot;&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top; height: 47.6px;&quot;&gt;
&lt;p&gt;&lt;strong&gt;Shared QA / Staging&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top; height: 47.6px;&quot;&gt;
&lt;p&gt;Full push on merge&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top; height: 47.6px;&quot;&gt;
&lt;p&gt;Full push on merge&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top; height: 47.6px;&quot;&gt;
&lt;p&gt;Full push after team coordination&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 67.2px;&quot;&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top; height: 67.2px;&quot;&gt;
&lt;p&gt;&lt;strong&gt;Production&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top; height: 67.2px;&quot;&gt;
&lt;p&gt;CI/CD on release&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top; height: 67.2px;&quot;&gt;
&lt;p&gt;CI/CD on release&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top; height: 67.2px;&quot;&gt;
&lt;p&gt;Coordinated release + content migration&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr /&gt;
&lt;h2&gt;The Point&lt;/h2&gt;
&lt;p&gt;Part 1 gave you selective push &amp;mdash; it works, and it&#39;s still the right approach for the vast majority of day-to-day content type changes.&lt;/p&gt;
&lt;p&gt;This part maps the cases where it gets more complex: when a single-file change reaches further than the diff suggests. The five cases are real &amp;mdash; the CMS validates them this way and you will hit them. The folder structure and propose-review-push workflow are directions we think are worth exploring, not a proven system.&lt;/p&gt;
&lt;p&gt;We&#39;re publishing this partly because the answer isn&#39;t fully settled. If you&#39;ve solved any of these more cleanly &amp;mdash; or found that some of this doesn&#39;t hold up in practice &amp;mdash; that&#39;s exactly the kind of feedback this series is for.&lt;/p&gt;
&lt;p&gt;Know the cases. Structure the repo. Let the workflow propose, and you review.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;Have you hit any of these dependency cases on a project? Particularly curious whether the folder-structure approach holds up in practice, and whether anyone has a cleaner answer for the key-rename detection. Comments open below.&lt;/em&gt;&lt;/p&gt;</id><updated>2026-07-25T12:23:05.0000000Z</updated><summary type="html">Blog post</summary></entry> <entry><title>From AI Agents to AI Workflow with Opal</title><link href="https://world.optimizely.com/blogs/my-blog/dates/2026/7/from-ai-agents-to-ai-workflow-with-opal/" /><id>&lt;h2 class=&quot;article-editor-heading article-editor-content__has-focus&quot;&gt;Introduction&lt;/h2&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;In the &lt;a class=&quot;article-editor-link article-editor-link&quot; href=&quot;/link/b31bb7d003754244a5a025412aca0edd.aspx&quot;&gt;first article in this series&lt;/a&gt;, we talked about AI agents in Optimizely Opal and walked through the process of creating a specialised agent. In this article we will take the next step and build a workflow agent.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;As in the first article, we will go through a step&amp;ndash;by&amp;ndash;step process of creating a workflow agent using a practical example of creating a workflow that performs automated initial review of content draft for brand and tone of voice alignment.&lt;/p&gt;
&lt;h2 class=&quot;article-editor-heading&quot;&gt;Standalone agent vs workflow agent&lt;/h2&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Before we dive into building a workflow agent, let&amp;rsquo;s first look at how it differs from a Standalone Agent. &amp;ldquo;Standalone agent&amp;rdquo; is not an official term used in Optimizely documentation, but in this article, we will use it to refer to either an existing agent from the Optimizely Agent Directory or a specialised agent you build yourself.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;A standalone agent already uses context (prompt template, variables, attached files, instructions and the conversation it is in). It can already take actions on its own using tools. What it cannot do is start on its own, coordinate other agents, or run the same workflow on a schedule or every time an event happens. That is what a workflow agent adds: it uses triggers, steps sequence, conditions and loops to combine multiple standalone agents into a multi&amp;ndash;step process.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Can you just create a specialised agent with a prompt that will make it do the same multi-step process? In theory &amp;ndash; yes, but in practice such a complex agent will be very difficult (if not impossible) and expensive (in terms of Opal credits and time spent) to test, debug and maintain. You can refer to the first article &amp;ndash; it explains this in more detail.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;An important consideration to keep in mind: workflow agents typically operate with greater autonomy and less human involvement. A standalone agent is usually invoked by a person, who then reviews its output. A workflow agent, by contrast, often starts automatically and runs unattended, with human review taking place only after the workflow is complete. For this reason, workflow agents (and any standalone agents they use as part of the workflow) need stricter guardrails defining what they can do, what outputs they must produce, and how they should handle errors and edge cases.&lt;/p&gt;
&lt;h2 class=&quot;article-editor-heading&quot;&gt;Building Blocks of a Workflow Agent&lt;/h2&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;A workflow agent consists of 3 building blocks: a trigger that decides when it starts, a series of agents that each perform one step, and the logic that determines how those steps are sequenced.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph is-empty&quot;&gt;&lt;img style=&quot;display: block; margin-left: auto; margin-right: auto;&quot; src=&quot;https://media.licdn.com/dms/image/v2/D5612AQFAGVo8yhATAQ/article-inline_image-shrink_1000_1488/B56Z.KYG2mH4AI-/0/1784733003415?e=1786579200&amp;amp;v=beta&amp;amp;t=hCqpg6pXSyvFyieIdqE7wDKGi6OHElqbitKq8XYHZsM&quot; /&gt;&lt;/p&gt;
&lt;h3 class=&quot;article-editor-heading&quot;&gt;Trigger&lt;/h3&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;a class=&quot;article-editor-link article-editor-link&quot; href=&quot;https://support.optimizely.com/hc/en-us/articles/39865892823693-Workflow-agent-triggers&quot;&gt;Trigger&lt;/a&gt; is what starts the workflow agent. It can be one of the following:&lt;/p&gt;
&lt;ul class=&quot;article-editor-bullet-list&quot;&gt;
&lt;li class=&quot;article-editor-list-item&quot;&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;strong&gt;Chat&lt;/strong&gt; &amp;ndash; agent is called from a chat, just like a standalone agent.&lt;/p&gt;
&lt;/li&gt;
&lt;li class=&quot;article-editor-list-item&quot;&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;strong&gt;Scheduler&lt;/strong&gt; &amp;ndash; agent is executed on a schedule (every hour, every day at 9AM, etc.).&lt;/p&gt;
&lt;/li&gt;
&lt;li class=&quot;article-editor-list-item&quot;&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;strong&gt;Webhook&lt;/strong&gt; &amp;ndash; Opal listens for an incoming webhook and starts the workflow when an external. system sends an event.&lt;/p&gt;
&lt;/li&gt;
&lt;li class=&quot;article-editor-list-item&quot;&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;strong&gt;Email&lt;/strong&gt; &amp;ndash; agent is called when email is sent to the email address that you set as the Trigger Recipient. You can configure additional conditions for sender and subject to avoid accidental email or spam to result in workflow execution&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 class=&quot;article-editor-heading&quot;&gt;Logic&lt;/h3&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Workflow agent logic is defined using conditions and loops.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;a class=&quot;article-editor-link article-editor-link&quot; href=&quot;https://support.optimizely.com/hc/en-us/articles/43620929090829-Workflow-agent-conditions#h_01KHS15TF8A8X5ZW0N9HKT1GYE&quot;&gt;Conditions&lt;/a&gt; allow the workflow to follow different paths based on defined criteria. This is your typical &amp;ldquo;if-then-else&amp;rdquo; condition that can have multiple &amp;ldquo;then&amp;rdquo; parts. Below is an example of having different processing steps for blog article, email or social post.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;img style=&quot;display: block; margin-left: auto; margin-right: auto;&quot; src=&quot;/link/cb0c40f167984ffd99605a2f8abe0263.aspx&quot; /&gt;&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Consider the following best practices when using conditions in workflow:&lt;/p&gt;
&lt;ul class=&quot;article-editor-bullet-list&quot;&gt;
&lt;li class=&quot;article-editor-list-item&quot;&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;strong&gt;Use clear criteria and structured data&lt;/strong&gt; &amp;ndash; base conditions on specific, predictable values. Prefer numeric or pre-defined values such as High, Medium or Low over free-form text.&lt;/p&gt;
&lt;/li&gt;
&lt;li class=&quot;article-editor-list-item&quot;&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;strong&gt;Keep conditions simple&lt;/strong&gt; &amp;ndash; split complex logic into smaller decisions.&lt;/p&gt;
&lt;/li&gt;
&lt;li class=&quot;article-editor-list-item&quot;&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;strong&gt;Avoid overlapping or duplicate rules &lt;/strong&gt;&amp;ndash; make it clear which path takes priority.&lt;/p&gt;
&lt;/li&gt;
&lt;li class=&quot;article-editor-list-item&quot;&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;strong&gt;Cover every outcome &lt;/strong&gt;&amp;ndash; include both matching and fallback paths.&lt;/p&gt;
&lt;/li&gt;
&lt;li class=&quot;article-editor-list-item&quot;&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;strong&gt;Test edge cases &lt;/strong&gt;&amp;ndash; check missing, unexpected and boundary values.&lt;/p&gt;
&lt;/li&gt;
&lt;li class=&quot;article-editor-list-item&quot;&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;strong&gt;Add a safe fallback &lt;/strong&gt;&amp;ndash; route uncertain cases for human review or end the workflow safely.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;a class=&quot;article-editor-link article-editor-link&quot; href=&quot;https://support.optimizely.com/hc/en-us/articles/43590976293389-Workflow-agent-loops&quot;&gt;Loop&lt;/a&gt; is handy when you need to perform same step (or set of steps) over multiple items. Below is an example of using loop to do AEO/GEO analysis and provide summary and improvement recommendations for all child pages of a specified page.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;img style=&quot;display: block; margin-left: auto; margin-right: auto;&quot; src=&quot;https://media.licdn.com/dms/image/v2/D5612AQGrafRs4viSMQ/article-inline_image-shrink_1000_1488/B56Z.KYwDcJoAM-/0/1784733172179?e=1786579200&amp;amp;v=beta&amp;amp;t=dt_NwalvU39iQTgUKbkqBTq0zdJH-pOxJKSNXjyCjks&quot; /&gt;&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Some best practices when using loops:&lt;/p&gt;
&lt;ul class=&quot;article-editor-bullet-list&quot;&gt;
&lt;li class=&quot;article-editor-list-item&quot;&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;strong&gt;Keep the loop focused&lt;/strong&gt; &amp;ndash; include only the steps required for each item inside the loop, all other steps should be outside of the loop.&lt;/p&gt;
&lt;/li&gt;
&lt;li class=&quot;article-editor-list-item&quot;&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;strong&gt;Handle individual failures&lt;/strong&gt; &amp;ndash; define what should happen when one or more items in the loop cannot be processed.&lt;/p&gt;
&lt;/li&gt;
&lt;li class=&quot;article-editor-list-item&quot;&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;strong&gt;Test with different list sizes&lt;/strong&gt; &amp;ndash; plan/check for empty lists, single-item lists and larger collections.&lt;/p&gt;
&lt;/li&gt;
&lt;li class=&quot;article-editor-list-item&quot;&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;strong&gt;Limit the number of iterations&lt;/strong&gt; &amp;ndash; large lists can increase workflow execution time and credits consumption.&lt;/p&gt;
&lt;/li&gt;
&lt;li class=&quot;article-editor-list-item&quot;&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;strong&gt;Avoid excessive nesting&lt;/strong&gt; &amp;ndash; Optimizely supports up to three nested loops, but simpler workflows are easier to maintain so avoid nested loops unless it&amp;rsquo;s really necessary.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 class=&quot;article-editor-heading&quot;&gt;Agents&lt;/h3&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Each step in the workflow is executed by an agent &amp;ndash; it can be out-of-the-box agent from Optimizely Agent Directory or a specialised agent you or someone else from your team created.&lt;/p&gt;
&lt;h2 class=&quot;article-editor-heading&quot;&gt;Creating Workflow Agent&lt;/h2&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Let&amp;rsquo;s now create a workflow agent that performs an automated initial review of a content draft for brand and tone-of-voice alignment.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;In this example we will use combination of Optimizely CMP + Opal. There is a reason this is a very powerful combination &amp;ndash; CMP provides the structure, governance and visibility to the process, while Opal adds the creativity, reasoning and judgement needed to accelerate complex tasks that cannot be done by conventional tools, such as creating content briefs and drafts, reviewing and proofreading content.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Here is the scenario we will build. A content team is working on content production. Every content production task follows a set of steps, and one of these steps is &lt;strong&gt;Brand review&lt;/strong&gt;. Today, a copywriter picks up this step, opens the content, opens the brand guidelines, and checks one against the other. It is necessary work, but most of it is mechanical.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;With the workflow we will build, when a task reaches the Brand review step, Opal picks it up automatically. It reads the content, fetches the latest brand and tone of voice guidelines, reviews the content against them, and leaves a comment on the task listing any issues it found and suggesting improvements. The task is then assigned to the copywriter, who now starts from a prepared initial review instead of a blank page.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Looks simple but even such a simple workflow can save lots of time for a team that has to produce lots of content and is short on resources. Or imagine a situation where a new version of brand guidelines is released and someone needs to re-check 20 most recent articles against updated guidelines. How long would that take if done manually from scratch? As a copywriter, is this a kind of task you&amp;rsquo;d be excited to work on? As a team manager, is this where you&amp;rsquo;d want to spend your copywriters&amp;rsquo; time?&lt;/p&gt;
&lt;h3 class=&quot;article-editor-heading&quot;&gt;Planning the Workflow&lt;/h3&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Before jumping into building the agent, it&amp;rsquo;s first important to understand and plan what exactly you want to the workflow to do, how do you want it to do it.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;A simple way to do this is to write down workflow steps or (if you&amp;rsquo;re a visual person) draw it as a sequence diagram. Then look at the workflow and ask yourself these questions:&lt;/p&gt;
&lt;ul class=&quot;article-editor-bullet-list&quot;&gt;
&lt;li class=&quot;article-editor-list-item&quot;&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Is it only covering the happy path, what could go unexpectedly and how should we handle it.&lt;/p&gt;
&lt;/li&gt;
&lt;li class=&quot;article-editor-list-item&quot;&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Do you have human review steps at the right place? It&amp;rsquo;s tempting to have a workflow where AI does just everything and human only reviews the final result but in a complex multi-step workflow you will likely need few human review checkpoints.&lt;/p&gt;
&lt;/li&gt;
&lt;li class=&quot;article-editor-list-item&quot;&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;If workflow has a loop in it &amp;ndash; is there a risk of it becoming an endless loop or having too many iterations, do you want to put a limit on number of iterations to avoid it from consuming too many credits?&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Here are the high-level steps in our example workflow:&lt;/p&gt;
&lt;ol class=&quot;article-editor-ordered-list&quot;&gt;
&lt;li class=&quot;article-editor-list-item&quot;&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Content creation task reaches the AI Assisted Brand review step.&lt;/p&gt;
&lt;/li&gt;
&lt;li class=&quot;article-editor-list-item&quot;&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Opal gets the task details and extracts the content to be reviewed.&lt;/p&gt;
&lt;/li&gt;
&lt;li class=&quot;article-editor-list-item&quot;&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Opal fetches the latest brand and tone of voice guidelines.&lt;/p&gt;
&lt;/li&gt;
&lt;li class=&quot;article-editor-list-item&quot;&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;For each content item in the task, Opal identifies the content type, reviews it against the relevant guidelines, and leaves a comment on the task with identified issues and suggested improvements.&lt;/p&gt;
&lt;/li&gt;
&lt;li class=&quot;article-editor-list-item&quot;&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Opal moves the task to the next step, Final Brand Review and Approval.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Next, we look at these initial steps and ask the questions:&lt;/p&gt;
&lt;ul class=&quot;article-editor-bullet-list&quot;&gt;
&lt;li class=&quot;article-editor-list-item&quot;&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;What should happen if we cannot get the task details on step 2?&lt;/p&gt;
&lt;/li&gt;
&lt;li class=&quot;article-editor-list-item&quot;&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;What if task status is Completed instead of instead of In Progress &amp;ndash; do we still do the review or skip it?&lt;/p&gt;
&lt;/li&gt;
&lt;li class=&quot;article-editor-list-item&quot;&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;What if we get an error fetching latest brand guidelines?&lt;/p&gt;
&lt;/li&gt;
&lt;li class=&quot;article-editor-list-item&quot;&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;On step 4, what content type should be reviewed and what content type should be skipped?&lt;/p&gt;
&lt;/li&gt;
&lt;li class=&quot;article-editor-list-item&quot;&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Do we want to have a limit of content types to review within 1 task?&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Now we adjust our workflow with these considerations in mind:&lt;/p&gt;
&lt;ol class=&quot;article-editor-ordered-list&quot;&gt;
&lt;li class=&quot;article-editor-list-item&quot;&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Content creation task reaches the AI Assisted Brand review step.&lt;/p&gt;
&lt;/li&gt;
&lt;li class=&quot;article-editor-list-item&quot;&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Get task details. If there is an error when getting task details &amp;ndash; notify support/ops team via email. If task status is Completed &amp;ndash; skip processing and exit.&lt;/p&gt;
&lt;/li&gt;
&lt;li class=&quot;article-editor-list-item&quot;&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Get the latest brand and tone of voice guidelines. If there is an error getting them &amp;ndash; skip processing, leave comment about this in the task and notify support/ops team via email&lt;/p&gt;
&lt;/li&gt;
&lt;li class=&quot;article-editor-list-item&quot;&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Get content items with content type &amp;ldquo;article&amp;rdquo; or &amp;ldquo;social media post&amp;rdquo; from the task &amp;ndash; these are the only ones we want to cover with the review. If there are no matching content items &amp;ndash; skip processing, leave comment about this in the task. If there are more than 5 matching content items found &amp;ndash; leave comment that only first 5 will be processed, the rest will be skipped&lt;/p&gt;
&lt;/li&gt;
&lt;li class=&quot;article-editor-list-item&quot;&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Loop over found content items: Review content item against brand guidelines, leave comment in the task with findings and recommendations. Stop after reviewing 5 content items.&lt;/p&gt;
&lt;/li&gt;
&lt;li class=&quot;article-editor-list-item&quot;&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Move the task to the next step &amp;ndash; Final Brand Review and Approval.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 class=&quot;article-editor-heading&quot;&gt;Configuring the Workflow&lt;/h3&gt;
&lt;h3 class=&quot;article-editor-heading&quot;&gt;CMP Configuration&lt;/h3&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;CMP will control the overall content production steps. We will not get into the details of CMP configuration in this article but if you&amp;rsquo;re starting with CMP, you can read about how to &lt;a class=&quot;article-editor-link article-editor-link&quot; href=&quot;https://support.optimizely.com/hc/en-us/articles/8186091022093-Manage-workflows&quot;&gt;configure task workflows&lt;/a&gt;.&lt;/p&gt;
&lt;h3 class=&quot;article-editor-heading&quot;&gt;Workflow&lt;/h3&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Here is how our CMP Task Workflow looks like&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;img style=&quot;display: block; margin-left: auto; margin-right: auto;&quot; src=&quot;https://media.licdn.com/dms/image/v2/D5612AQHQ38iuMYP90Q/article-inline_image-shrink_1500_2232/B56Z.KaC6TJoAQ-/0/1784733511531?e=1786579200&amp;amp;v=beta&amp;amp;t=Xxqod8dnRdkMlW4aHhw29eyliyLY88thxtBkO30X7DI&quot; /&gt;&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Note that AI Brand Review has the icon that indicates that it is an external step &amp;ndash; this is required to trigger Opal workflow agent from this step.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;When converting this step to external step you will be required to select external system:&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;We will later use this in Opal agent configuration to distinguish webhook for external step in &amp;ldquo;Content Production with AI-Assisted Brand Review&amp;rdquo; CMP workflow vs external step in another CMP workflow.&lt;/p&gt;
&lt;h3 class=&quot;article-editor-heading&quot;&gt;&lt;img style=&quot;display: block; margin-left: auto; margin-right: auto;&quot; src=&quot;https://media.licdn.com/dms/image/v2/D5612AQFKzpGPJOtgNA/article-inline_image-shrink_1500_2232/B56Z.KaGknKcAQ-/0/1784733526572?e=1786579200&amp;amp;v=beta&amp;amp;t=J6mE4K_5A8cCCa9M79L-j91-VF5-_X08CUipZqoTxD0&quot; /&gt;&lt;/h3&gt;
&lt;h3 class=&quot;article-editor-heading&quot;&gt;Configuring webhook to trigger Opal workflow agent&lt;/h3&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;We already have workflow with external step &amp;ndash; now we need to create webhook in CMP to notify Opal when our external step &amp;ldquo;AI Brand Review&amp;rdquo; is started.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;This process is describe in details here: &lt;a class=&quot;article-editor-link article-editor-link&quot; href=&quot;https://support.optimizely.com/hc/en-us/articles/43289384478989-How-to-trigger-workflow-agents-with-Content-Marketing-Platform-webhooks&quot;&gt;https://support.optimizely.com/hc/en-us/articles/43289384478989-How-to-trigger-workflow-agents-with-Content-Marketing-Platform-webhooks&lt;/a&gt;&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;We will only select 1 event for our webhook: &lt;strong&gt;external_sub_step_started&lt;/strong&gt;&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Note that when creating webhook you will need to provide callback URL and secret &amp;ndash; you get those when you create trigger in Opal &amp;ndash; once you get them copy them to webhook settings.&lt;/p&gt;
&lt;h3 class=&quot;article-editor-heading&quot;&gt;Opal Configuration&lt;/h3&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Here is how our workflow will look like when we&amp;rsquo;re done&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;img style=&quot;display: block; margin-left: auto; margin-right: auto;&quot; src=&quot;/link/d7e550844e6e4483a0aeb8ff530e086e.aspx&quot; width=&quot;1267&quot; height=&quot;693&quot; /&gt;&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Let&amp;rsquo;s see how we create it step by step.&lt;/p&gt;
&lt;h3 class=&quot;article-editor-heading&quot;&gt;Connect Opal to CMP instance&lt;/h3&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;For Opal agents to be able to use existing CMP tools to get task details, it must be given access to this CMP instance by connecting to it.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Note that this connection can only be added by Opal Administrator in your organisation: &lt;a class=&quot;article-editor-link article-editor-link&quot; href=&quot;https://support.optimizely.com/hc/en-us/articles/36359944449805-Get-started-with-Optimizely-Opal-for-administrators#h_01JTGSVJJ6VYDVXSYEK8MZTQWX&quot;&gt;https://support.optimizely.com/hc/en-us/articles/36359944449805-Get-started-with-Optimizely-Opal-for-administrators#h_01JTGSVJJ6VYDVXSYEK8MZTQWX&lt;/a&gt;&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;As a user, you can check what Optimizely products and instances Opal is connected to by creating a workflow agent, adding a trigger step, clicking on the trigger step and see what products and instances are available in Product Instance drop-down.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;img style=&quot;display: block; margin-left: auto; margin-right: auto;&quot; src=&quot;https://media.licdn.com/dms/image/v2/D5612AQEljT6VHAuhmg/article-inline_image-shrink_1500_2232/B56Z.Kanc7JoAQ-/0/1784733661280?e=1786579200&amp;amp;v=beta&amp;amp;t=C4uJBmOl5wc5HxohrbUycJDDkBrDdOXBTj4SfO2WteQ&quot; /&gt;&lt;/p&gt;
&lt;h3 class=&quot;article-editor-heading&quot;&gt;Add Trigger&lt;/h3&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Once you add a trigger by dragging it from the components section to working area, make sure to select the right product instance and configure auth header as described in the documentation:&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;a class=&quot;article-editor-link article-editor-link&quot; href=&quot;https://support.optimizely.com/hc/en-us/articles/43289384478989-How-to-trigger-workflow-agents-with-Content-Marketing-Platform-webhooks&quot;&gt;https://support.optimizely.com/hc/en-us/articles/43289384478989-How-to-trigger-workflow-agents-with-Content-Marketing-Platform-webhooks&lt;/a&gt;&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;img style=&quot;display: block; margin-left: auto; margin-right: auto;&quot; src=&quot;https://media.licdn.com/dms/image/v2/D5612AQEqTVINacL5Fg/article-inline_image-shrink_1500_2232/B56Z.KarpOKMAU-/0/1784733678399?e=1786579200&amp;amp;v=beta&amp;amp;t=Ev8Qto4m7v3RD3bRpIg5f-lVXOoXphwjLKOOvS9tLAM&quot; /&gt;&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Copy webhook URL and secret and put them in webhook configuration on CMP side.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph is-empty&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 class=&quot;article-editor-heading&quot;&gt;Add check for external system passed in webhook&lt;/h3&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;img style=&quot;display: block; margin-left: auto; margin-right: auto;&quot; src=&quot;https://media.licdn.com/dms/image/v2/D5612AQH1HuRYeQjiaA/article-inline_image-shrink_1500_2232/B56Z.KWnOVGQAQ-/0/1784732611703?e=1786579200&amp;amp;v=beta&amp;amp;t=G4z3BvhoHgxHG6vLDrM1bOMEh8E4Wg4UtW8-vyjzLq0&quot; /&gt;&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;This is similar to External Step Routing Agent described here: &lt;a class=&quot;article-editor-link article-editor-link&quot; href=&quot;https://support.optimizely.com/hc/en-us/articles/43289384478989-How-to-trigger-workflow-agents-with-Content-Marketing-Platform-webhooks&quot;&gt;https://support.optimizely.com/hc/en-us/articles/43289384478989-How-to-trigger-workflow-agents-with-Content-Marketing-Platform-webhooks&lt;/a&gt;&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;But instead of agent with external step name specified in prompt, we use an agent that gets value of external step + a condition that checks it. You can re-use this combination in other workflows initiated by a webhook trigger without having to create separate step routing agent with different external system name in prompt.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;We need to add this check because CMP will call the webhook any time an external sub step of a task is started. It could be an external step of a task in a completely different workflow unrelated to content production.&lt;/p&gt;
&lt;h3 class=&quot;article-editor-heading&quot;&gt;Add agents to get task and step details and check if it&amp;rsquo;s ready for processing&lt;/h3&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Next, we need agents that will get task details and check if task is at the right step in the workflow (AI Brand Review).&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;img style=&quot;display: block; margin-left: auto; margin-right: auto;&quot; src=&quot;https://media.licdn.com/dms/image/v2/D5612AQGMeGKuGmFOrw/article-inline_image-shrink_1000_1488/B56Z.KbhxNHUAM-/0/1784733900139?e=1786579200&amp;amp;v=beta&amp;amp;t=bgemiz65ENgvYWlT_D7e9GEaz1z1fuVaBWYujVGVkRY&quot; width=&quot;815&quot; height=&quot;469&quot; /&gt;&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;All this could be done with 1 specialised agent, but as we covered before, smaller agents are easier to create and maintain.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;We already covered details on creating specialised agents in the previous article, so here we&amp;rsquo;ll focus on what each of these agents will do rather than how they are built.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;strong&gt;Get CMP Task Details from Webhook &lt;/strong&gt;agent does exactly what the name suggests&lt;/p&gt;
&lt;ul class=&quot;article-editor-bullet-list&quot;&gt;
&lt;li class=&quot;article-editor-list-item&quot;&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Gets CMP task ID from webhook&lt;/p&gt;
&lt;/li&gt;
&lt;li class=&quot;article-editor-list-item&quot;&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Uses &lt;strong&gt;get_cmp_resource&lt;/strong&gt; tool to get task details&lt;/p&gt;
&lt;/li&gt;
&lt;li class=&quot;article-editor-list-item&quot;&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Returns task details&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;You can re-use agent in any other workflow that is initiated by a webhook from CMP Task.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;strong&gt;&amp;nbsp;Verify CMP Task and Step &lt;/strong&gt;agent gets task details from the previous agent, check the status of the task (should be &lt;strong&gt;In Progress&lt;/strong&gt;), and the current step of the task (should be AI Brand Review). If task has the right status and is at the right step &amp;ndash; it will return &lt;strong&gt;READY_TO_PROCEED&lt;/strong&gt;.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;img style=&quot;display: block; margin-left: auto; margin-right: auto;&quot; src=&quot;https://media.licdn.com/dms/image/v2/D5612AQGgl0LzgLwbxw/article-inline_image-shrink_1000_1488/B56Z.KbrIwG8AI-/0/1784733938544?e=1786579200&amp;amp;v=beta&amp;amp;t=yVsJ8MJg3CRJ7st2edSfPvmJzWIuGE7_FgdWEl01kiM&quot; width=&quot;629&quot; height=&quot;362&quot; /&gt;&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;We then have a condition step that moves workflow to next step (Brand Guardrails retrieval) or stops processing if the CMP task has wrong status or is not at &amp;ldquo;AI Brand Review&amp;rdquo; step.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;We also added a condition to notify Ops team in case Opal fails to get task details from CMP. This is an optional condition and we&amp;rsquo;re using it as example of how you can make the workflow more robust.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 class=&quot;article-editor-heading&quot;&gt;Add agent to get latest brand and tone of voice guidelines&lt;/h3&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;img style=&quot;display: block; margin-left: auto; margin-right: auto;&quot; src=&quot;https://media.licdn.com/dms/image/v2/D5612AQFn528yInFL1g/article-inline_image-shrink_1500_2232/B56Z.KbvejGQAU-/0/1784733956347?e=1786579200&amp;amp;v=beta&amp;amp;t=LgddnHyWZI5LG5RzDcu4tIFwit5JydWnt831Th0l39Y&quot; width=&quot;617&quot; height=&quot;478&quot; /&gt;&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;This agent gets the latest version of brand and tone of voice guidelines. Depending on how and where you store these guidelines, it can use tools like browse_web or read_file or a custom tool (if you keep brand guidelines in a system that must be access in a specific way).&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;This agent should also return the status of guidelines retrieval. In this example we return &lt;strong&gt;BRAND_GUIDELINES_READY&lt;/strong&gt;&amp;nbsp; if all went well.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Here we also have a contingency step to notify Ops team if brand retrieval was not successful.&lt;/p&gt;
&lt;h3 class=&quot;article-editor-heading&quot;&gt;Add agent to get content items from the task&lt;/h3&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;img style=&quot;display: block; margin-left: auto; margin-right: auto;&quot; src=&quot;https://media.licdn.com/dms/image/v2/D5612AQGPPfxb9RPhAQ/article-inline_image-shrink_400_744/B56Z.Kb3vRG4AQ-/0/1784733990158?e=1786579200&amp;amp;v=beta&amp;amp;t=jFrVd66DRu1KoFyJClSPFD-Soc1OZ7Gr_ZC9UXs1lxE&quot; /&gt;&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Now that we have the brand guidelines, we need to get content items from the task. This agent goes over list of content items created in the task and filters one with the appropriate type (article, social post, etc). It can get list of content items in task from &amp;ldquo;Get CMP task details&amp;rdquo; agent or use tool &lt;strong&gt;get_cmp_resource&lt;/strong&gt; to get this information. We can also instruct this agent to:&lt;/p&gt;
&lt;ul class=&quot;article-editor-bullet-list&quot;&gt;
&lt;li class=&quot;article-editor-list-item&quot;&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;leave comment in the task if there are no content items found in the task and there is nothing to review;&lt;/p&gt;
&lt;/li&gt;
&lt;li class=&quot;article-editor-list-item&quot;&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;only choose first 5 content items from the task if it has too many (to avoid workflow taking forever and consuming too many credits) and leave comment about this in the task.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 class=&quot;article-editor-heading&quot;&gt;Use loop to call Brand Review Agent for each content item&lt;/h3&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;img style=&quot;display: block; margin-left: auto; margin-right: auto;&quot; src=&quot;https://media.licdn.com/dms/image/v2/D5612AQG6a48UEsVXgQ/article-inline_image-shrink_400_744/B56Z.Kb._XGgAM-/0/1784734019933?e=1786579200&amp;amp;v=beta&amp;amp;t=IQOS6DiuDgUwMsCDM2LQCW3EWa2NahECSkWgfiFSZ10&quot; /&gt;&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Since a task can have multiple content items in it, we use loop to iterate over these content items and call Brand Review Agent for each of them.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Brand Review Agent already has access to brand guidelines (passed from &amp;ldquo;Retrieve Latest Brand Guidelines&amp;rdquo; agent), uses tool &lt;strong&gt;cmp_retrieve_asset_from_library &lt;/strong&gt;to get asset contents, and uses tool &lt;strong&gt;add_comment_on_task_substep &lt;/strong&gt;to leave comment with review details.&lt;/p&gt;
&lt;h3 class=&quot;article-editor-heading&quot;&gt;Mark step in task as Completed&lt;/h3&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;To make CMP workflow progress to the next step, we have an agent that uses tool &lt;strong&gt;update_task_substep &lt;/strong&gt;to complete the current step (AI Brand Review).&lt;/p&gt;
&lt;h3 class=&quot;article-editor-heading&quot;&gt;Testing and Troubleshooting the Workflow&lt;/h3&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Just as specialised agents, workflow agents in Opal keep execution log that show details of each step in the workflow, including input, output and processing status.&lt;/p&gt;
&lt;figure class=&quot;article-editor-figure-image&quot;&gt;
&lt;div class=&quot;article-editor-inline-image__container article-editor-inline-image__container--resize&quot;&gt;
&lt;div class=&quot;article-editor-content__element-overlay&quot;&gt;
&lt;div class=&quot;article-editor-inline-image__buttons&quot;&gt;
&lt;div class=&quot;resize-image-control-button-tooltip resize-image-control-button-tooltip--hidden&quot;&gt;Maximize image&lt;/div&gt;
&lt;div class=&quot;resize-image-control-button-tooltip resize-image-control-button-tooltip--hidden&quot;&gt;Edit image&lt;/div&gt;
&lt;div class=&quot;resize-image-control-button-tooltip resize-image-control-button-tooltip--hidden&quot;&gt;Delete image&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;img class=&quot;article-editor-inline-image__image&quot; src=&quot;https://media.licdn.com/dms/image/v2/D5612AQEo6FmSPoucnw/article-inline_image-shrink_1000_1488/B56Z.KcJ_SKAAI-/0/1784734064904?e=1786579200&amp;amp;v=beta&amp;amp;t=jKUmmqtJitOsWqLG2jnV62CqgBRZKqf1og14CvxjxvE&quot; alt=&quot;&quot; /&gt;&lt;/div&gt;
&lt;figcaption class=&quot;is-empty&quot;&gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;To see details of an execution &amp;ndash; click on the flow icon&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;img style=&quot;display: block; margin-left: auto; margin-right: auto;&quot; src=&quot;/link/ff060a4fea194b54a3a6fce1d2812278.aspx&quot; width=&quot;1725&quot; height=&quot;262&quot; /&gt;&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;And from there you can click on each step in the workflow to see its input, output and execution memory.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;I recommend that you do this testing early in the process and repeat every time you add a new step to the workflow, especially if you are creating a workflow for the first time. Otherwise, it will be difficult to find where it went wrong when you need to troubleshoot across 5-10 steps.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;As a starting point &amp;ndash; make sure CMP trigger actually works:&lt;/p&gt;
&lt;ul class=&quot;article-editor-bullet-list&quot;&gt;
&lt;li class=&quot;article-editor-list-item&quot;&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Create CMP task based on the workflow that includes external step that will trigger the webhook&lt;/p&gt;
&lt;/li&gt;
&lt;li class=&quot;article-editor-list-item&quot;&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Make sure you saved you workflow agent and it&amp;rsquo;s in Active status&lt;/p&gt;
&lt;/li&gt;
&lt;li class=&quot;article-editor-list-item&quot;&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Move task to the step that triggers the Opal workflow and make sure step is in In Progress state&lt;/p&gt;
&lt;/li&gt;
&lt;li class=&quot;article-editor-list-item&quot;&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Go to Opal agent &amp;gt; View logs &amp;ndash; you should see agent execution in the log&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 class=&quot;article-editor-heading&quot;&gt;Lessons learned&lt;/h3&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;A few principles I would carry into building any workflow like this:&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;strong&gt;Start small. &lt;/strong&gt;One trigger, one review, one comment. A first version that also fixes the content, reassigns the task, and updates three external systems in the process is harder to test and debug. Get one path working end to end before you add the next.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;strong&gt;Use a clear trigger. &lt;/strong&gt;It should be clear what starts the workflow. If you find yourself adding too many conditions right after the trigger, the trigger is probably too broad.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;strong&gt;Keep each agent focused.&lt;/strong&gt; The same rule as in the first article applies: an agent should do one thing well. This matters even more inside a workflow than in chat, because agents in a workflow run unattended in a single pass - there is nobody there to answer a clarifying question, so the prompt has to carry everything the agent needs.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;strong&gt;Keep a human approval step. &lt;/strong&gt;No matter how good the results may look, leave the final step assigned to a person. The workflow&#39;s job is to make that person faster, not to replace their decision.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;strong&gt;Do not automate irreversible actions early.&lt;/strong&gt; Leaving a comment is easy to ignore if it is wrong. Un-publishing content or rejecting a task is not as easy. Keep the workflow on the safe side of that line until you trust it.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;strong&gt;Measure the result.&lt;/strong&gt; The point of this workflow is less manual effort and better review coverage. Check whether that is actually happening by looking at the measurements available in agent execution log, CMP reports on tasks throughput and most importantly, by talking to people who use the workflow.&lt;/p&gt;
&lt;h2 class=&quot;article-editor-heading&quot;&gt;Wrapping up&lt;/h2&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;In the first article, we built a specialised agent that focuses on one task. In this one, we connected several agents to create to a real content process: a trigger from CMP, context gathered at run time, a loop over content items, an action written back into the task, and a human making the final call.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Standalone agents are already valuable, but workflows are where the efficiency gains can multiply. When applied to the right process and designed well, workflows can run consistently whenever they are needed, without relying on someone to initiate each step. As this example shows, that does not mean removing the human from the process. The goal is to reduce the time spent on repetitive, time-consuming tasks so people can focus on review, judgement and final decisions. AI simply expands the range of work that can be treated as routine and automated.&lt;/p&gt;</id><updated>2026-07-23T14:48:00.0000000Z</updated><summary type="html">Blog post</summary></entry> <entry><title>Building Your First Optimizely Opal AI Agent: a Hands-On Walkthrough</title><link href="https://world.optimizely.com/blogs/my-blog/dates/2026/7/building-your-first-optimizely-opal-ai-agent-a-hands-on-walkthrough/" /><id>&lt;h2 class=&quot;article-editor-heading article-editor-content__has-focus&quot;&gt;Introduction&lt;/h2&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;There&#39;s a common assumption that building an AI agent is something only engineers or developers can do. It isn&#39;t. At the Optimizely ANZ Ai-ccelerate Opal Workshop in Melbourne this May, a room full of marketeers built their own working Opal agents in a single 2-hour session. For some of them it was the first time they&amp;rsquo;ve seen Optimizely Opal.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;A specialised agent in Optimizely Opal is, at its core, a set of written instructions and a few settings &amp;ndash; there&amp;rsquo;s no code writing required. The main skill is being able to describe a task clearly: if you can write a clear brief for a colleague, you have most of what you need. Of course, understanding the subject matter is also very important. The rest is understanding few key concepts and some best practices &amp;ndash; this is what this article covers.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;This article is for anyone who has started using Opal and wants to understand how specialised agents are built. You don&#39;t need to be technical, and you don&#39;t need a developer&#39;s background. It&#39;s written with marketing managers, content managers, and CRO specialists in mind, but it&#39;s just as useful if you&#39;re still getting familiar with Opal and want a clear picture of what&#39;s going on under the hood before you build anything yourself.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;To keep it practical, we will use example of building &lt;strong&gt;Answer Readiness Reviewer&lt;/strong&gt; agent: you give it a page URL and a question, and it tells you whether an AI assistant (ChatGPT, Claude, Perplexity, and the like) could actually pull the answer to that question off that page. It reads the content, decides whether the answer is explicit, buried, fragmented, or missing, and then suggests what to change so the page is more likely to be the one an AI quotes. Deciding whether a page actually answers a question well enough for an AI assistant to use it is a judgement call, not a straightforward check that can be done by a script, and that&#39;s exactly the kind of work an agent is good at. We&#39;ll use it as our running example so that every concept has something solid attached to it.&lt;/p&gt;
&lt;h2 class=&quot;article-editor-heading&quot;&gt;What an agent actually is&lt;/h2&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Let&amp;rsquo;s start with understanding key concepts used in Opal: &lt;strong&gt;agents&lt;/strong&gt;, &lt;strong&gt;tools&lt;/strong&gt;, and &lt;strong&gt;skills&lt;/strong&gt;.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;An &lt;strong&gt;agent&lt;/strong&gt; is the assistant that completes the task. It follows a set of written instructions to complete it, and it can call on tools to take action along the way. Opal describes agents as intelligent assistants that use natural language prompts and tools to complete tasks on your behalf, with each agent having a specific purpose &amp;ndash; generating content, analysing data, or automating a step in a workflow.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;A &lt;strong&gt;tool&lt;/strong&gt; is a single action the agent can take. Opal&#39;s own analogy is an attachment on a Swiss Army knife &amp;ndash; each one does one specific thing. Examples of tools include searching the web, creating a campaign, generating an image, or fetching the HTML of a web page. The agent decides which tools it needs and calls them; you don&#39;t run them by hand. Our example agent will use a tool called browse_web_html. This tool fetches the raw contents of a web page. Agent will use it to read the actual page it&#39;s been asked to review, exactly as a browser would see it. Opal has an rich catalogue of tools available out of the box &amp;ndash; you can find it here &lt;a class=&quot;article-editor-link article-editor-link&quot; href=&quot;https://support.optimizely.com/hc/en-us/articles/39340107628429-System-tools-overview&quot;&gt;https://support.optimizely.com/hc/en-us/articles/39340107628429-System-tools-overview&lt;/a&gt;&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;The relationship between the two is simple: the &lt;strong&gt;agent is the worker, the tools are its equipment&lt;/strong&gt;. An agent with no tools can only think and write. An agent with the right tools can also do things &amp;ndash; read a page, send an email, update a record. You give an agent access to a tool by naming it in the agent&#39;s instructions and adding it in the agent&#39;s Tools section.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;strong&gt;Skills&lt;/strong&gt; (which Opal used to call &quot;instructions&quot;) are the third piece. Skills are reusable context and guidelines that shape how Opal behaves, like your brand voice, your product descriptions, your target personas, or your formatting rules. The point of skills is that you write them once and reuse them everywhere, instead of repeating &quot;here&#39;s our tone of voice&quot; in every single agent. One thing worth knowing: a specialized agent does not automatically pull in your skills. If you want an agent to use one, you reference it explicitly in the agent&#39;s instructions. So skills are the shared knowledge your agents use if needed.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Put together: an &lt;strong&gt;agent&lt;/strong&gt; does a job by following its prompt, using shared &lt;strong&gt;skills&lt;/strong&gt; for context, and calling &lt;strong&gt;tools&lt;/strong&gt; to take actions.&lt;/p&gt;
&lt;h3 class=&quot;article-editor-heading&quot;&gt;The three types of agents&lt;/h3&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Opal has three kinds of agents, and knowing the difference saves you from building something that already exists.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;strong&gt;Out-of-the-box agents&lt;/strong&gt; (also called default agents) live in the &lt;strong&gt;Agent Directory&lt;/strong&gt;. Optimizely builds and maintains these, and they&#39;re ready to run without any setup. They cover common jobs like reviewing content for tone and style, generating competitive insights, or drafting support responses. Each listing tells you what the agent does, which tools it includes, and what you can configure. If one of these already does what you need &amp;ndash; try using it first. They&#39;re also worth checking out even when you plan to build your own, because they show you working examples of how an agent is structured. You can see list of available agents if you select Agents from the left frame menu in Opal and then select Agent Directory.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;img src=&quot;/link/34a8a928b5fe47d7bb163326c549dd1f.aspx&quot; /&gt;&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;If you don&amp;rsquo;t yet have access to Opal, you can find agent directory here:&amp;nbsp;&lt;a class=&quot;article-editor-link article-editor-link&quot; href=&quot;https://www.optimizely.com/agents/&quot;&gt;https://www.optimizely.com/agents/&lt;/a&gt;&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;strong&gt;Specialised agents&lt;/strong&gt; are the custom agents you build. Specialised agents are the right choice when no default agent covers your task and you want precise control over the inputs, the output, and the behaviour.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;strong&gt;Workflow agents&lt;/strong&gt; are the third type, and they&#39;re more advanced. A workflow agent chains several default and specialised agents together into a multi-step process, with the output of one feeding into the next. Think of it as assembling a small team where each member does their part and hands off to the next.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;This article focuses on building Specialised agents, we&amp;rsquo;ll cover Workflow agents in another article.&lt;/p&gt;
&lt;h3 class=&quot;article-editor-heading&quot;&gt;Before you start: the prerequisites&lt;/h3&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;A few things need to be in place before you can create a specialised agent.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;strong&gt;Opal license that supports creating specialised agents.&lt;/strong&gt; If you&#39;re unsure what your Opal license covers, your Customer Success Manager can confirm.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;strong&gt;The right role assigned to your user.&lt;/strong&gt; The roles that can create agents are &lt;em&gt;Agent Builder&lt;/em&gt;, &lt;em&gt;Opal Administrator&lt;/em&gt; or any custom role that&#39;s been given the &quot;&lt;em&gt;Add, edit, and install specialized agents&lt;/em&gt;&quot; permission. If you can see the Agents page but the Add Agent button isn&#39;t there, you most likely have Opal User role that lets you use Opal but does not allow to create or edit agents.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;img src=&quot;/link/f15f4e13f7eb4e479f7bc75a2a23252d.aspx&quot; /&gt;&lt;/p&gt;
&lt;h3 class=&quot;article-editor-heading&quot;&gt;Best practices: keep each agent small&lt;/h3&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Before proceed with agent configuration, you need to plan what you want this agent to do and what part of the overall workflow it will handle. There is an important best practice worth remembering when doing the planning: give each agent one job, keep agents simple.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;You may be tempted to build one big agent that does everything &amp;ndash; fetches the pages, analyses them, rewrites the content, and emails a report. Avoid doing this. Big agents are hard to maintain. They are slower and more expensive to test, because each run does more work and consumes more Opal credits. They are harder to troubleshoot, because when the result is wrong, it&#39;s not always obvious which part failed. And they are harder to improve, because any change you make carries a higher risk of breaking something that was working.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Better approach is to break a process into individual steps and for each step decide who will handle it &amp;ndash; a person, an external system, an existing tool, out-of-the-box Opal agent or a specialised Opal agent. Our example is a good illustration. The broad goal is &quot;make our content perform well with AI assistants,&quot; which is a big task. But the Answer Readiness Reviewer doesn&#39;t try to own all of it. It does exactly one thing: given one page and one question, decide whether an AI could extract the answer, and suggest what would make it better. It doesn&#39;t crawl your whole site, it doesn&#39;t pick which questions matter, and it doesn&#39;t rewrite the page for you. Such agent is easy to describe, easy to test, and easy to reuse &amp;ndash; including as one step inside a bigger workflow later.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;If this principle sounds familiar, it&#39;s because it isn&#39;t new. This is one of the things I liked about Linux when I started learning it: the idea that each program should do one thing well, and that you chain small programs together to do complex work.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;A few related habits that come from the same principle:&lt;/p&gt;
&lt;ul class=&quot;article-editor-bullet-list&quot;&gt;
&lt;li class=&quot;article-editor-list-item&quot;&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;strong&gt;Define the job in 1-2 sentences before you build.&lt;/strong&gt; If you can&#39;t, the scope could be too big and you may need to break it down further. Our example&#39;s sentence: &quot;Given a page and a question, judge whether an AI could extract the answer, and suggest how to improve the page&#39;s chances of being cited.&quot;&lt;/p&gt;
&lt;/li&gt;
&lt;li class=&quot;article-editor-list-item&quot;&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;strong&gt;Be clear about the output.&lt;/strong&gt; Decide upfront what the agent should return as result &amp;ndash; plain text, a table, a file, or structured data like JSON (to pass to another agent or tool) and how that output will be used. Our example returns a short text summary a person can act on: a rating, the evidence behind it, and a list of suggested fixes.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Know which tools it will need. List only the tools required for that one job. Our example needs to read one web page, so it gets one tool: browse_web_html.&lt;/p&gt;
&lt;h2 class=&quot;article-editor-heading&quot;&gt;Getting started&lt;/h2&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;You can build a specialised agent completely from scratch. But there&#39;s usually a faster path, especially if you are new to this: find the existing agent that&#39;s closest to what you want and use it as a starting point. A close-enough agent already has a sensible structure &amp;ndash; a prompt laid out in steps, the right kind of tools selected, an output format chosen. You can keep this structure and change the details, instead of trying to think of everything from scratch. The default agents in the Agent Directory are good candidates for this, and so is any agent your team has already built.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;In our example, we will use FAQ Creation agent as starting point.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Use Duplicate Agent from the context menu to create a copy of an existing agent.&lt;/p&gt;
&lt;h3 class=&quot;article-editor-heading&quot;&gt;&lt;img src=&quot;/link/332e08be77b341c7ae715873dfa45673.aspx&quot; /&gt;&lt;/h3&gt;
&lt;h3 class=&quot;article-editor-heading&quot;&gt;Agent Configuration&lt;/h3&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Now that we&#39;ve started creating the agent, let&#39;s look at what it&#39;s made up of.&lt;/p&gt;
&lt;h3 class=&quot;article-editor-heading&quot;&gt;Name, Id, and Description&lt;/h3&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;strong&gt;&lt;img src=&quot;/link/16bbe41dad80403d949075df0cb94106.aspx&quot; width=&quot;1135&quot; height=&quot;544&quot; /&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;strong&gt;Name&lt;/strong&gt; is for you and your team to understand what agent does and find it.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;strong&gt;Id&lt;/strong&gt; is how the agent gets invoked &amp;mdash; it&#39;s prefixed with @, so you&#39;d call this one with something like &lt;strong&gt;@answer_readiness_reviewer&lt;/strong&gt;. This Id must be unique, Opal checks that the Id is available and suggests alternatives if it&#39;s taken.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;strong&gt;Description&lt;/strong&gt; explains what the agent does and when to use it. It&#39;s a required field. Treat it as documentation for the next person who has to maintain, troubleshoot, or reuse the agent - and this could be a future you who hasn&#39;t looked at it in six months :). A clear description also makes it easier to find the right agent when your library grows and someone needs to choose between several similar ones.&lt;/p&gt;
&lt;h3 class=&quot;article-editor-heading&quot;&gt;Interaction mode&lt;/h3&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;strong&gt;&lt;img src=&quot;/link/aa9afbc78ff34b0d8bf2d8f47b08fd9f.aspx&quot; width=&quot;424&quot; height=&quot;181&quot; /&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;strong&gt;Support multi-turn conversation &lt;/strong&gt;is off by default, which means the agent runs in single-shot mode: you provide the inputs, it runs once and returns a final result. There is no back-and-forth. This is the right choice for most specialised agents that handle tasks that are well-defined enough that a single run with clear inputs produces a usable output. It&#39;s also suitable for agents that are going to perform a step in a multi-step workflow. Multi-turn mode keeps the agent active in Opal Chat for continued conversation, which is better suited to tasks where you expect to refine the output through follow-up such as content draft you want to iterate on.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;strong&gt;Trigger from Chat&lt;/strong&gt; controls whether people can invoke the agent directly in Opal Chat.&lt;/p&gt;
&lt;h3 class=&quot;article-editor-heading&quot;&gt;Input&lt;/h3&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;strong&gt;Variables.&lt;/strong&gt; Variables are the inputs the agent collects before it runs, and they&#39;re what make an agent reusable instead of hard-coded. Each variable has a type, a name, a description, and a flag for whether it&#39;s required.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;In our example we have 2 variables &amp;ndash; URL and Question and both are required.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph is-empty&quot;&gt;&lt;img src=&quot;/link/ccb4b8e72cec43898f7848739ecb9edf.aspx&quot; width=&quot;660&quot; height=&quot;207&quot; /&gt;&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;strong&gt;Tools.&lt;/strong&gt; This is where you give the agent its equipment. You can let Opal predict and add relevant tools automatically or add them manually by selecting from the tools available in your instance.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;img src=&quot;/link/a9465742661c4650adb01ba608a46420.aspx&quot; width=&quot;946&quot; height=&quot;214&quot; /&gt;&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;In our example we only need one tool &amp;ndash; &lt;code&gt;browse_web_html&lt;/code&gt;. It will be used to fetch the page so agent can read page content.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;strong&gt;Inference level.&lt;/strong&gt; This controls how hard Opal thinks before answering. Higher levels improve quality on complex, multi-step reasoning but take longer to run and cost more (in terms of Opal credits). You match the level to the task. In our example, I will set it to Complex, because deciding whether an answer is explicit, buried, fragmented, or missing is genuine reasoning&amp;nbsp; as the agent has to understand the question, comprehend the page, and judge the relationship between them.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;strong&gt;Model Provider.&amp;nbsp;&lt;/strong&gt;As of June 2026, Opal allows selecting between Google Gemini or Antropic Claude as AI model provider for specialised agents.&amp;nbsp;&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;strong&gt;Files (optional).&lt;/strong&gt; You can attach files the agent should reference every time it runs. This can be used when you have guidelines already documented in a file. Going to keep this empty for our example as all the instructions will be in the prompt.&lt;/p&gt;
&lt;h3 class=&quot;article-editor-heading&quot;&gt;Writing the prompt&lt;/h3&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;The prompt (Opal calls it the &lt;strong&gt;prompt template&lt;/strong&gt;) is the agent&#39;s standing instructions, the brief it follows every single time it runs. This is where most of your effort goes, and what defines how good your agent will be.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Optimizely guidance for prompts is very straightforward, and it matches how you&#39;d brief a capable teammate:&lt;/p&gt;
&lt;ul class=&quot;article-editor-bullet-list&quot;&gt;
&lt;li class=&quot;article-editor-list-item&quot;&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;strong&gt;Write in clear, direct language.&lt;/strong&gt; Say what you want done, not what to &quot;consider.&quot;&lt;/p&gt;
&lt;/li&gt;
&lt;li class=&quot;article-editor-list-item&quot;&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;strong&gt;Build a clear hierarchy.&lt;/strong&gt; Use headers, bold text, and lists so the structure of the task is obvious.&lt;/p&gt;
&lt;/li&gt;
&lt;li class=&quot;article-editor-list-item&quot;&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;strong&gt;Break the work into explicit steps.&lt;/strong&gt; Number them. An agent following &quot;Step 1, Step 2, Step 3&quot; is better than unstructured wall of text.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;A few additional prompt-writing techniques that are worth mentioning:&lt;/p&gt;
&lt;ul class=&quot;article-editor-bullet-list&quot;&gt;
&lt;li class=&quot;article-editor-list-item&quot;&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Reference variables and tools explicitly. In the prompt, variables are written in double square brackets like &lt;code&gt;[[PAGE_URL]]&lt;/code&gt; and &lt;code&gt;[[QUESTION]]&lt;/code&gt;, and tools are written in backticks like &lt;code&gt;browse_web_html&lt;/code&gt;. This is how you wire the prompt to the inputs and equipment you configured in the other sections. &lt;br /&gt;Spelling out when and how to use a tool rather than just making it available produces far more consistent results.&lt;/p&gt;
&lt;/li&gt;
&lt;li class=&quot;article-editor-list-item&quot;&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;strong&gt;Define what to do when things go wrong.&lt;/strong&gt; A robust prompt tells the agent how to handle not just the happy path, but also negative scenarios and edge cases. &lt;br /&gt;In our example it could be some of these: a page that won&#39;t load (empty or returns an error), a page behind a login, content is presented as picture rather than text, a question that doesn&#39;t really relate to the page at all or a questions that is not a question but a statement.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;If you want to get better at writing these prompts or just needs an inspiration, you can always check out prompts used in the out-of-the-box agents or check the recommendations here: &lt;a class=&quot;article-editor-link article-editor-link&quot; href=&quot;https://support.optimizely.com/hc/en-us/articles/39300056376717-Prompts-for-specialized-agents&quot;&gt;https://support.optimizely.com/hc/en-us/articles/39300056376717-Prompts-for-specialized-agents&lt;/a&gt;&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;And another tip - you can use Opal or your favourite LLM (ChatGPT, Claude) to generate prompt for your agent or improve existing prompt - they are all quite good at writing and can give ideas for some instruction points or directions you didn&#39;t think about. Just make sure to always review the result before you use it.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Prompt for our example agent is too big to put here but you can find it at &lt;a class=&quot;article-editor-link article-editor-link&quot; href=&quot;https://gist.github.com/igor-safonov/696c1a07e93e8e4f3aa446cc63348f60&quot;&gt;https://gist.github.com/igor-safonov/696c1a07e93e8e4f3aa446cc63348f60&lt;/a&gt;&lt;/p&gt;
&lt;h3 class=&quot;article-editor-heading&quot;&gt;Output&lt;/h3&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;You select an &lt;strong&gt;Output Data Type&lt;/strong&gt; from a list and you can optionally add a brief &lt;strong&gt;Description&lt;/strong&gt; of what the output is.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;img src=&quot;/link/1afe59a09e7c46e88c62a669995a720e.aspx&quot; width=&quot;697&quot; height=&quot;232&quot; /&gt;&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Two important things to keep in mind:&lt;/p&gt;
&lt;ul class=&quot;article-editor-bullet-list&quot;&gt;
&lt;li class=&quot;article-editor-list-item&quot;&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;strong&gt;The detailed shape of the output is defined in the prompt, not here.&lt;/strong&gt; Whether the response uses a table, what columns it has, what the four ratings are - all of that lives in your prompt template. The Output section just tells Opal what kind of data the response is and (optionally) describes it at a high level.&lt;/p&gt;
&lt;/li&gt;
&lt;li class=&quot;article-editor-list-item&quot;&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;strong&gt;What the Description field is for.&lt;/strong&gt; It&#39;s a short, plain-language summary of what the agent returns. It&#39;s most useful when the agent is later used as a step inside a workflow agent, because the next step in the workflow needs to know what shape of result it&#39;s getting. If the agent only ever runs on its own, the description is less critical, but it&#39;s still good documentation for your teammates.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;The choice of data type usually comes down to who or what is going to consume the output. If the result will be passed to another agent or to an automated step in a workflow, &lt;strong&gt;JSON&lt;/strong&gt; is the better choice - it&#39;s structured and machine-readable, and you can pin down the exact shape with an Output Schema. If the result is meant for a person to read and act on, &lt;strong&gt;Text&lt;/strong&gt; is the right choice.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;In our example, the output is meant for a person, so we&#39;ll set:&lt;/p&gt;
&lt;ul class=&quot;article-editor-bullet-list&quot;&gt;
&lt;li class=&quot;article-editor-list-item&quot;&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;strong&gt;Output Data Type&lt;/strong&gt;: Text&lt;/p&gt;
&lt;/li&gt;
&lt;li class=&quot;article-editor-list-item&quot;&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;strong&gt;Description&lt;/strong&gt;: &quot;A markdown response containing the answer-readiness rating (Explicit, Buried, Fragmented, or Missing), evidence from the page that supports the rating, and a short list of concrete suggested improvements. Returns an error template instead if the question is invalid, the page can&#39;t be read, or the question is off-topic for the page.&quot;&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;One more practice worth mentioning, even though it isn&#39;t part of the Output section itself: &lt;strong&gt;preferred outputs&lt;/strong&gt;. After you&#39;ve run the agent a few times, you can mark a particularly good result as a &lt;em&gt;preferred output&lt;/em&gt;. Opal can then use it as an example to guide future runs, which helps tune the agent toward the style and quality you want. This is done from the agent&#39;s execution logs after the fact, not configured upfront - so you&#39;ll come back to it once the agent is built and you&#39;ve seen a few real results. More details available at &lt;a class=&quot;article-editor-link article-editor-link&quot; href=&quot;https://support.optimizely.com/hc/en-us/articles/43244850788109-Preferred-output-examples&quot;&gt;https://support.optimizely.com/hc/en-us/articles/43244850788109-Preferred-output-examples&lt;/a&gt;&lt;/p&gt;
&lt;h3 class=&quot;article-editor-heading&quot;&gt;Testing and improving the agent&lt;/h3&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Building the agent is the first draft, not the finished product. You will then need to do a few rounds of testing and adjustment until you&amp;rsquo;re happy with the result.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;strong&gt;Run a test.&lt;/strong&gt; Save the agent and use &lt;strong&gt;Test Run&lt;/strong&gt; to try it with real inputs. For our example, that means a real page URL and a real question &amp;ndash; ideally start with cases where you already know the answer, so you can tell whether the agent got it right.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;strong&gt;Build yourself a small set of known cases to test every time you make some big changes to the agent.&lt;/strong&gt; This way you can check if baseline still works well or recent change made things worse.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;strong&gt;Look at what the agent did, not just what it returned.&lt;/strong&gt; Opal keeps execution logs that show the steps the agent took and the tools it called. If the result is not what you expected, the log can help you see where it went wrong.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;strong&gt;Adjust one thing at a time.&lt;/strong&gt; When something&#39;s off, the fix is usually adjusting the prompt: a rule that wasn&#39;t explicit enough, a step that needed splitting, an edge case you didn&#39;t mention. Change one thing, test it again, compare.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Do not forget the settings can play a part too &amp;ndash; if adjusting prompt gets you nowhere, check if inference level as well as output instructions and examples.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;strong&gt;Save versions as you go.&lt;/strong&gt; Opal keeps versions of specialised agents, so you can make changes confidently and go back to a previous version anytime.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;img src=&quot;/link/36774809d97a4a4ab7845b26e5197665.aspx&quot; width=&quot;855&quot; height=&quot;366&quot; /&gt;&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Or you can just test it by calling it from Opal chat. Let&#39;s give our example agent a try and see how it goes:&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;img src=&quot;/link/0503efff9b324830a57ab9a2583afce8.aspx&quot; width=&quot;853&quot; height=&quot;793&quot; /&gt;&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;Looks pretty good to me :)&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;And if you&#39;re worried you&#39;ll consume too many Opal credits with your agent - there is a very simple way to check how many tokens each run consumes - from agent editor go to Logs tab and it&#39;s all there.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;&lt;img src=&quot;/link/7a75638925da4c3aa379c04553d38074.aspx&quot; width=&quot;1247&quot; height=&quot;463&quot; /&gt;&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;As you see, the amount of credits consumed by our agent is very reasonable which is another reminder that small agents focused on one task are easier to maintain and more cost efficient that complex do-it-all agents.&lt;/p&gt;
&lt;h2 class=&quot;article-editor-heading&quot;&gt;Conclusion&lt;/h2&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;If there&#39;s one thing to take from this, it&#39;s that building an Opal agent is mostly clear thinking written down and knowing some best practices. You define a job, describe it in clear direct language with steps by step instructions, give the agent the inputs and the tools it needs, and then test and tune it until it&#39;s reliable. The Answer Readiness Reviewer we used as example is straightforward under the hood &amp;ndash; two main inputs, one tool, a well-structured but simple prompt. And yet it does something quite useful and something that a generic script cannot do.&lt;/p&gt;
&lt;p class=&quot;article-editor-paragraph&quot;&gt;For now, the best next step is a simple one: open Opal, find the agent closest to something you&#39;d find useful, duplicate it, and start changing it. You learn this far faster by building than by reading.&lt;/p&gt;</id><updated>2026-07-23T14:34:13.0000000Z</updated><summary type="html">Blog post</summary></entry> <entry><title>Advanced Task Manager Gets a Big Update</title><link href="https://www.adnanzameer.com/2026/07/whats-new-in-advanced-task-manager.html" /><id>&lt;p&gt;One of the things I keep coming back to with Optimizely&#39;s content approval workflow is that it&#39;s solid at the individual level - you know a piece of content needs approving, you click through, done. But the moment you&#39;re managing a site with dozens or hundreds of items in the queue, the interface starts to feel a bit like doing surgery with oven mitts.&lt;/p&gt;

&lt;p&gt;This release is largely the result of that frustration. Each feature below started as something I (or someone using the tool) wanted to do that took too many clicks, too much scrolling, or just wasn&#39;t possible at all. Here&#39;s what changed, and why.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2&gt;Advanced Filtering&lt;/h2&gt;

&lt;p&gt;Before this update, the only way to narrow down the task list was by language. That&#39;s fine if your queue is short, but not if you&#39;re staring at 200 tasks and trying to find everything that&#39;s a block, or everything from a specific site, or that one page whose name you only half remember.&lt;/p&gt;

&lt;p&gt;There&#39;s now a proper filter bar above the list with four independent dimensions:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Status&lt;/strong&gt; - &lt;em&gt;In Review&lt;/em&gt; (the default) or &lt;em&gt;Ready to Publish&lt;/em&gt;, useful for finding things that are approved but haven&#39;t gone live yet.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Type&lt;/strong&gt; - narrow to Pages, Blocks, or Assets/Media only.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Site&lt;/strong&gt; - in multi-site setups, limits the list to one site. Hidden on single-site installs.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Content search&lt;/strong&gt; - type a content ID (integer) or part of the name to find a specific item.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every filter combination updates the URL, so you can bookmark a view or send a link to a colleague.&lt;/p&gt;

&lt;p style=&quot;background:#e8f0ff; border-left:3px solid #1a56e8; padding:12px 16px; border-radius:0 4px 4px 0;&quot;&gt;Active filters show up as dismissible badge pills below the filter bar. Each badge removes just that one filter when clicked. A &lt;strong&gt;Clear all&lt;/strong&gt; link removes everything at once.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2&gt;Select All Across Pages&lt;/h2&gt;

&lt;p&gt;The existing checkbox in the table header selects everything visible on the current page. Which is fine, until you realise there are 12 more pages of results and you need to approve the lot.&lt;/p&gt;

&lt;p&gt;When you check that header checkbox and there are more results than the current page can show, a small banner appears:&lt;/p&gt;

&lt;p style=&quot;background:#e8f0ff; border-left:3px solid #1a56e8; padding:12px 16px; border-radius:0 4px 4px 0;&quot;&gt;&lt;em&gt;All 30 items on this page are selected.&lt;/em&gt; &lt;strong&gt;Select all 412 items matching current filters.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Clicking that link fetches every matching approval ID across all pages and loads them into the current selection. The approval action then covers the complete set - one click, one comment, done. A &lt;strong&gt;Clear selection&lt;/strong&gt; link sits in the same banner if you want to start over.&lt;/p&gt;

&lt;p&gt;The &quot;all items&quot; fetch respects every active filter, so you&#39;re not accidentally bulk-approving things you didn&#39;t intend to include.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2&gt;Scheduled Publishing&lt;/h2&gt;

&lt;p&gt;Publishing right after approval is the common path, but it&#39;s not the only one. Launches, campaigns, news embargoes - there are plenty of reasons to approve content today and want it to go live at a specific future moment without someone having to remember to click Publish at 8 AM on a Tuesday.&lt;/p&gt;

&lt;p&gt;When you check the &lt;em&gt;Publish selected content after approval&lt;/em&gt; option in the approval modal, a second checkbox appears: &lt;em&gt;Schedule publishing for a specific date and time.&lt;/em&gt; Enabling it reveals a date/time picker.&lt;/p&gt;

&lt;p&gt;The chosen datetime is passed along with the approval request. Optimizely handles the rest by setting &lt;code&gt;IVersionable.StartPublish&lt;/code&gt; to that value, so the standard CMS scheduled-publish mechanism kicks in with no custom job needed.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2&gt;Approve Blocks &amp;amp; Media on a Page&lt;/h2&gt;

&lt;p&gt;This is the one I&#39;m most glad made it into this release. Here&#39;s the scenario: a page has gone through its approval sequence and is ready, but it references a dozen blocks and a handful of media files that are all sitting in their own approval queues. The page can&#39;t go live until those are cleared too. Hunting them down individually is tedious.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;Approve Page Dependencies&lt;/strong&gt; button in the filter bar opens a modal where you can pick any page in the site and approve all of its pending block and media dependencies in one action.&lt;/p&gt;

&lt;p&gt;Here&#39;s how it works:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;Click &lt;strong&gt;Approve Page Dependencies&lt;/strong&gt; in the filter bar.&lt;/li&gt;
  &lt;li&gt;Navigate the &lt;strong&gt;page hierarchy tree&lt;/strong&gt; to find the target page. The tree lazy-loads child pages as you expand nodes. In multi-site setups, each configured site appears as a top-level node.&lt;/li&gt;
  &lt;li&gt;If you know the content ID, type it directly into the number input above the tree instead. Picking from the tree clears the input; typing a number clears the tree selection.&lt;/li&gt;
  &lt;li&gt;Select one or more &lt;strong&gt;language branches&lt;/strong&gt; to process.&lt;/li&gt;
  &lt;li&gt;Optionally add an approval comment and check &lt;strong&gt;Publish approved content&lt;/strong&gt; to publish the dependencies immediately after approval.&lt;/li&gt;
  &lt;li&gt;Click &lt;strong&gt;Approve Dependencies&lt;/strong&gt;. A spinner shows while the request runs.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Behind the scenes the system reads every &lt;code&gt;ContentArea&lt;/code&gt; and &lt;code&gt;ContentReference&lt;/code&gt; property on the selected page, collects all referenced blocks and media, and approves any that currently have a pending approval step for the chosen languages. The number of items approved is shown inline, and if anything was approved the task list refreshes automatically after a short pause.&lt;/p&gt;

&lt;p style=&quot;background:#e8f0ff; border-left:3px solid #1a56e8; padding:12px 16px; border-radius:0 4px 4px 0;&quot;&gt;&lt;strong&gt;Note:&lt;/strong&gt; This only approves dependencies - blocks and media that the page references. It does not approve the page itself. That separation is intentional: the page&#39;s own approval workflow stays under normal editorial control.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2&gt;Task Ordering&lt;/h2&gt;

&lt;p&gt;The task list columns are now sortable. Click a column header to sort ascending, click again to sort descending. You can order by name, content type, task type, submission date, who started the review, or deadline (if you&#39;re using the deadline property).&lt;/p&gt;

&lt;p&gt;It sounds small, but being able to sort by submission date newest-first, or by deadline to see what&#39;s most urgent, changes how you triage a long queue.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2&gt;Site Column (Multi-site)&lt;/h2&gt;

&lt;p&gt;If your Optimizely installation runs multiple sites, the task list now shows a &lt;strong&gt;Site&lt;/strong&gt; column alongside each item so you can see at a glance where things belong. On a single-site installation the column stays hidden.&lt;/p&gt;

&lt;p&gt;This pairs naturally with the Site filter: use the column to notice that most of your queue is from one site, then use the filter to focus on it.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2&gt;A note on CMS versions&lt;/h2&gt;

&lt;p&gt;All of the above is available in &lt;strong&gt;version 4.1.0&lt;/strong&gt; on Optimizely CMS 13 / .NET 10, and as &lt;strong&gt;version 3.1.0&lt;/strong&gt; on the CMS 12 branch for teams still on .NET 6. Change Approval support is CMS 12 only for now, since the &lt;code&gt;EPiServer.ChangeApproval&lt;/code&gt; package doesn&#39;t yet have a CMS 13-compatible release.&lt;/p&gt;

&lt;p&gt;To install or update:&lt;/p&gt;

&lt;pre style=&quot;background:#f1f5fb; border:1px solid #dce6f8; border-radius:6px; padding:14px 18px; overflow-x:auto; font-size:14px;&quot;&gt;&lt;code&gt;dotnet add package AdvancedTaskManager&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Source, changelog, and issue tracker are on &lt;a href=&quot;https://github.com/adnanzameer/optimizely-advancedtaskmanager&quot;&gt;GitHub&lt;/a&gt;. If any of these features behave unexpectedly in your setup, or there&#39;s something you&#39;d like to see that isn&#39;t here yet, open an issue - the backlog is public and feature requests are genuinely read.&lt;/p&gt;</id><updated>2026-07-21T14:43:20.0000000Z</updated><summary type="html">Blog post</summary></entry> <entry><title>Parallel Development in Optimizely CMS SaaS: A Smarter Way to Register Components</title><link href="https://world.optimizely.com/blogs/vipin-banka--learnings--insights/dates/2026/7/parallel-development-in-optimizely-cms-saas-a-smarter-way-to-register-components/" /><id>&lt;p&gt;&lt;strong&gt;&#128204; A note before you read:&lt;/strong&gt; The approach described in this article is not a replacement for Optimizely&amp;rsquo;s recommended out-of-the-box component registration pattern &amp;mdash; which is clean, explicit, and exactly right for most projects. This is written for teams of &lt;strong&gt;4 or more developers&lt;/strong&gt; working on &lt;strong&gt;parallel feature branches&lt;/strong&gt; who are finding that a shared registry file is becoming a source of repeated, low-value merge conflicts. If that&amp;rsquo;s not your situation yet, file this away for when it is.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;When Optimizely introduced code-first content modeling for CMS SaaS, they solved one of the biggest headaches in modern CMS development. Being able to define your content types in standard &lt;strong&gt;.tsx&lt;/strong&gt; files and push them via the CLI is a massive developer experience win.&lt;/p&gt;
&lt;p&gt;But modeling is only half the equation. On the frontend, you must map those content types to actual React components so the SDK can resolve and render them.&lt;/p&gt;
&lt;p&gt;The standard approach uses a central registration file &amp;mdash; typically in your Next.js layout or a dedicated registry file &amp;mdash; where you manually import every single component and content type, registering them with &lt;strong&gt;initContentTypeRegistry&lt;/strong&gt; and &lt;strong&gt;initReactComponentRegistry&lt;/strong&gt;:&lt;/p&gt;
&lt;pre class=&quot;code-block&quot;&gt;&lt;code class=&quot;language-typescript&quot;&gt;// The classic static registration (e.g., src/components/registry.ts)
import { initContentTypeRegistry } from &#39;@optimizely/cms-sdk&#39;;
import { initReactComponentRegistry } from &#39;@optimizely/cms-sdk/react/server&#39;;

import HeroBanner, { HeroBannerContentType } from &#39;./HeroBanner&#39;;
import ArticlePage, { ArticlePageContentType } from &#39;./ArticlePage&#39;;
import GridContainer, { GridContainerContentType } from &#39;./GridContainer&#39;;

export function registerComponents() {
  initContentTypeRegistry([
    HeroBannerContentType,
    ArticlePageContentType,
    GridContainerContentType,
  ]);

  initReactComponentRegistry({
    resolver: {
      HeroBanner,
      ArticlePage,
      GridContainer,
    },
  });
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This explicit registration is an excellent default design. It&#39;s clean, type-safe, and self-documenting.&lt;/p&gt;
&lt;p&gt;But as your engineering team grows from one developer to five, ten, or twenty working in parallel, this central registry file starts to feel a bit crowded.&lt;/p&gt;
&lt;p&gt;Let&#39;s talk about the friction this creates &amp;mdash; and how a small shift in thinking can clean up your git history forever.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;The Tragedy of the Common Registry File&lt;/h2&gt;
&lt;p&gt;Imagine a typical day on an enterprise development team.&lt;/p&gt;
&lt;ul class=&quot;tight&quot;&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Developer A&lt;/strong&gt; is on a feature branch building a new &lt;strong&gt;PromoCard&lt;/strong&gt; component.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Developer B&lt;/strong&gt; is on a separate branch creating an &lt;strong&gt;AuthorBio&lt;/strong&gt; content type.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Developer C&lt;/strong&gt; is refactoring the &lt;strong&gt;NavigationMenu&lt;/strong&gt;.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Each developer creates their standalone component file. But to make it work in their respective sandbox environments, they &lt;em&gt;all&lt;/em&gt; have to touch the exact same central file &amp;mdash; &lt;strong&gt;src/components/registry.ts&lt;/strong&gt; &amp;mdash; to import and register their work.&lt;/p&gt;
&lt;p&gt;Everything works beautifully in isolation. The local tests pass, and the PRs are opened.&lt;/p&gt;
&lt;p&gt;But when the time comes to merge these branches back into &lt;strong&gt;main&lt;/strong&gt;, Git grinds to a halt. There&#39;s a merge conflict in &lt;strong&gt;registry.ts&lt;/strong&gt;. Because everyone modified the exact same import list on the exact same lines, the automated merge fails.&lt;/p&gt;
&lt;p&gt;Dealing with merge conflicts on actual code logic is a normal part of engineering. Dealing with merge conflicts on a &lt;em&gt;list of import statements&lt;/em&gt; is just tax. It slows down your CI/CD pipelines, frustrates developers, and adds zero business value.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;The Mind Shift: Static Imports vs. Runtime Discovery&lt;/h2&gt;
&lt;p&gt;The default behavior of manually importing components in a static file is excellent for small projects or stable schemas. It provides absolute certainty about what is being loaded &amp;mdash; Optimizely&#39;s design here is intentional and solid.&lt;/p&gt;
&lt;p&gt;But as you scale parallel development, the question changes: &lt;em&gt;&quot;Do we really need to declare what files exist when our file system already knows?&quot;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;This is the mental model shift: &lt;strong&gt;move from manual, static registration to automated, runtime discovery.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;If a developer places a valid component &amp;mdash; with its matching &lt;strong&gt;contentType&lt;/strong&gt; definition &amp;mdash; inside the &lt;strong&gt;src/components&lt;/strong&gt; folder, the project should automatically detect and register it. Your folder structure is the source of truth; your registry file should simply reflect that truth dynamically at build time.&lt;/p&gt;
&lt;p&gt;By generating this registry file automatically, we preserve all the power, safety, and type-safety of Optimizely&#39;s SDK while eliminating manual list maintenance &amp;mdash; and the inevitable merge conflicts that come with it &amp;mdash; entirely.&lt;/p&gt;
&lt;p&gt;The question for your team to consider: is the overhead of manually maintaining the registry file appropriate for the size and pace of your team? For a solo developer or a two-person squad, the static approach is perfect. For five or more developers running parallel feature tracks, automation becomes the right call.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Implementing Registry Generation&lt;/h2&gt;
&lt;p&gt;Instead of writing complex dynamic imports that can sometimes complicate React server-side rendering or bundle splitting, the most robust approach is a lightweight pre-build &lt;strong&gt;codegen script&lt;/strong&gt; that automatically compiles your &lt;strong&gt;registry.ts&lt;/strong&gt; file right before your app starts or builds.&lt;/p&gt;
&lt;p&gt;This is a well-established industry pattern that works perfectly with standard Next.js and React environments.&lt;/p&gt;
&lt;h3&gt;1. The Registry Generator Script&lt;/h3&gt;
&lt;p&gt;Create a native Node.js script in your project root: &lt;strong&gt;generate-registry.mjs&lt;/strong&gt;. It recursively scans &lt;strong&gt;src/components&lt;/strong&gt;, finds every component file, and writes a fully-resolved &lt;strong&gt;registry.ts&lt;/strong&gt;.&lt;/p&gt;
&lt;pre class=&quot;code-block&quot;&gt;&lt;code class=&quot;language-javascript&quot;&gt;// generate-registry.mjs
import fs from &#39;fs&#39;;
import path from &#39;path&#39;;

const COMPONENTS_DIR = &#39;./src/components&#39;;
const OUTPUT_FILE = &#39;./src/components/registry.ts&#39;;

function getComponentFiles(dir, filesList = []) {
  if (!fs.existsSync(dir)) return filesList;

  const items = fs.readdirSync(dir);

  for (const item of items) {
    const fullPath = path.join(dir, item);
    const stat = fs.statSync(fullPath);

    if (stat.isDirectory()) {
      getComponentFiles(fullPath, filesList);
    } else if ((item.endsWith(&#39;.tsx&#39;) || item.endsWith(&#39;.ts&#39;)) &amp;amp;&amp;amp; !item.includes(&#39;registry&#39;)) {
      // Expects each file to export a default component + a named ContentType
      // e.g. export const HeroBannerContentType = contentType(...)
      filesList.push(fullPath);
    }
  }
  return filesList;
}

try {
  console.log(&#39;[Optimizely Registry] Scanning components...&#39;);
  const files = getComponentFiles(COMPONENTS_DIR);

  const imports = [];
  const contentTypes = [];
  const resolvers = [];

  files.forEach((file) =&amp;gt; {
    const relativePath = &#39;./&#39; + path.relative(path.dirname(OUTPUT_FILE), file)
      .replace(/\\/g, &#39;/&#39;)
      .replace(/\.tsx?$/, &#39;&#39;);

    const componentName = path.basename(file, path.extname(file));
    const contentTypeConst = `${componentName}ContentType`;

    imports.push(`import ${componentName}, { ${contentTypeConst} } from &#39;${relativePath}&#39;;`);
    contentTypes.push(`  ${contentTypeConst}`);
    resolvers.push(`    ${componentName}`);
  });

  const output = `// ==========================================================================
// AUTO-GENERATED &amp;mdash; do not edit manually. Changes will be overwritten.
// Run: node generate-registry.mjs  |  Source: generate-registry.mjs
// ==========================================================================

import { initContentTypeRegistry } from &#39;@optimizely/cms-sdk&#39;;
import { initReactComponentRegistry } from &#39;@optimizely/cms-sdk/react/server&#39;;

${imports.join(&#39;\n&#39;)}

export function registerComponents() {
  initContentTypeRegistry([
${contentTypes.join(&#39;,\n&#39;)},
  ]);

  initReactComponentRegistry({
    resolver: {
${resolvers.join(&#39;,\n&#39;)},
    },
  });
}
`;

  fs.writeFileSync(OUTPUT_FILE, output, &#39;utf8&#39;);
  console.log(`[Optimizely Registry] Done &amp;mdash; ${files.length} component(s) registered.`);

} catch (err) {
  console.error(&#39;[Optimizely Registry] Generation failed:&#39;, err.message);
  process.exit(1);
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2. Wire It Into Your Lifecycle&lt;/h3&gt;
&lt;p&gt;Hook the script into your &lt;strong&gt;package.json&lt;/strong&gt; using npm&#39;s built-in &lt;strong&gt;pre&lt;/strong&gt; hooks. It runs automatically before both &lt;strong&gt;dev&lt;/strong&gt; and &lt;strong&gt;build&lt;/strong&gt; &amp;mdash; no manual step required.&lt;/p&gt;
&lt;pre class=&quot;code-block&quot;&gt;&lt;code class=&quot;language-json&quot;&gt;&quot;scripts&quot;: {
  &quot;predev&quot;: &quot;node generate-registry.mjs&quot;,
  &quot;prebuild&quot;: &quot;node generate-registry.mjs&quot;,
  &quot;dev&quot;: &quot;next dev&quot;,
  &quot;build&quot;: &quot;next build&quot;,
  &quot;start&quot;: &quot;next start&quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then add the generated file to &lt;strong&gt;.gitignore&lt;/strong&gt;, since it is a build artifact:&lt;/p&gt;
&lt;pre class=&quot;code-block&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;# .gitignore
src/components/registry.ts&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;From this point on, no developer ever touches &lt;strong&gt;registry.ts&lt;/strong&gt; again. It is generated fresh on every &lt;strong&gt;npm run dev&lt;/strong&gt; and every CI/CD build run.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Why This Works on Local and CI/CD Alike&lt;/h2&gt;
&lt;p&gt;Because the generator runs as part of the standard dev/build lifecycle, it scales flawlessly across all stages without any developer intervention.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; border-width: 1px; border-style: solid;&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top;&quot;&gt;
&lt;p&gt;&lt;strong&gt;Scenario&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top;&quot;&gt;
&lt;p&gt;&lt;strong&gt;Static Registry&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top;&quot;&gt;
&lt;p&gt;&lt;strong&gt;Generated Registry&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top;&quot;&gt;
&lt;p&gt;&lt;strong&gt;Local Sandbox&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top;&quot;&gt;
&lt;p&gt;Developers must manually add imports every time they create a component.&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top;&quot;&gt;
&lt;p&gt;Creating a &lt;strong&gt;.tsx&lt;/strong&gt; file and running &lt;strong&gt;npm run dev&lt;/strong&gt; registers it automatically.&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top;&quot;&gt;
&lt;p&gt;&lt;strong&gt;Feature Branch&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top;&quot;&gt;
&lt;p&gt;Every branch modifies the shared &lt;strong&gt;registry.ts&lt;/strong&gt;, creating Git conflicts on merge.&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top;&quot;&gt;
&lt;p&gt;Branches only contain the component file itself. &lt;strong&gt;registry.ts&lt;/strong&gt; is Git-ignored. Merges are 100% clean.&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top;&quot;&gt;
&lt;p&gt;&lt;strong&gt;CI/CD Pipeline&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top;&quot;&gt;
&lt;p&gt;Static imports are read as-is. A missed import causes a silent runtime failure.&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; padding: 10px; border-style: solid; text-align: left; vertical-align: top;&quot;&gt;
&lt;p&gt;The generator scans the actual workspace. If the file exists in the repo, it gets registered.&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr /&gt;
&lt;h2&gt;Is This the Right Move for Your Team?&lt;/h2&gt;
&lt;p&gt;Runtime registry generation isn&#39;t a universal answer &amp;mdash; it&#39;s a tool for the right context. Here&#39;s how to think about it:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Stick with the static registry if:&lt;/strong&gt;&lt;/p&gt;
&lt;ul class=&quot;tight&quot;&gt;
&lt;li&gt;
&lt;p&gt;Your team is small (one to three developers)&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Your schema is stable and rarely changes&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;You value absolute explicitness and prefer to control every import&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Consider dynamic generation if:&lt;/strong&gt;&lt;/p&gt;
&lt;ul class=&quot;tight&quot;&gt;
&lt;li&gt;
&lt;p&gt;You have four or more developers working on parallel feature branches&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;You&#39;re frequently hitting merge conflicts on a single registration file&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;You want your CI/CD pipeline to be self-healing rather than dependent on human memory&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Neither approach is wrong. The static model is exactly what Optimizely recommends out of the box &amp;mdash; and for good reason. The generated model is a team-scale evolution of that same idea, where automation takes on the maintenance burden.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Wrapping Up&lt;/h2&gt;
&lt;p&gt;Optimizely&#39;s SDK gives you a clean, type-safe, and explicit way to map content types to React components. That design is intentional and it&#39;s solid.&lt;/p&gt;
&lt;p&gt;But as teams grow, automation should replace manual list-keeping. By shifting your mental model toward dynamic registry generation, you get the best of both worlds: the reliability and structure of Optimizely&#39;s component resolution, paired with a conflict-free, scalable parallel development workflow.&lt;/p&gt;
&lt;p&gt;It&#39;s a simple script with a meaningful impact on your daily developer experience. Give it a try on your next feature sprint and let the filesystem do the organizing.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;Are you running into registry conflicts on your Optimizely CMS SaaS projects? Have you found other ways to automate the component wiring? Share what&#39;s working for your team in the comments &amp;mdash; we&#39;d love to hear it.&lt;/em&gt;&lt;/p&gt;</id><updated>2026-07-21T05:42:00.0000000Z</updated><summary type="html">Blog post</summary></entry> <entry><title>How I Deployed My Optimizely Content JS SDK Next.js App on Vercel (Hello Opti World)</title><link href="https://kpbasics.com/?p=8370" /><id>&amp;#x1f4cc; Scope: This post covers Optimizely CMS (SaaS) only, using the official content-js-sdk with Next.js 15 deployed to Vercel. This is a practitioner walkthrough — not official documentation. &amp;#x26a0;&amp;#xfe0f; No official Vercel deployment guide exists for the content-js-sdk at the time of writing. This is what I figured out building my first app — Hello [&amp;#8230;]</id><updated>2026-07-21T04:02:15.0000000Z</updated><summary type="html">Blog post</summary></entry> <entry><title>Finding Thomas Part 5 - The Closed Loop</title><link href="https://world.optimizely.com/blogs/ritu-madan/dates/2026/7/finding-thomas-part-5---the-closed-loop/" /><id>&lt;p class=&quot;font-claude-response-body break-words whitespace-normal&quot;&gt;Five weeks. Five layers. One Thomas.&lt;/p&gt;
&lt;p class=&quot;font-claude-response-body break-words whitespace-normal&quot;&gt;If you&#39;ve followed this series from the start &amp;mdash; thank you. If you&#39;re just landing here, the short version: Thomas is the returning visitor who reads everything, opens every email, converts on nothing, and one day quietly stops coming back. No warning. No signal. Just gone.&lt;/p&gt;
&lt;p class=&quot;font-claude-response-body break-words whitespace-normal&quot;&gt;Over the last four posts we traced exactly how that happens &amp;mdash; and how to stop it. The &lt;span style=&quot;color: rgb(35, 111, 161);&quot;&gt;&lt;strong&gt;&lt;a style=&quot;color: rgb(35, 111, 161);&quot; href=&quot;https://www.linkedin.com/pulse/finding-thomas-part-1-observation-post-ritu-madan-8kkwe&quot;&gt;CMS&lt;/a&gt;&lt;/strong&gt;&lt;/span&gt; watching Thomas&#39;s behavior as it unfolds. &lt;strong&gt;&lt;span style=&quot;color: rgb(35, 111, 161);&quot;&gt;&lt;a style=&quot;color: rgb(35, 111, 161);&quot; href=&quot;https://www.linkedin.com/pulse/finding-thomas-part-2-recognition-engine-ritu-madan-wq2ef&quot;&gt;ODP&lt;/a&gt;&lt;/span&gt;&lt;/strong&gt; turning that behavior into a persistent, scorable profile. &lt;strong&gt;&lt;span style=&quot;color: rgb(35, 111, 161);&quot;&gt;&lt;a style=&quot;color: rgb(35, 111, 161);&quot; href=&quot;https://www.linkedin.com/pulse/finding-thomas-part-3-moment-recognition-ritu-madan-5hvwe&quot;&gt;Personalization&lt;/a&gt;&lt;/span&gt;&lt;/strong&gt; making him feel seen for the first time. &lt;strong&gt;&lt;span style=&quot;color: rgb(35, 111, 161);&quot;&gt;&lt;a style=&quot;color: rgb(35, 111, 161);&quot; href=&quot;https://www.linkedin.com/pulse/finding-thomas-part-4-intelligence-layer-ritu-madan-tgvgc&quot;&gt;Opal&lt;/a&gt;&lt;/span&gt;&lt;/strong&gt; scaling that recognition to every Thomas in your audience without a human bottleneck.&lt;/p&gt;
&lt;p class=&quot;font-claude-response-body break-words whitespace-normal&quot;&gt;Part 5 is the final part where all four connect.&lt;/p&gt;
&lt;p class=&quot;font-claude-response-body break-words whitespace-normal&quot;&gt;Not as adjacent tools. Not as a martech stack. As a single, continuous loop &amp;mdash; one that finds Thomas while he&#39;s still Drifting, responds before he reaches At Risk, and gets smarter with every intervention it makes.&lt;/p&gt;
&lt;p class=&quot;font-claude-response-body break-words whitespace-normal&quot;&gt;It also ends where the whole series began. With Thomas. And with the question worth asking honestly of any Optimizely implementation that has been live for more than a year:&lt;/p&gt;
&lt;p class=&quot;font-claude-response-body break-words whitespace-normal&quot;&gt;Is your platform pointed at finding Thomas &amp;mdash; or is it still primarily pointed at bringing new visitors through the door?&lt;/p&gt;
&lt;p class=&quot;font-claude-response-body break-words whitespace-normal&quot;&gt;Read the full story &lt;strong&gt;&lt;span style=&quot;color: rgb(35, 111, 161);&quot;&gt;&lt;a style=&quot;color: rgb(35, 111, 161);&quot; href=&quot;https://www.linkedin.com/pulse/finding-thomas-part-5-closed-loop-ritu-madan-pmkoc/&quot;&gt;here&lt;/a&gt;&lt;/span&gt;&lt;/strong&gt;.&lt;/p&gt;</id><updated>2026-07-20T14:34:06.0000000Z</updated><summary type="html">Blog post</summary></entry> <entry><title>Extending the Optimizely Product Recommendations Feed to Include Custom Product Types</title><link href="https://wseweryn.dev/blog/2026-07-17-extending-optimizely-product-feed-with-custom-types/" /><id>A practical way to extend the Optimizely Product Recommendations catalog feed so the export scheduled job also includes custom catalog types, like bundles, that are skipped by default.</id><updated>2026-07-20T14:20:00.0000000Z</updated><summary type="html">Blog post</summary></entry> <entry><title>Parallel Development in Optimizely CMS SaaS: A Smarter Way to Push Content Models</title><link href="https://world.optimizely.com/blogs/vipin-banka--learnings--insights/dates/2026/7/parallel-development-in-optimizely-cms-saas-a-smarter-way-to-push-content-models/" /><id>&lt;p&gt;When Optimizely shipped the JavaScript SDK and CLI for CMS SaaS, they gave developers something pretty cool &amp;mdash; a code-first workflow for content modeling. Define your content types in &lt;strong&gt;.tsx&lt;/strong&gt; files, run &lt;strong&gt;npx @optimizely/cms-cli@latest push&lt;/strong&gt;, and your content models land in the CMS instance via the REST API.&lt;/p&gt;
&lt;p&gt;Clean. Fast. Developer-friendly.&lt;/p&gt;
&lt;p&gt;But here&#39;s the thing about developer tools &amp;mdash; the moment you hand them to a &lt;em&gt;team&lt;/em&gt;, the dynamics shift. And that&#39;s not a flaw in the tooling. It&#39;s just what happens when multiple people start pushing content models to the same environment at the same time.&lt;/p&gt;
&lt;p&gt;Let&#39;s talk about that.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;The Reality of Parallel Development&lt;/h2&gt;
&lt;p&gt;Picture this: you&#39;re on a feature branch, building out a &lt;strong&gt;HeroBanner&lt;/strong&gt; component. Your teammate is on a separate branch, refining the &lt;strong&gt;ArticlePage&lt;/strong&gt; schema. Both of you are pointing your CLI at the same non-production CMS instance.&lt;/p&gt;
&lt;p&gt;You run &lt;strong&gt;push&lt;/strong&gt;. The CLI reads your &lt;strong&gt;optimizely.config.mjs&lt;/strong&gt;, scans the entire &lt;strong&gt;src/components/&lt;/strong&gt; directory, and attempts to sync &lt;em&gt;everything&lt;/em&gt; it finds &amp;mdash; your changes, your teammate&#39;s changes, and every other content type definition sitting in the codebase.&lt;/p&gt;
&lt;p&gt;Now, if your local branch doesn&#39;t have your teammate&#39;s latest work (because they haven&#39;t merged yet), the CLI sees a mismatch. It flags a conflict. You get an error.&lt;/p&gt;
&lt;p&gt;This is actually &lt;em&gt;good behavior&lt;/em&gt;. The CLI is protecting you from accidentally overwriting someone else&#39;s work. Optimizely built in a &lt;strong&gt;--force&lt;/strong&gt; flag for exactly this reason &amp;mdash; when you know what you&#39;re doing and want to push regardless, you can.&lt;/p&gt;
&lt;p&gt;But here&#39;s where the mental model needs to shift.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;The Force Flag Solves the Wrong Problem (For This Scenario)&lt;/h2&gt;
&lt;p&gt;Let&#39;s be clear &amp;mdash; the &lt;strong&gt;--force&lt;/strong&gt; option is a legitimate and useful tool. It exists because there are real scenarios where you need to override what&#39;s on the remote instance. Schema migrations, breaking changes, cleanup operations &amp;mdash; force is the right call.&lt;/p&gt;
&lt;p&gt;But in a parallel development workflow, the question isn&#39;t &lt;em&gt;&quot;how do I push harder?&quot;&lt;/em&gt; &amp;mdash; it&#39;s &lt;em&gt;&quot;why am I pushing things I didn&#39;t change?&quot;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Think about it. If you only modified &lt;strong&gt;HeroBanner.tsx&lt;/strong&gt;, why should the CLI even &lt;em&gt;look&lt;/em&gt; at &lt;strong&gt;ArticlePage.tsx&lt;/strong&gt;? Why should it care about 47 other component files that you never touched?&lt;/p&gt;
&lt;p&gt;The problem isn&#39;t conflict resolution. The problem is &lt;strong&gt;scope&lt;/strong&gt;.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;A Different Way to Think About It&lt;/h2&gt;
&lt;p&gt;Here&#39;s the mental shift: instead of treating the CLI push as a &lt;em&gt;full sync&lt;/em&gt; operation, treat it as a &lt;em&gt;deployment of your changes&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;This is how we already think about code deployments. Your CI/CD pipeline doesn&#39;t redeploy every microservice when you change one. Your database migration tool doesn&#39;t re-run every migration from the beginning. You deploy &lt;em&gt;what changed&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;Content model pushes should work the same way.&lt;/p&gt;
&lt;p&gt;The good news? The CLI already supports this. The &lt;strong&gt;optimizely.config.mjs&lt;/strong&gt; file controls &lt;em&gt;which components the CLI processes&lt;/em&gt;. If you narrow the &lt;strong&gt;components&lt;/strong&gt; array to only the files you&#39;ve changed, the CLI will only push those content types. No conflicts with your teammate&#39;s work. No force flags needed. No wasted API calls.&lt;/p&gt;
&lt;p&gt;The trick is doing this &lt;em&gt;dynamically&lt;/em&gt; &amp;mdash; based on your actual Git branch changes.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Git as Your Scope Engine&lt;/h2&gt;
&lt;p&gt;Your version control system already knows exactly what you changed. A simple &lt;strong&gt;git diff&lt;/strong&gt; against your base branch gives you the precise list of modified &lt;strong&gt;.tsx&lt;/strong&gt; files. From there, it&#39;s straightforward to generate a temporary config file, push only those components, and clean up.&lt;/p&gt;
&lt;p&gt;Here&#39;s a Node.js script that handles the entire flow:&lt;/p&gt;
&lt;pre class=&quot;code-block&quot;&gt;&lt;code class=&quot;language-js&quot;&gt;// push-branch-changes.mjs
import { execSync } from &#39;child_process&#39;;
import fs from &#39;fs&#39;;
import path from &#39;path&#39;;

const baseBranch = process.argv[2] || &#39;origin/main&#39;;
const tempConfig = &#39;optimizely.config.temp.mjs&#39;;

try {
  console.log(`Checking changes against ${baseBranch}...`);

  const gitOutput = execSync(
    `git diff ${baseBranch} --name-only -- &quot;src/components/**/*.tsx&quot;`,
    { encoding: &#39;utf8&#39; }
  );

  const changedFiles = gitOutput
    .split(&#39;\n&#39;)
    .map(f =&amp;gt; f.trim())
    .filter(f =&amp;gt; f.length &amp;gt; 0 &amp;amp;&amp;amp; fs.existsSync(f));

  if (changedFiles.length === 0) {
    console.log(&#39;No component changes on this branch. Nothing to push.&#39;);
    process.exit(0);
  }

  console.log(`\nPushing ${changedFiles.length} changed component(s):`);
  changedFiles.forEach(f =&amp;gt; console.log(`  - ${f}`));

  // Generate a temporary config scoped to changed files only
  const paths = changedFiles.map(f =&amp;gt; `&#39;./${f}&#39;`).join(&#39;,\n    &#39;);
  const config = `import { buildConfig } from &#39;@optimizely/cms-sdk&#39;;

export default buildConfig({
  components: [
    ${paths}
  ],
});
`;

  fs.writeFileSync(path.resolve(tempConfig), config, &#39;utf8&#39;);

  // Push using the scoped config
  execSync(`npx @optimizely/cms-cli@latest push --config ${tempConfig}`, {
    stdio: &#39;inherit&#39;,
  });

  // Cleanup
  fs.unlinkSync(tempConfig);
  console.log(&#39;Done. Only your branch changes were pushed.&#39;);

} catch (err) {
  console.error(&#39;Push failed:&#39;, err.message);
  if (fs.existsSync(tempConfig)) fs.unlinkSync(tempConfig);
  process.exit(1);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Wire it up in your &lt;strong&gt;package.json&lt;/strong&gt;:&lt;/p&gt;
&lt;pre class=&quot;code-block&quot;&gt;&lt;code class=&quot;language-json&quot;&gt;&quot;scripts&quot;: {
  &quot;cms:push-branch&quot;: &quot;node push-branch-changes.mjs&quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And run it:&lt;/p&gt;
&lt;pre class=&quot;code-block&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;# Compare against origin/main (default)
npm run cms:push-branch

# Or compare against a different base
npm run cms:push-branch origin/develop&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That&#39;s it. The script diffs your branch, generates a scoped config, pushes only your changes, and cleans up after itself.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;What This Actually Gets You&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Speed.&lt;/strong&gt; Instead of the CLI parsing and validating every content type in your project, it processes only the handful you touched. On larger codebases with dozens of content types, the difference is noticeable.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Safety.&lt;/strong&gt; You literally cannot overwrite a teammate&#39;s content type because the CLI never sees it. There&#39;s no conflict to resolve, no force flag to debate, no Slack message asking &lt;em&gt;&quot;did someone just push over my changes?&quot;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Focus.&lt;/strong&gt; Your push operation maps 1:1 to your pull request. What you changed in code is what gets pushed to the CMS. Nothing more, nothing less. That&#39;s easier to review, easier to debug, and easier to roll back.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Where This Fits in a Bigger Picture&lt;/h2&gt;
&lt;p&gt;This approach pairs well with a structured environment strategy. Consider this setup:&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; border-width: 1px; border-style: solid;&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;padding: 10px; text-align: left; vertical-align: top; border-width: 1px; border-style: solid;&quot;&gt;
&lt;p&gt;&lt;strong&gt;Environment&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;padding: 10px; text-align: left; vertical-align: top; border-width: 1px; border-style: solid;&quot;&gt;
&lt;p&gt;&lt;strong&gt;Who Pushes&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;padding: 10px; text-align: left; vertical-align: top; border-width: 1px; border-style: solid;&quot;&gt;
&lt;p&gt;&lt;strong&gt;How&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;padding: 10px; text-align: left; vertical-align: top; border-width: 1px; border-style: solid;&quot;&gt;
&lt;p&gt;Developer instances&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;padding: 10px; text-align: left; vertical-align: top; border-width: 1px; border-style: solid;&quot;&gt;
&lt;p&gt;Individual devs&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;padding: 10px; text-align: left; vertical-align: top; border-width: 1px; border-style: solid;&quot;&gt;
&lt;p&gt;Branch-scoped push (this script)&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;padding: 10px; text-align: left; vertical-align: top; border-width: 1px; border-style: solid;&quot;&gt;
&lt;p&gt;Shared QA/Staging&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;padding: 10px; text-align: left; vertical-align: top; border-width: 1px; border-style: solid;&quot;&gt;
&lt;p&gt;CI/CD pipeline&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;padding: 10px; text-align: left; vertical-align: top; border-width: 1px; border-style: solid;&quot;&gt;
&lt;p&gt;Full push on merge to main&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;padding: 10px; text-align: left; vertical-align: top; border-width: 1px; border-style: solid;&quot;&gt;
&lt;p&gt;Production&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;padding: 10px; text-align: left; vertical-align: top; border-width: 1px; border-style: solid;&quot;&gt;
&lt;p&gt;CI/CD pipeline&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;padding: 10px; text-align: left; vertical-align: top; border-width: 1px; border-style: solid;&quot;&gt;
&lt;p&gt;Full push on release&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Developers get fast, isolated feedback loops on their own instances. The shared environments only receive content models that have been reviewed and merged. Production stays locked down behind your release process.&lt;/p&gt;
&lt;p&gt;The Optimizely CLI&#39;s full push behavior is &lt;em&gt;exactly right&lt;/em&gt; for CI/CD &amp;mdash; you want a complete sync when deploying from a merged, canonical branch. The branch-scoped approach fills the gap for the development phase, where speed and isolation matter more than completeness.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Wrapping Up&lt;/h2&gt;
&lt;p&gt;The Optimizely CMS SaaS CLI is a solid tool. The force option exists for a reason and handles real conflicts well. But parallel development introduces a different kind of challenge &amp;mdash; not &lt;em&gt;&quot;how do I resolve conflicts&quot;&lt;/em&gt; but &lt;em&gt;&quot;how do I avoid creating them in the first place?&quot;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;By scoping your pushes to only the content types you&#39;ve actually changed, you sidestep the problem entirely. Your Git branch becomes the source of truth for what gets pushed, and everyone on the team stays out of each other&#39;s way.&lt;/p&gt;
&lt;p&gt;It&#39;s a small script with a big impact on your daily workflow. Give it a try on your next feature branch.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;Have you tried a similar approach or found other ways to streamline parallel development with Optimizely CMS SaaS? We&#39;d love to hear what&#39;s working for your team.&lt;/em&gt;&lt;/p&gt;</id><updated>2026-07-20T09:04:26.0000000Z</updated><summary type="html">Blog post</summary></entry> <entry><title>Fixing index_not_found_exception After Purging External Data in Optimizely Graph</title><link href="https://world.optimizely.com/blogs/akash/dates/2026/7/index-external-data-with-optimizely-graph/" /><id>&lt;h3&gt;The Scenario: Indexing External Data&lt;/h3&gt;
&lt;p&gt;When working with Optimizely Content Graph, indexing external data is a straightforward process. &lt;a href=&quot;https://docs.developers.optimizely.com/platform-optimizely/docs/sync-content-data&quot;&gt;Synchronize custom data sources with Optimizely Graph&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Followed by setting up a GraphQL schema to push an external content type called &lt;strong&gt;Article &lt;/strong&gt;into a specific source src1&lt;/p&gt;
&lt;p&gt;Here is the initial schema definition using the V3 API:&lt;/p&gt;
&lt;p&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;/p&gt;
&lt;div class=&quot;code-block ng-tns-c2171817996-27 ng-animate-disabled ng-trigger ng-trigger-codeBlockRevealAnimation&quot;&gt;&lt;!----&gt;
&lt;div class=&quot;formatted-code-block-internal-container ng-tns-c2171817996-27&quot;&gt;
&lt;div class=&quot;animated-opacity ng-tns-c2171817996-27&quot;&gt;&lt;!----&gt;
&lt;pre class=&quot;ng-tns-c2171817996-27&quot;&gt;&lt;code class=&quot;code-container formatted ng-tns-c2171817996-27&quot;&gt;curl --location --request PUT &lt;span class=&quot;hljs-string&quot;&gt;&#39;https://cg.optimizely.com/api/content/v3/types?id=src1&#39;&lt;/span&gt; \
--header &lt;span class=&quot;hljs-string&quot;&gt;&#39;Content-Type: application/json&#39;&lt;/span&gt; \
--header &lt;span class=&quot;hljs-string&quot;&gt;&#39;Authorization: Basic BASE64_ENCODED_CREDENTIALS&#39;&lt;/span&gt; \
--data &lt;span class=&quot;hljs-string&quot;&gt;&#39;{  
  &quot;languages&quot;: [&quot;en-US&quot;],
  &quot;contentTypes&quot;:{
   &quot;Article&quot;: {
     &quot;contentType&quot;: [],
     &quot;properties&quot;: {
       &quot;Id&quot;: { &quot;type&quot;: &quot;Int&quot; },
       &quot;Keywords&quot;: { &quot;type&quot;: &quot;String&quot;, &quot;searchable&quot;: true },
       &quot;Language&quot;: { &quot;type&quot;: &quot;String&quot; },
       &quot;Comment&quot;: { &quot;type&quot;: &quot;String&quot; },
       &quot;Body&quot;: { &quot;type&quot;: &quot;String&quot;, &quot;searchable&quot;: true },
       &quot;Summary&quot;: { &quot;type&quot;: &quot;String&quot;, &quot;searchable&quot;: true },
       &quot;CreatedTime&quot;: { &quot;type&quot;: &quot;Date&quot; }      
     }
   }
 } 
}&#39;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;!----&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;/p&gt;
&lt;p&gt;After registering the schema, I successfully synced the content using Post request via a scheduled job. The data was indexed and queryable.&lt;code&gt;POST /api/content/v2/data?id=src1&lt;/code&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3&gt;The Problem: A Silent Failure Post-Purge&lt;/h3&gt;
&lt;p&gt;To clean up the index and start fresh, I executed a purge operation using the API: &lt;code&gt;DELETE /api/content/v2/data?id=src1&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;While the purge succeeded and cleared the results, a major issue occurred when my scheduled job tried to sync new data. The Post request returned a successful response with a journalId, but queries still returned zero items.&lt;/p&gt;
&lt;p&gt;Upon checking the journal stream details in Postman I found the culprit: a failed status throwing an &lt;strong&gt;index_not_found_exception&lt;/strong&gt;&amp;nbsp;&lt;code&gt;https://cg.optimizely.com/journal/stream/journalId&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/12ce3b2d0ae34c59b8981d2f360438db.aspx&quot; alt=&quot;&quot; width=&quot;1065&quot; height=&quot;294&quot; /&gt;&lt;/p&gt;
&lt;h3&gt;The Root Cause: Management Layer vs. Storage Layer&lt;/h3&gt;
&lt;p&gt;Why did the API return a success response if the index didn&#39;t exist? The answer lies in how Optimizely Content Graph separates data configuration:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;The Management Layer (Schema Registry):&lt;/strong&gt; When you check for your schema, the API looks at its registry. The blueprint for &lt;strong&gt;Article&lt;/strong&gt; is still there, so the ingestion gateway assumes everything is fine and accepts your data stream (giving you a success response and a journalId).&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;The Storage Layer (Elasticsearch Cluster):&lt;/strong&gt; This is where the physical data buckets live. When you performed the purge, it completely wiped the physical storage bucket to free up cloud resources.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;When the background worker attempts to write your new data to the physical storage layer, it panics and throws the &lt;strong&gt;index_not_found_exception &lt;/strong&gt;because the actual bucket no longer exists.&lt;/p&gt;
&lt;p&gt;According to the Optimizely development team, the legacy DELETE /api/content/v2/data and the newer DELETE /api/content/v3/sources?mode=data endpoints delete &lt;em&gt;all&lt;/em&gt; data, &lt;strong&gt;including the underlying indices&lt;/strong&gt;. Because this is an &lt;strong&gt;&lt;em&gt;external content type&lt;/em&gt;&lt;/strong&gt;, the system doesn&#39;t automatically rebuild the physical container upon receiving new data.&lt;/p&gt;
&lt;h3&gt;The Current Solution&lt;/h3&gt;
&lt;p&gt;If you expect a purge to solely remove records while keeping the underlying physical container intact, you will run into this error. As the purge inherently drops the Elasticsearch index, you must manually recreate it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;To resolve this, you must re-push your schema before indexing new documents.&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Purge the data:&lt;/strong&gt; &lt;code&gt;DELETE /api/content/v2/data?id=src1&lt;/code&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Recreate the Schema:&lt;/strong&gt; Resend your original PUT request to&amp;nbsp;o rebuild the physical storage index. &lt;code&gt;PUT /api/content/v3/types?id=src1&lt;/code&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Sync the Content:&lt;/strong&gt; Execute your Post request to index the new documents.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;By pushing the schema again after every purge, you ensure the underlying Elasticsearch container is recreated and ready to accept your external content data streams.&lt;/p&gt;
&lt;h3&gt;Support the Feature Request&lt;/h3&gt;
&lt;p&gt;While the workaround above successfully resolves the issue, the ideal behavior would be for the purge operation to delete only the content records, leaving the underlying physical container and schema intact so background syncs don&#39;t fail.&lt;/p&gt;
&lt;p&gt;I have submitted a feedback request to the Optimizely support to address this behavior. If you have run into this same issue, please upvote and support the request here:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;!----&gt;&lt;a class=&quot;ng-star-inserted&quot; href=&quot;https://feedback.optimizely.com/forums/964785-cms-saas-content-management-system/suggestions/51482671-sync-external-content-data-purge-deletes-content-a&quot;&gt;Vote here: Sync External Content Data - Purge Deletes Content and Schema&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;</id><updated>2026-07-16T08:20:17.0000000Z</updated><summary type="html">Blog post</summary></entry> <entry><title>Finding Thomas Part 4 - The Intelligence Layer</title><link href="https://world.optimizely.com/blogs/ritu-madan/dates/2026/7/finding-thomas-part-4---the-intelligence-layer/" /><id>&lt;p class=&quot;font-claude-response-body break-words whitespace-normal&quot;&gt;I&#39;ve been finding Thomas for a couple weeks now. Bear with me &amp;mdash; we&#39;re almost at the full picture.&lt;/p&gt;
&lt;p class=&quot;font-claude-response-body break-words whitespace-normal&quot;&gt;Quick catch-up : Thomas is the returning visitor who reads everything, opens every email, converts on nothing &amp;mdash; and one day quietly stops coming back. No warning. No signal. Just gone.&lt;/p&gt;
&lt;p class=&quot;font-claude-response-body break-words whitespace-normal&quot;&gt;In Parts 1 through 3 we built the foundation &amp;mdash; the &lt;a href=&quot;https://www.linkedin.com/pulse/finding-thomas-part-1-observation-post-ritu-madan-8kkwe/&quot;&gt;&lt;strong&gt;CMS&lt;/strong&gt;&lt;/a&gt; watching him, &lt;a href=&quot;https://www.linkedin.com/pulse/finding-thomas-part-2-recognition-engine-ritu-madan-wq2ef/&quot;&gt;&lt;strong&gt;ODP&lt;/strong&gt;&lt;/a&gt; profiling him, &lt;a href=&quot;https://www.linkedin.com/pulse/finding-thomas-part-3-moment-recognition-ritu-madan-5hvwe/&quot;&gt;&lt;strong&gt;Personalization&lt;/strong&gt;&lt;/a&gt; making him feel seen for the first time.&lt;/p&gt;
&lt;p class=&quot;font-claude-response-body break-words whitespace-normal&quot;&gt;Part 4 is where it gets interesting.&lt;/p&gt;
&lt;p class=&quot;font-claude-response-body break-words whitespace-normal&quot;&gt;Because everything we&#39;ve built so far has a ceiling. Someone still has to hypothesize the Thomas profile, build the segment manually, monitor the scores, and make judgment calls. At the scale of a real content ecosystem, that human dependency is the bottleneck.&lt;/p&gt;
&lt;p class=&quot;font-claude-response-body break-words whitespace-normal&quot;&gt;Opal removes the ceiling.&lt;/p&gt;
&lt;p class=&quot;font-claude-response-body break-words whitespace-normal&quot;&gt;It doesn&#39;t guess what Thomas looks like. It learns what Thomas looks like &amp;mdash; from the behavior of every visitor who eventually stopped coming back. Then it scales that recognition to thousands of Thomas-profile users without manual intervention.&lt;/p&gt;
&lt;p class=&quot;font-claude-response-body break-words whitespace-normal&quot;&gt;There&#39;s also a productive paradox at the heart of this one: the answer to AI displacing your site traffic is AI deployed inside your own ecosystem. Part 4 makes that case. Read it &lt;span style=&quot;color: rgb(35, 111, 161);&quot;&gt;&lt;strong&gt;&lt;a style=&quot;color: rgb(35, 111, 161);&quot; href=&quot;https://www.linkedin.com/pulse/finding-thomas-part-4-intelligence-layer-ritu-madan-tgvgc&quot;&gt;here&lt;/a&gt;&lt;/strong&gt;&lt;/span&gt;.&lt;/p&gt;</id><updated>2026-07-14T19:01:56.0000000Z</updated><summary type="html">Blog post</summary></entry> <entry><title>The Silent Success: When Your Optimizely SaaS CMS Config Push Succeeds with &quot;0&quot; Changes</title><link href="https://world.optimizely.com/blogs/vipin-banka--learnings--insights/dates/2026/7/the-silent-success-why-your-optimizely-saas-cms-config-pushes-succeed-while-doing-absolutely-nothing/" /><id>&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;Picture this frustratingly common scenario in headless, code-first development with Optimizely SaaS CMS:&lt;/p&gt;
&lt;p&gt;You&amp;rsquo;ve defined a brilliant new element, block, or page type in your React codebase. You trigger your local push command. The console works its magic, spinning up green indicators and a clean finish:&lt;/p&gt;
&lt;pre class=&quot;code-block&quot;&gt;&lt;code&gt;&#128228; Pushing content types...
✓ Configuration file uploaded
✅ Push succeeded.&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;But when you open the Optimizely CMS UI, navigate to &lt;strong&gt;Settings &amp;gt; Content Types&lt;/strong&gt;, and look for your new type... there is absolutely, crushing nothingness.&lt;/p&gt;
&lt;p&gt;You immediately jump into emergency triage mode. You double-check your &lt;strong&gt;.env&lt;/strong&gt; keys. You check permissions. Everything is correct. So why did the CLI say &quot;Success&quot; while doing absolutely nothing?&lt;/p&gt;
&lt;p&gt;This is the &lt;strong&gt;CLI Silent Empty Push&lt;/strong&gt; &amp;mdash; and to debug it, you have to work through a specific hierarchy of checks. Let&#39;s trace the journey from the most obvious suspects down to the real, silent culprit.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;The &quot;Everything Looks Correct&quot; Checklist&lt;/h2&gt;
&lt;p&gt;Before tear-downs and code reviews, developers usually start with the obvious infrastructure questions. Here is the checklist of things you likely already verified:&lt;/p&gt;
&lt;h3&gt;&#128269; Check 1: Client ID &amp;amp; Client Secret&lt;/h3&gt;
&lt;ul class=&quot;tight&quot;&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;The Question:&lt;/strong&gt; Are your credentials expired or invalid?&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;How to verify:&lt;/strong&gt; Run &lt;strong&gt;npx @optimizely/cms-cli@latest login&lt;/strong&gt;. If the connection is broken, the CLI will throw an authentication error immediately. If it says &quot;Authentication complete,&quot; your credentials are functional.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;&#128269; Check 2: API Permissions &amp;amp; Impersonation Scopes&lt;/h3&gt;
&lt;ul class=&quot;tight&quot;&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;The Question:&lt;/strong&gt; Does your API Key have the rights to actually write schemas?&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;How to verify:&lt;/strong&gt; Check &lt;strong&gt;Settings &amp;gt; API Keys&lt;/strong&gt; in the CMS UI. Ensure the key is active and possesses write scopes. If your key lacks permissions, a push would trigger a stark &lt;strong&gt;403 Forbidden&lt;/strong&gt; or &lt;strong&gt;401 Unauthorized&lt;/strong&gt; error in your terminal.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;&#128269; Check 3: The Target Environment URL&lt;/h3&gt;
&lt;ul class=&quot;tight&quot;&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;The Question:&lt;/strong&gt; Are you accidentally pushing to the wrong sandbox or staging instance?&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;How to verify:&lt;/strong&gt; Inspect your &lt;strong&gt;OPTIMIZELY_CMS_API_URL&lt;/strong&gt; environment variable. If you pushed to a different instance, the schemas would exist, just on the wrong server.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h3&gt;If all of those are green, why is the CMS still empty?&lt;/h3&gt;
&lt;p&gt;If credentials, permissions, and host URLs are completely correct, and the terminal prints a successful &lt;strong&gt;200 OK&lt;/strong&gt;, you have officially bypassed standard configuration errors.&lt;/p&gt;
&lt;p&gt;The issue isn&#39;t security or network-related. It is a silent, 12-character path resolution discrepancy.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;What the Official Documentation Says&lt;/h2&gt;
&lt;p&gt;The official Optimizely &lt;a href=&quot;https://docs.developers.optimizely.com/content-management-system/v1.0.0-CMS-SaaS/docs/configure-javascript-sdk&quot;&gt;&lt;em&gt;Configure JavaScript SDK&lt;/em&gt;&lt;/a&gt; guide is explicit about where the configuration file should live. Step 4 reads:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;*&quot;Create an &lt;strong&gt;optimizely.config.mjs&lt;/strong&gt; file in the &lt;strong&gt;root of your project&lt;/strong&gt;.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The official project structure diagram reinforces this:&lt;/p&gt;
&lt;pre class=&quot;code-block&quot;&gt;&lt;code&gt;.
├── src/
│   └── components/
├── .env
├── optimizely.config.mjs   &amp;larr; root level, explicitly
└── ...&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The documentation does not cover what happens when you move this file into a subdirectory &amp;mdash; &lt;strong&gt;because it assumes you won&#39;t.&lt;/strong&gt; Developers following natural project organisation instincts &amp;mdash; a clean root, a monorepo structure, a dedicated &lt;strong&gt;config/&lt;/strong&gt; folder for multiple environment files &amp;mdash; deviate from this guidance without realising there is a hidden consequence.&lt;/p&gt;
&lt;p&gt;The CLI works perfectly when the file is at the root. The moment it moves into a subdirectory, globs silently resolve against the wrong base path, and the push uploads nothing.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;The Real Culprit: The Glob Resolution Trap&lt;/h2&gt;
&lt;p&gt;In clean, modern JavaScript monorepos or well-structured project hierarchies, developers rarely leave configuration files floating in the root directory. To keep things tidy, we often organize configurations into subdirectories like &lt;strong&gt;.optimizely/&lt;/strong&gt; or &lt;strong&gt;config/&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;For instance, you might move your CMS configuration to:&lt;/p&gt;
&lt;pre&gt;&lt;strong&gt;config/optimizely.config.mjs&lt;/strong&gt;&lt;/pre&gt;
&lt;p&gt;And inside that nested configuration file, you write your relative component search paths:&lt;/p&gt;
&lt;pre class=&quot;code-block&quot;&gt;&lt;code class=&quot;language-javascript&quot;&gt;import { buildConfig } from &#39;@optimizely/cms-sdk&#39;;

export default buildConfig({
  components: [&#39;./src/content-types/**/*.ts&#39;],
});&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is where the silent trap springs. Here is the golden rule of the Optimizely CMS CLI:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;The CLI resolves glob patterns inside a configuration file relative to the location of the configuration file itself, not relative to the root directory where you ran the CLI command.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Because the configuration file lives inside the &lt;strong&gt;config/&lt;/strong&gt; subdirectory, the CLI reads &lt;strong&gt;./src/content-types/...&lt;/strong&gt; as relative to &lt;strong&gt;config/&lt;/strong&gt;. It starts searching for your schemas here:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;config/src/content-types/**/*.ts&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;That subdirectory path does not exist. The CLI scans the folder, matches &lt;strong&gt;0 files&lt;/strong&gt;, and finds exactly &lt;strong&gt;0 schemas&lt;/strong&gt; to process.&lt;/p&gt;
&lt;h3&gt;Why did the CLI report success?&lt;/h3&gt;
&lt;p&gt;The Optimizely CLI is highly permissive. If a glob pattern matches zero files, it does not throw an error or fail the build. It assumes you just have a clean configuration slate, packs up an empty schema payload, and uploads it.&lt;/p&gt;
&lt;p&gt;The CMS API receives a valid, perfectly formed (but empty) configuration payload, updates nothing, and returns an &lt;strong&gt;HTTP 200 OK&lt;/strong&gt;. The CLI reports &lt;strong&gt;Configuration file uploaded&lt;/strong&gt; because the upload itself &lt;em&gt;did&lt;/em&gt; succeed.&lt;/p&gt;
&lt;p&gt;The result: a completely silent, completely successful push of nothing.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;The Fix&lt;/h2&gt;
&lt;p&gt;The simplest fix is to follow the official documentation &amp;mdash; keep your configuration file at the &lt;strong&gt;project root&lt;/strong&gt; where the CLI expects it:&lt;/p&gt;
&lt;pre class=&quot;code-block&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;# ❌ Nested (globs resolve relative to config/ &amp;mdash; finds nothing)
npx @optimizely/cms-cli config push config/optimizely.config.mjs

# ✅ Root level (globs resolve relative to root &amp;mdash; finds your files)
npx @optimizely/cms-cli config push optimizely.config.mjs&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you have a strong reason to keep the config file inside a subdirectory &amp;mdash; such as a monorepo setup or managing multiple environment configurations &amp;mdash; you can adjust the glob paths inside the config to step up one level:&lt;/p&gt;
&lt;pre class=&quot;code-block&quot;&gt;&lt;code class=&quot;language-javascript&quot;&gt;import { buildConfig } from &#39;@optimizely/cms-sdk&#39;;

export default buildConfig({
  // ../  steps up from config/ back to the project root
  components: [&#39;../src/content-types/**/*.ts&#39;],
});&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Both approaches work. The root-level approach aligns with official documentation and is the least error-prone for single-app projects. The &lt;strong&gt;../&lt;/strong&gt; prefix approach is useful for monorepos or multi-environment setups where nesting is intentional.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Reusable Debugging Checklist for Teams&lt;/h2&gt;
&lt;p&gt;Bookmark this order-of-operations checklist for the next time a developer on your team runs into missing content types after a successful-looking push:&lt;/p&gt;
&lt;ol class=&quot;tight&quot;&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;CLI Authentication Test&lt;/strong&gt; &amp;mdash; Run &lt;strong&gt;npx @optimizely/cms-cli@latest login&lt;/strong&gt; to rule out expired credentials.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;API Key Scope Verification&lt;/strong&gt; &amp;mdash; Confirm your key has write access under &lt;strong&gt;Settings &amp;gt; API Keys&lt;/strong&gt; in the CMS.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Environment URL Check&lt;/strong&gt; &amp;mdash; Verify &lt;strong&gt;OPTIMIZELY_CMS_API_URL&lt;/strong&gt; points to the instance you are currently viewing.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Config File Location Audit&lt;/strong&gt; &amp;mdash; Is your &lt;strong&gt;optimizely.config.mjs&lt;/strong&gt; (or any dynamically generated config) in the project root? If it is nested in a subdirectory, check whether the glob paths inside use &lt;strong&gt;../&lt;/strong&gt; to compensate.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Glob Match Validation&lt;/strong&gt; &amp;mdash; Test whether your glob patterns actually match any files before pushing:&lt;/p&gt;
&lt;pre class=&quot;code-block&quot;&gt;&lt;code class=&quot;language-typescript&quot;&gt;import { globSync } from &#39;fast-glob&#39;;
const matched = globSync(&#39;./src/content-types/**/*.ts&#39;);
if (matched.length === 0) {
  console.error(&#39;❌ Zero files matched. Config push will be empty.&#39;);
}&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Visual Builder Composition&lt;/strong&gt; &amp;mdash; If your type appears in &lt;strong&gt;Settings &amp;gt; Content Types&lt;/strong&gt; but not in the Visual Builder editor, go to that type&#39;s &lt;strong&gt;Settings&lt;/strong&gt; tab and enable &lt;strong&gt;&quot;Available for composition in Visual Builder&quot;&lt;/strong&gt;.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h2&gt;Key Takeaway&lt;/h2&gt;
&lt;p&gt;The Optimizely developer documentation is clear: &lt;strong&gt;optimizely.config.mjs&lt;/strong&gt; belongs at the root of your project. When it lives there, everything works exactly as expected. When it moves &amp;mdash; even for completely legitimate reasons &amp;mdash; the CLI&#39;s glob resolution silently breaks, the push uploads an empty payload, and the CMS returns a perfectly polite &lt;strong&gt;200 OK&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The fix is simple. The behaviour, however, is not documented &amp;mdash; which is precisely what makes it one of the most confusing silent failures in a SaaS CMS headless setup.&lt;/p&gt;
&lt;p&gt;One line change. Types appear instantly.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;Have you run into other silent failure modes when working with Optimizely SaaS CMS content type workflows? Share your experience in the comments &amp;mdash; this kind of institutional knowledge keeps teams shipping faster.&lt;/em&gt;&lt;/p&gt;</id><updated>2026-07-13T07:32:17.0000000Z</updated><summary type="html">Blog post</summary></entry> <entry><title>Architecting an Enterprise-Grade Development Pipeline in Optimizely SaaS CMS</title><link href="https://world.optimizely.com/blogs/vipin-banka--learnings--insights/dates/2026/7/architecting-an-enterprise-grade-development-pipeline-in-optimizely-saas-cms/" /><id>&lt;p&gt;Most enterprise teams show up to Optimizely SaaS CMS with a clear roadmap for their release pipeline: DEV &amp;rarr; QA &amp;rarr; Stage &amp;rarr; Prod. Four logical environments to manage active coding, quality assurance, business sign-off, and live operations.&lt;/p&gt;
&lt;p&gt;When setting up your initial cloud-native topology, the question immediately arises: how do we align this 4-stage pipeline with our SaaS CMS instances in a way that is automated, secure, and team-safe?&lt;/p&gt;
&lt;p&gt;This is a practical guide for every dev lead, architect, and project manager looking to design a high-velocity, team-safe development workflow. We&#39;ve mapped out the key architectural strategies, branching patterns, and deployment automation tactics to make your Optimizely SaaS CMS pipeline production-ready.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;First, Let&#39;s Talk About What &quot;Separate Applications&quot; Can and Can&#39;t Do&lt;/h2&gt;
&lt;p&gt;Before we dive into environment mapping, it&#39;s critical to understand how Optimizely SaaS CMS handles resource isolation: &lt;strong&gt;applications and environments are not the same thing.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Within a single CMS SaaS instance, you can create multiple &lt;strong&gt;Applications&lt;/strong&gt; &amp;mdash; each with its own:&lt;/p&gt;
&lt;ul class=&quot;tight&quot;&gt;
&lt;li&gt;
&lt;p&gt;Content tree and start page hierarchy&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Preview URLs and hostname configuration&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Routing and application root settings&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is incredibly powerful for multi-site and multi-brand strategies. But here is the architectural boundary: &lt;strong&gt;content types are scoped to the instance, not the application.&lt;/strong&gt; When a developer pushes a new content type or modifies an existing schema, that change is immediately live across every application on that instance. There is no branching, draft mode, or per-application isolation for schemas.&lt;/p&gt;
&lt;p&gt;Because schemas are instance-wide, a shared development or testing instance requires a deliberate promotion strategy to ensure that active development doesn&#39;t disrupt running QA or staging cycles.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Analyzing the Topologies: Pros and Cons of Environment Sharing&lt;/h2&gt;
&lt;p&gt;If your initial setup is configured with three physical SaaS instances, achieving a 4-stage pipeline requires pairing two of your logical phases onto a single instance. There are three primary ways to design this topology, each with distinct architectural consequences.&lt;/p&gt;
&lt;hr /&gt;
&lt;h3&gt;Topology 1: Shared DEV &amp;amp; QA (The Schema Bottleneck Pattern)&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Overall Assessment: Not recommended for parallel development teams&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;In this pattern, the first instance is shared by developers and QA testers, while Staging and Production remain fully isolated.&lt;/p&gt;
&lt;ul class=&quot;tight&quot;&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;The Problem:&lt;/strong&gt; Developers iterate constantly. Every new feature requires adding content types, modifying properties, or updating validation rules. In a shared setup, these schema changes land on the instance immediately.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;The Impact on QA:&lt;/strong&gt; If a developer pushes an unverified or breaking schema change mid-sprint, it can instantly destabilize the testing environment, blocking QA automation runs and invalidating manual test cycles.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Verdict:&lt;/strong&gt; While workable for small, highly coordinated teams (1-2 developers), this pattern quickly becomes a bottleneck for larger teams working on parallel streams.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h3&gt;Topology 2: DEV | Shared QA &amp;amp; Stage (The Recommended Pattern)&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Overall Assessment: The gold standard for velocity and stability&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;This pattern keeps your DEV environment fully isolated, while QA testing and User Acceptance Testing (UAT/Staging) share the middle instance.&lt;/p&gt;
&lt;ul class=&quot;tight&quot;&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Why it works:&lt;/strong&gt; Developers have complete freedom to experiment, iterate, and break things on the DEV instance. Schemas are promoted to the QA+Stage instance only through a controlled, automated deployment gate.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Managing the Share:&lt;/strong&gt; Because QA and UAT/Staging are typically &lt;strong&gt;sequential, not simultaneous&lt;/strong&gt;, they rarely require different schema versions at the same time. You complete functional QA, sign off on the release branch, and then hand over to business stakeholders for UAT on the same schema.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;How Separate Applications Help:&lt;/strong&gt; By creating two separate &lt;strong&gt;Applications&lt;/strong&gt; within this single instance, you cleanly isolate your content and preview layers:&lt;/p&gt;
&lt;ul class=&quot;tight&quot;&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;QA Application:&lt;/strong&gt; Points to your QA frontend deployment, using QA-specific content and automated test trees.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;UAT Application:&lt;/strong&gt; Points to your Staging/UAT frontend deployment, showcasing pristine demo content for business stakeholders.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Verdict:&lt;/strong&gt; This is the most robust topology for a 3-instance setup. It completely protects developer velocity and ensures production-level safety.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h3&gt;Topology 3: DEV | QA | Shared Stage &amp;amp; Production (The High-Risk Pattern)&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Overall Assessment: Avoid entirely&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;In this pattern, DEV and QA are fully isolated, but business sign-off (Staging/UAT) happens on the production instance.&lt;/p&gt;
&lt;ul class=&quot;tight&quot;&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;The Danger:&lt;/strong&gt; Even with separate CMS applications isolating your live content tree from your draft UAT tree, &lt;strong&gt;schemas are still instance-wide&lt;/strong&gt;.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;The Impact:&lt;/strong&gt; Any content type modification or configuration change made during UAT is immediately live on production. A single configuration oversight during a stakeholder review session can trigger an immediate production incident.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Verdict:&lt;/strong&gt; Production stability is non-negotiable. This topology should never be used.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;The Pipeline Comparison Matrix&lt;/h2&gt;
&lt;table&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border-style: solid; border-width: 1px;&quot;&gt;
&lt;p&gt;Pipeline Strategy&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-style: solid; border-width: 1px;&quot;&gt;
&lt;p&gt;DEV+QA Shared / Stage / Prod&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-style: solid; border-width: 1px;&quot;&gt;
&lt;p&gt;DEV / QA+Stage Shared / Prod (Recommended)&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-style: solid; border-width: 1px;&quot;&gt;
&lt;p&gt;DEV / QA / Stage+Prod Shared&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border-style: solid; border-width: 1px;&quot;&gt;
&lt;p&gt;Developer Freedom&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-style: solid; border-width: 1px;&quot;&gt;
&lt;p&gt;❌ Constrained&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-style: solid; border-width: 1px;&quot;&gt;
&lt;p&gt;✅ Full&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-style: solid; border-width: 1px;&quot;&gt;
&lt;p&gt;✅ Full&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border-style: solid; border-width: 1px;&quot;&gt;
&lt;p&gt;QA Schema Stability&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-style: solid; border-width: 1px;&quot;&gt;
&lt;p&gt;❌ Unstable&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-style: solid; border-width: 1px;&quot;&gt;
&lt;p&gt;✅ Stable&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-style: solid; border-width: 1px;&quot;&gt;
&lt;p&gt;✅ Stable&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border-style: solid; border-width: 1px;&quot;&gt;
&lt;p&gt;UAT/Stakeholder Experience&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-style: solid; border-width: 1px;&quot;&gt;
&lt;p&gt;✅ Clean&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-style: solid; border-width: 1px;&quot;&gt;
&lt;p&gt;⚠️ Shares with QA&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-style: solid; border-width: 1px;&quot;&gt;
&lt;p&gt;❌ Shares with Prod&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border-style: solid; border-width: 1px;&quot;&gt;
&lt;p&gt;Production Safety&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-style: solid; border-width: 1px;&quot;&gt;
&lt;p&gt;✅ Safe&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-style: solid; border-width: 1px;&quot;&gt;
&lt;p&gt;✅ Safe&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-style: solid; border-width: 1px;&quot;&gt;
&lt;p&gt;❌ Dangerous&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border-style: solid; border-width: 1px;&quot;&gt;
&lt;p&gt;Application Isolation Helps?&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-style: solid; border-width: 1px;&quot;&gt;
&lt;p&gt;⚠️ Minimally&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-style: solid; border-width: 1px;&quot;&gt;
&lt;p&gt;✅ Meaningfully&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-style: solid; border-width: 1px;&quot;&gt;
&lt;p&gt;⚠️ Not enough&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border-style: solid; border-width: 1px;&quot;&gt;
&lt;p&gt;Large Team Suitability&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-style: solid; border-width: 1px;&quot;&gt;
&lt;p&gt;&#128308; Poor&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-style: solid; border-width: 1px;&quot;&gt;
&lt;p&gt;&#128994; Excellent&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-style: solid; border-width: 1px;&quot;&gt;
&lt;p&gt;&#128308; Unacceptable&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr /&gt;
&lt;h2&gt;Implementing Automated Schema Promotion&lt;/h2&gt;
&lt;p&gt;To make this pipeline robust, you should eliminate manual schema management. The recommended approach is to define all content types in code &amp;mdash; inside your frontend components (e.g., .tsx files) &amp;mdash; and let the &lt;strong&gt;Optimizely CMS CLI&lt;/strong&gt; handle promotion programmatically.&lt;/p&gt;
&lt;h3&gt;1. Integrate CLI Sync into Your CI/CD Build&lt;/h3&gt;
&lt;p&gt;Your Git branch is the source of truth, and your CI/CD pipeline is the gate. Configure separate API credentials for each CMS instance in your pipeline&#39;s environment variables:&lt;/p&gt;
&lt;pre class=&quot;code-block&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;# In your CI/CD pipeline (e.g., GitHub Actions, Azure DevOps)
# On deploy to the staging environment:
OPTIMIZELY_CMS_CLIENT_ID=${{ secrets.QA_CMS_CLIENT_ID }}
OPTIMIZELY_CMS_CLIENT_SECRET=${{ secrets.QA_CMS_CLIENT_SECRET }}

npx @optimizely/cms-cli@latest sync&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This ensures that schema changes only land on your QA+Stage instance when code is merged and deployed to your release branch &amp;mdash; completely insulating testers from mid-sprint developer changes.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Technical Note:&lt;/strong&gt; The CLI operates as a one-way push, reading content definitions from code and synchronizing them to the target CMS instance. This reinforces the best practice that schemas should always be version-controlled in Git.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;2. Map Git Branches directly to CMS Instances&lt;/h3&gt;
&lt;p&gt;Establishing a clear one-to-one relationship between your repository branches and your CMS targets ensures a seamless release flow:&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; border-width: 1px; border-style: solid;&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border-width: 1px; text-align: left; vertical-align: top; border-style: solid;&quot;&gt;
&lt;p&gt;&lt;strong&gt;Git Branch&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; text-align: left; vertical-align: top; border-style: solid;&quot;&gt;
&lt;p&gt;&lt;strong&gt;Target Frontend&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; text-align: left; vertical-align: top; border-style: solid;&quot;&gt;
&lt;p&gt;&lt;strong&gt;CLI target (CMS Instance)&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border-width: 1px; text-align: left; vertical-align: top; border-style: solid;&quot;&gt;
&lt;p&gt;feature/*&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; text-align: left; vertical-align: top; border-style: solid;&quot;&gt;
&lt;p&gt;Local / DEV Deploy&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; text-align: left; vertical-align: top; border-style: solid;&quot;&gt;
&lt;p&gt;DEV Instance&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border-width: 1px; text-align: left; vertical-align: top; border-style: solid;&quot;&gt;
&lt;p&gt;release/* or main&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; text-align: left; vertical-align: top; border-style: solid;&quot;&gt;
&lt;p&gt;QA &amp;amp; Stage Deploys&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; text-align: left; vertical-align: top; border-style: solid;&quot;&gt;
&lt;p&gt;QA+Stage Shared Instance&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border-width: 1px; text-align: left; vertical-align: top; border-style: solid;&quot;&gt;
&lt;p&gt;production&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; text-align: left; vertical-align: top; border-style: solid;&quot;&gt;
&lt;p&gt;Live Production Deploy&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;border-width: 1px; text-align: left; vertical-align: top; border-style: solid;&quot;&gt;
&lt;p&gt;Prod Instance&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr /&gt;
&lt;h2&gt;Aligning SaaS Architecture with Team Requirements&lt;/h2&gt;
&lt;p&gt;This approach reflects the cloud-native, headless architecture of modern SaaS platforms: the CMS backend is tightly integrated with global content APIs, real-time preview engines, and the Optimizely Graph layer.&lt;/p&gt;
&lt;p&gt;By utilizing branch-gated CI/CD pipelines and native CMS Applications, teams can deliver enterprise-grade release management that feels fast, automated, and safe.&lt;/p&gt;
&lt;p&gt;For large global organizations where a strictly isolated 4-environment physical topology is required by compliance or parallel testing tracks, we recommend discussing your roadmap early with your Optimizely account team. Additional non-production instances can easily be provisioned, and framing the discussion around team velocity and schema-gated testing ensures a highly collaborative path forward.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Summary of Best Practices&lt;/h2&gt;
&lt;ul class=&quot;tight&quot;&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Differentiate applications from environments:&lt;/strong&gt; Use separate Applications to isolate content trees, but remember schemas are instance-wide.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Isolate development first:&lt;/strong&gt; Keep your DEV instance fully separated so developers have complete freedom to innovate and experiment.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Automate schema delivery:&lt;/strong&gt; Define content types in code and use the &lt;strong&gt;Optimizely CMS CLI&lt;/strong&gt; to synchronize schemas at build time.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Gate with Git:&lt;/strong&gt; Align your branching strategy directly with your CMS targets to turn your pipeline into a hands-free, automated release gate.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;How has your team structured its headless deployment pipeline? Share your architecture tips and lessons learned in the comments below!&lt;/em&gt;&lt;/p&gt;</id><updated>2026-07-12T02:38:20.0000000Z</updated><summary type="html">Blog post</summary></entry></feed>