<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom"><title type="text">Blogs</title><link href="https://world.optimizely.com/blogs/" /><updated>2026-10-21T21:59:52.0000000Z</updated><id>https://world.optimizely.com/blogs/</id><generator uri="https://world.optimizely.com/blogs/" version="2.0">Optimizely World</generator><entry><title>Virtual Opticon 2026: Where AI Can Improve DXP Delivery</title><link href="https://world.optimizely.com/blogs/opti-chronicles/dates/2026/10/virtual-opticon-2026-where-ai-can-improve-dxp-delivery2/" /><id>https://world.optimizely.com/blogs/opti-chronicles/dates/2026/10/virtual-opticon-2026-where-ai-can-improve-dxp-delivery2/</id><published>2026-10-21T21:59:52.0000000Z</published><updated>2026-10-21T21:59:52.0000000Z</updated><author><name>adavalos@oshyn.com</name></author><category term="Blog post" /><summary type="html">&lt;p class="MsoNormal"&gt;Consider a campaign whose first draft takes ten minutes to generate but then waits five days for review. Faster drafting has helped one task. The campaign still reaches the customer five days later.&lt;/p&gt;
&lt;p class="MsoNormal"&gt;As an attendee at Opticon Online on October 21, I was particularly interested in how AI could address that delivery problem. Across the customer stories and product keynote, the strongest case for AI was its ability to take responsibility for recurring work across a digital experience program. The value depends on which work we assign it, what context it receives, and how we measure the result.&lt;/p&gt;
&lt;p class="MsoNormal"&gt;Optimizely framed the discussion around three experiences: the marketer doing the work, the customer using the experience, and the AI agent increasingly researching or acting on a customer's behalf. Its October Agent Platform updates make that direction more tangible. Here is how I would translate it into DXP decisions.&lt;/p&gt;
&lt;h1&gt;Measure the time it takes to reach a decision&lt;/h1&gt;
&lt;p class="MsoNormal"&gt;Robert Chatwani's DocuSign presentation focused on cycle time: the path from a hypothesis to a live experiment, a decision, and the next test. His recommendations were specific: bring the relevant functions together, assign a directly responsible individual with decision authority, and remove the handoffs where work stalls.&lt;/p&gt;
&lt;p class="MsoNormal"&gt;For a DXP team, this changes where an AI pilot should start. Map one recurring process before choosing an agent. Record the time spent producing work, waiting for review, resolving missing information, and implementing the decision. If most of the delay sits in an approval queue, another copy generator addresses the wrong part of that process.&lt;/p&gt;
&lt;p class="MsoNormal"&gt;A useful pilot might assemble the source material, perform an initial brand check, and route a complete review package to a named approver. Measure elapsed time through approval and publication, alongside the amount of rework. That tells you whether the process improved.&lt;/p&gt;
&lt;p class="MsoNormal"&gt;Experimentation needs a further distinction. AI can shorten preparation and analysis work. A valid decision still needs appropriate measurement, traffic, and observation time. Faster launches do not automatically produce reliable learning sooner.&lt;/p&gt;
&lt;p class="MsoNormal"&gt;Personalization also needs an explicit decision framework: which observed signal changes the experience, what content is eligible, and what visitors receive when the signal is absent. More content variants are useful when they support a relevant customer choice and an outcome the team can evaluate.&lt;/p&gt;
&lt;h1&gt;Make approved context part of the delivery process&lt;/h1&gt;
&lt;p class="MsoNormal"&gt;KPMG's sequence was one of the most useful examples in the event. Lauren Boyman described consolidating intake, incorporating editorial and brand review into workflows, and establishing a shared digital asset library with metadata and usage information. Specialized agents followed that foundation.&lt;/p&gt;
&lt;p class="MsoNormal"&gt;That sequence belongs in a DXP roadmap. Before asking AI to reuse an asset or adapt a claim, establish which version is approved, where it can be used, and who maintains it. Otherwise, automation can distribute outdated information more efficiently.&lt;/p&gt;
&lt;p class="MsoNormal"&gt;Brand Packs address part of this requirement. Optimizely documents them as structured brand assets and rules that become a skill when published. A default pack supplies brand context unless another is selected. Variants extend the production workflow by creating channel-specific image versions.&lt;/p&gt;
&lt;p class="MsoNormal"&gt;The distinction between brand guidance and approval deserves care. Publishing a Brand Pack gives agents approved instructions; it does not establish that every future output is factually accurate, legally acceptable, or suitable for every market. Teams still need to define which outputs require review.&lt;/p&gt;
&lt;p class="MsoNormal"&gt;I would give the pack a business owner and a review process. Test it against representative outputs, including difficult cases such as an expired offer or a claim that requires qualification. For image variants, inspect cropping, legibility, and required disclosures. The useful measure is how much work passes review without correction.&lt;/p&gt;
&lt;p class="MsoNormal"&gt;The Skills Directory can help distribute reusable guidance across the organization. Administrators can add organization skills, while users can add personal skills.&amp;nbsp;That creates a practical reason to make approved organizational guidance easy to find and maintain, especially when personal instructions also exist.&lt;/p&gt;
&lt;h1&gt;Give Virtual Teammates a bounded job and an accountable owner&lt;/h1&gt;
&lt;p class="MsoNormal"&gt;October's Web Manager and Content Marketer additions move the discussion toward recurring responsibilities. The Web Manager documentation describes compliance audits, site-health work, and website updates. Its jobs run through scheduled or triggered workflows; enabled jobs and connected tools determine what is available. The documentation also says it asks for approval before acting on your behalf.&amp;nbsp;&lt;/p&gt;
&lt;p class="MsoNormal"&gt;The Content Marketer monitors trends and competitors and can use connected Salesforce data to identify pipeline-related content gaps. The opportunity is to give editorial teams evidence for prioritization. A competitor publishing on a topic is a signal to investigate, not sufficient reason to publish the same thing.&lt;/p&gt;
&lt;p class="MsoNormal"&gt;For either role, I would define a short operating brief: the pages or topics in scope, the approved sources, the actions it can propose or execute, the person who resolves exceptions, and the conditions that stop a run.&lt;/p&gt;
&lt;p class="MsoNormal"&gt;Then follow the work through to completion. An audit that generates fifty findings has created a queue. Track how many findings were accepted, corrected, verified, and prevented from recurring. For content recommendations, track whether briefs address an agreed audience need and progress through review. Activity counts alone will overstate the value.&lt;/p&gt;
&lt;p class="MsoNormal"&gt;The platform's workflow-priority controls also deserve attention as usage grows. They distinguish interactive work from background runs when execution capacity is constrained. A team needs to decide which processes can wait before scheduling large volumes of automated work.&lt;/p&gt;
&lt;h1&gt;Evaluate orchestration before expanding a migration&lt;/h1&gt;
&lt;p class="MsoNormal"&gt;The keynote positioned Agent Platform as an orchestration layer across Optimizely applications and external systems, including competing content platforms. The October update adds connections spanning advertising, analytics, collaboration, and content tools.&amp;nbsp;&lt;/p&gt;
&lt;p class="MsoNormal"&gt;For organizations with an established technology stack, this creates an option worth evaluating: improve a workflow across the systems already in use before adding a broader platform replacement to the plan.&lt;/p&gt;
&lt;p class="MsoNormal"&gt;Connector availability is only the start of that assessment. Validate the actual operations the workflow needs, the permissions required, and how failures are handled. A connector that retrieves campaign data does not, by itself, prove that the complete review and publishing process can run through it.&lt;/p&gt;
&lt;p class="MsoNormal"&gt;A contained workflow gives the team a way to test that boundary. For example, investigate whether an agent can gather performance data, prepare a recommendation, and create a review task while publication remains in the established CMS workflow. The result should inform the architecture decision.&lt;/p&gt;
&lt;h1&gt;Prepare for agents completing customer tasks&lt;/h1&gt;
&lt;p class="MsoNormal"&gt;The agent-experience discussion extends beyond visibility in AI-generated answers. A prospective customer may ask an assistant to find a suitable product, locate its documentation, or arrange a meeting. The website then needs to support the task as well as explain the offering.&lt;/p&gt;
&lt;p class="MsoNormal"&gt;Optimizely's WebMCP material describes a proposed standard for exposing structured website capabilities to agents. Its Agent Ready Experiences implementation is currently described as a closed beta. This warrants a controlled pilot and a check of current availability before committing a production roadmap.&lt;/p&gt;
&lt;p class="MsoNormal"&gt;I would start with one journey, such as finding a product and retrieving its current technical document. Check whether the product identifiers, versions, eligibility rules, and document relationships are explicit and consistent. If the journey progresses to an action, define the authorization and confirmation requirements.&lt;/p&gt;
&lt;p class="MsoNormal"&gt;Measure successful task completion, incorrect selections, and points where a person must take over. Being cited in an AI answer and helping a customer finish a task are separate outcomes. Both need their own evidence.&lt;/p&gt;
&lt;h1&gt;Build the business case around accepted work&lt;/h1&gt;
&lt;p class="MsoNormal"&gt;The evolution from Opal toward Mark, including domain-specific models and a marketing context layer, brings AI economics into the DXP discussion. The keynote described some model capabilities as forthcoming, particularly models trained for customers' own agents. Those plans should remain separate from capabilities verified in a customer's environment.&lt;/p&gt;
&lt;p class="MsoNormal"&gt;Optimizely reported a 67% benchmark all-pass rate against 60% for Claude Code at half the cost in the cited default configurations. That is a seven-percentage-point gap on that evaluation. The separate claim of up to tenfold model cost efficiency came from early testing. Neither establishes the savings a particular customer will achieve, and multiplying them into a twentyfold business-case assumption is not justified without a combined test.&amp;nbsp;&lt;/p&gt;
&lt;p class="MsoNormal"&gt;For a pilot, calculate cost per accepted output or completed workflow. Include platform consumption, setup and maintenance, review time, corrections, and failed runs. Compare that with the baseline for the same work. Cheap generation can still produce an expensive process if reviewers must reconstruct the answer.&lt;/p&gt;
&lt;p class="MsoNormal"&gt;The next thirty days can be straightforward: select one recurring workflow with a visible delay, assign an owner, establish the baseline, and run a bounded pilot using approved context. Expand when it reduces elapsed time or effort while meeting the agreed quality standard.&lt;/p&gt;
&lt;p class="MsoNormal"&gt;Seth Godin's closing session brought the discussion back to the audience: who the work serves, what change it should create, and whether people want to hear from the brand. That is a useful test for the time AI gives back. Use it to understand customers better, improve an experience, or test a substantive idea. A higher publishing count is a weak return if the work gives customers no additional reason to care.&lt;/p&gt;
&lt;h3&gt;About the Author&lt;/h3&gt;
&lt;p&gt;&lt;em&gt;I am Augusto Davalos, a Senior Project Manager at&lt;/em&gt; &lt;a href="https://www.oshyn.com/"&gt;&lt;em&gt;&lt;strong&gt;Oshyn&lt;/strong&gt;&lt;/em&gt;&lt;/a&gt;&lt;em&gt;, a digital transformation consultancy helping brands get more value from composable DXP platforms such as Optimizely. My work sits at the intersection of digital strategy, agile delivery, content operations, and business outcomes. I share practical perspectives on Optimizely, DXP, MarTech, and digital leadership.&lt;/em&gt;&lt;/p&gt;</summary></entry><entry><title>The Low-Level API Is Not Automatically Fast. Part 4: Counting Round Trips</title><link href="https://szolkowski.github.io/2026/10/21/the-low-level-api-is-not-automatically-fast-part-4-counting-round-trips" /><id>https://szolkowski.github.io/2026/10/21/the-low-level-api-is-not-automatically-fast-part-4-counting-round-trips</id><published>2026-10-21T08:00:00.0000000Z</published><updated>2026-10-21T11:30:30.0000000Z</updated><author><name>stanislaw.szolkowski@firstlinesoftware.com</name></author><category term="Blog post" /><summary type="html">The low-level Commerce DTO API is not automatically fast: counting the database round trips per imported row, and four fixes I would apply first.</summary></entry><entry><title>The Migration Bugs That Actually Caught Us Out</title><link href="https://reillydom.com/blog/the-migration-bugs-that-actually-caught-us-out" /><id>https://reillydom.com/blog/the-migration-bugs-that-actually-caught-us-out</id><published>2026-10-20T17:28:21.0000000Z</published><updated>2026-10-20T17:31:06.0000000Z</updated><author><name>dominic.reilly@mandogroup.com</name></author><category term="Blog post" /><summary type="html">The less obvious issues we hit migrating Optimizely CMS 11 to CMS 12 and .NET 8, including routing changes, JSON model binding, package dependency issues, Optimizely Forms email failures, and CMS authorization.</summary></entry><entry><title>Camp Opticon 26 (London): AI requires a different operating model</title><link href="https://world.optimizely.com/blogs/william-parr/dates/2026/10/camp-opticon-26-london-ai-requires-a-different-operating-model/" /><id>https://world.optimizely.com/blogs/william-parr/dates/2026/10/camp-opticon-26-london-ai-requires-a-different-operating-model/</id><published>2026-10-20T08:41:02.0000000Z</published><updated>2026-10-20T08:41:02.0000000Z</updated><author><name>william.parr@iodigital.com</name></author><category term="Blog post" /><summary type="html">&lt;h3&gt;Introduction&lt;/h3&gt;
&lt;p&gt;Last week I attended Camp Opticon 26 in London and although it was far from my first time at Opticon, it was in fact my first time that I spent Day 1 not at the Partner Forum, but at the &lt;strong&gt;OMVP Summit&lt;/strong&gt;. Needless to say, this brought back all of the excitement that I felt when I first attended Opticon back in 2022! This blog post isn't about the OMVP Summit but I did want to include that it was awesome spending the day with so many other Optimizely Most Valuable Professionals that I usually only connect with online and occasionally see on stage at Optimizely events. It was also a great preparation for Day 2, when I was joined by several colleagues from iO and a handful of our Optimizely customers.&lt;/p&gt;
&lt;p&gt;Upon returning to The Netherlands, I was asked to write an article with some insights and my key takeaways from Camp Opticon 26. This was subsequently published on the Dutch blog &lt;strong&gt;Marketing Report&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Disclaimer: it's not a tech article and as per the provided author guidelines it doesn't contain product and feature names. Those who were at Camp Opticon will be able to map the takeaways to the specific topics that were discussed there, but for those that weren't (and that should definitely sign up for &lt;a href="https://www.optimizely.com/opticon-online"&gt;&lt;span style="color: rgb(45, 194, 107);"&gt;Opticon Online&lt;/span&gt;&lt;/a&gt; tomorrow!), a short list that maps the most important parts of this write-up to applicable Optimizely products / themes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;AI becomes a digital colleague &amp;rarr; &lt;a href="https://www.optimizely.com/solutions/virtual-teammates"&gt;&lt;span style="color: rgb(45, 194, 107);"&gt;Virtual Teammates&lt;/span&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Governance becomes the real bottleneck &amp;rarr; &lt;a href="https://www.optimizely.com/products/ai"&gt;&lt;span style="color: rgb(45, 194, 107);"&gt;Agent Platform&lt;/span&gt;&lt;/a&gt; (knowledge integration, quality evaluation, human-in-the-loop) and &lt;span style="color: rgb(45, 194, 107);"&gt;&lt;a style="color: rgb(45, 194, 107);" href="https://www.optimizely.com/solutions/agentic-content-ops"&gt;Agentic Content Ops&lt;/a&gt;&lt;/span&gt;
&lt;ul&gt;
&lt;li&gt;Also see: &lt;span style="color: rgb(45, 194, 107);"&gt;&lt;a style="color: rgb(45, 194, 107);" href="https://docs.optimizely.com/agent-platform/docs/configure-execution-guardrails"&gt;Execution Guardrails&lt;/a&gt;&lt;/span&gt;, &lt;span style="color: rgb(45, 194, 107);"&gt;&lt;a style="color: rgb(45, 194, 107);" href="https://docs.optimizely.com/agent-platform/docs/configure-output-evaluation"&gt;Output Evaluation&lt;/a&gt;&lt;/span&gt; and &lt;span style="color: rgb(45, 194, 107);"&gt;&lt;a style="color: rgb(45, 194, 107);" href="https://docs.optimizely.com/agent-platform/docs/execution-advisor"&gt;Execution Advisor&lt;/a&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Personalisation becomes continuous &amp;rarr; &lt;span style="color: rgb(45, 194, 107);"&gt;&lt;a style="color: rgb(45, 194, 107);" href="https://www.optimizely.com/solutions/limitless-personalization"&gt;Limitless Personalization&lt;/a&gt;&lt;/span&gt; and &lt;a href="https://www.optimizely.com/solutions/dynamic-experiences"&gt;&lt;span style="color: rgb(45, 194, 107);"&gt;Dynamic Experiences&lt;/span&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Second audience: AI agents &amp;rarr; &lt;span style="color: rgb(45, 194, 107);"&gt;&lt;a style="color: rgb(45, 194, 107);" href="https://www.optimizely.com/products/content-management"&gt;Agentic CMS&lt;/a&gt;&lt;/span&gt; ("Stay visible automatically" -- SEO/GEO/AI), &lt;span style="color: rgb(45, 194, 107);"&gt;&lt;a style="color: rgb(45, 194, 107);" href="https://www.optimizely.com/products/commerce"&gt;Agentic Commerce&lt;/a&gt;&lt;/span&gt; ("Commerce without clicks"), &lt;span style="color: rgb(45, 194, 107);"&gt;&lt;a style="color: rgb(45, 194, 107);" href="https://www.optimizely.com/solutions/agent-visibility"&gt;Agent Visibility&lt;/a&gt;&lt;/span&gt; and &lt;span style="color: rgb(45, 194, 107);"&gt;&lt;a style="color: rgb(45, 194, 107);" href="https://www.optimizely.com/field-notes/articles/webmcp-agentic-web"&gt;WebMCP&lt;/a&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Seeing as the article is written in Dutch, I took the liberty of having Agent Platform / Mark translate it for me so that I could paste it below!&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Note: this content was originally published on &lt;span style="color: rgb(45, 194, 107);"&gt;&lt;a style="color: rgb(45, 194, 107);" href="https://marketingreport.nl/william-parr-ai-vraagt-om-een-ander-operating-model"&gt;https://marketingreport.nl/william-parr-ai-vraagt-om-een-ander-operating-model&lt;/a&gt;&lt;/span&gt; by Saskia van Weert, and has been translated to English and enriched with several images below.&lt;/em&gt;&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Camp Opticon 26 (London): AI requires a different operating model&lt;/h2&gt;
&lt;p class="mb-2 whitespace-pre-wrap"&gt;Last year, AI was still often discussed as an experiment or a standalone productivity tool. Now the conversation is more about workflows, governance and measurable impact.&lt;/p&gt;
&lt;p class="mb-2 whitespace-pre-wrap"&gt;That was the main conclusion from Opticon London, Optimizely's leading conference for digital experience professionals. As an Optimizely Most Valuable Professional at iO, I had the chance to attend a conference where the conversations are clearly heading in a different direction.&lt;/p&gt;
&lt;p class="mb-2 whitespace-pre-wrap"&gt;&lt;img src="/link/198b184a55ec46a493d1772abc3d6309.aspx?1792484684698" width="600" /&gt;&lt;/p&gt;
&lt;p class="mb-2 whitespace-pre-wrap"&gt;&lt;br /&gt;Here are five key insights:&lt;/p&gt;
&lt;h3&gt;1. AI becomes a digital colleague, not just a tool&lt;/h3&gt;
&lt;p class="mb-2 whitespace-pre-wrap"&gt;The most important shift isn't that AI writes better copy or generates images faster. The bigger change is that AI is increasingly given a role within teams, with tasks, permissions, context, memory and human checkpoints. This changes how marketing and digital teams organise their work. We are moving from "AI as assistant" to "AI as colleague", and that demands a fundamentally different approach to workflows, roles and governance.&lt;/p&gt;
&lt;p class="mb-2 whitespace-pre-wrap"&gt;&lt;img src="/link/9c7959acc45f4d09ad52e1c9eac5ddb0.aspx?1792485346898" width="600" /&gt;&lt;/p&gt;
&lt;h3&gt;2. Content production scales, but governance becomes the real bottleneck&lt;/h3&gt;
&lt;p class="mb-2 whitespace-pre-wrap"&gt;AI makes it easier than ever to create, translate, rewrite and personalise content at scale. But without a solid content structure, clear brand rules, defined approval flows and clear ownership, you mainly create more complexity and risk. The organisations that win are not those that produce the most content with AI. They are the ones that have built the governance infrastructure to manage that content. Investing in AI content governance is no longer optional. It is the foundation.&lt;/p&gt;
&lt;p class="mb-2 whitespace-pre-wrap"&gt;&amp;nbsp;&lt;img src="/link/ec1501b82fcf490ca7a38fd6cc0e82a4.aspx?1792484684698" width="600" /&gt;&lt;/p&gt;
&lt;h3&gt;3. Personalisation becomes continuous, not campaign-driven&lt;/h3&gt;
&lt;p class="mb-2 whitespace-pre-wrap"&gt;Most organisations still personalise or experiment on a limited scale today, simply because teams don't have enough time or capacity to build and test variants for every audience, every market or every page. AI fundamentally changes that. It enables continuous learning, testing and optimisation, but only if the right frameworks are in place. The shift from campaign-driven to continuous personalisation is one of the most impactful changes AI makes possible, and at the same time one of the least discussed.&lt;/p&gt;
&lt;p class="mb-2 whitespace-pre-wrap"&gt;&lt;img src="/link/87e45b49842b4129b1e6f3aa3fc4da46.aspx?1792485363209" width="600" /&gt;&lt;/p&gt;
&lt;h3&gt;4. Your website now has a second audience: AI agents&lt;/h3&gt;
&lt;p class="mb-2 whitespace-pre-wrap"&gt;Findability is no longer just about SEO and human visitors. AI systems increasingly read, interpret and cite web content, and in doing so they are becoming an intermediary layer between brand and customer. That means companies need to think about AI visibility: does an AI system understand your brand, your offering and your expertise? Are you part of the answers it gives? This is a new discipline that most organisations still aren't taking seriously enough.&lt;/p&gt;
&lt;p class="mb-2 whitespace-pre-wrap"&gt;&lt;img src="/link/c5785f835d6d4e5885df49174061b823.aspx?1792485378152" width="600" /&gt; &lt;img src="/link/b54447bff2874d00856223f21da0aa93.aspx?1792485389353" width="600" /&gt;&lt;/p&gt;
&lt;h3&gt;5. The winners don't have the most AI tools, but the best operating model&lt;/h3&gt;
&lt;p class="mb-2 whitespace-pre-wrap"&gt;The biggest challenge is no longer "what can AI do?", but "how do we make AI safe, scalable and genuinely useful within our organisation?" The answer lies in the fundamentals: data quality, content models, workflows, roles, permissions and governance. Without that foundation, AI remains a collection of isolated pilots that consume budget without delivering results.&lt;/p&gt;
&lt;p class="mb-2 whitespace-pre-wrap"&gt;&lt;img src="/link/0fc7e2e5663642e68bdf25bc1368c931.aspx?1792485415760" width="600" /&gt; &lt;img src="/link/c6dd1719ea6447a7ad0cb066e6649b1a.aspx?1792485424931" width="600" /&gt;&lt;/p&gt;
&lt;h3&gt;From separate tools to integrated operations&lt;/h3&gt;
&lt;p class="mb-2 whitespace-pre-wrap"&gt;Across all five themes, I see the same underlying movement: the market is shifting from separate CMS, DXP and AI tools to integrated digital experience operations. Content, personalisation, experiments, analytics and AI are becoming more and more connected, and the platforms are evolving in that direction.&lt;/p&gt;
&lt;p class="mb-2 whitespace-pre-wrap"&gt;My key takeaway from Opticon is that the AI conversation is maturing. This aligns with what we increasingly see with clients: the challenge isn't only the choice of platform, but how content, data, technology, AI and teams come together in one workable operating model. Organisations that want to create value quickly need to look beyond the technology and focus on content structure, governance and the workflows that connect everything. Those who invest in structure, governance and collaboration now will get more out of AI later than organisations that simply add new tools.&lt;/p&gt;
&lt;p&gt;&lt;img src="/link/b38c557ce9274cc38b620fd4db6c10e0.aspx?1792485438943" width="400" /&gt;&lt;/p&gt;
&lt;hr /&gt;
&lt;p class="mb-2 whitespace-pre-wrap"&gt;And with that, we're already having conversations with several customers based on the above and are looking forward to helping them prepare for this agentic era we're collectively venturing into. This year's Camp Opticon was another stellar edition and I am looking forward to seeing what Optimizely has in store for us in 2027!&lt;/p&gt;</summary></entry><entry><title>Migrating ContentArea.FilteredItems in Optimizely CMS 13</title><link href="https://www.adnanzameer.com/2026/10/migrating-contentarea-filtereditems-optimizely-cms-13.html" /><id>https://www.adnanzameer.com/2026/10/migrating-contentarea-filtereditems-optimizely-cms-13.html</id><published>2026-10-19T15:31:24.0000000Z</published><updated>2026-10-19T16:30:29.0000000Z</updated><author><name>adnan.zameer@episerver.com</name></author><category term="Blog post" /><summary type="html">&lt;!--excerpt: In Optimizely CMS 13, ContentArea.FilteredItems is marked obsolete and scheduled for removal in CMS 14. If you called FilteredItems in your controllers, services, or view model factories to check visibility, count items, or evaluate visitor groups, you must migrate to IContentAreaItemsRenderingFilter. Here is why the API changed and how to write a clean replacement.--&gt;

If you have </summary></entry><entry><title>Categories have landed in Optimizely SaaS CMS: structure, translate, tag and query</title><link href="https://world.optimizely.com/blogs/Minesh-Shah/Dates/2026/10/categories-have-landed-in-optimizely-saas-cms-structure-translate-tag-and-query/" /><id>https://world.optimizely.com/blogs/Minesh-Shah/Dates/2026/10/categories-have-landed-in-optimizely-saas-cms-structure-translate-tag-and-query/</id><published>2026-10-19T14:37:13.0000000Z</published><updated>2026-10-19T14:37:13.0000000Z</updated><author><name>minesh.shah@netcel.com</name></author><category term="Blog post" /><summary type="html">&lt;p&gt;Categories arrived in Optimizely SaaS CMS at the end of August, with Graph support alongside. I have wanted a proper taxonomy in SaaS since the first build I did on it. Until now we have been faking it with select lists, string arrays or a folder of "tag" blocks. None of those are great. They are hard for editors to maintain, they do not translate cleanly and they make filtering in Graph clumsy.&lt;/p&gt;
&lt;p&gt;So I spent an afternoon putting the new feature through its paces on our sandbox instance. This post walks through the whole flow with screenshots: building a category structure, translating it, tagging pages and then querying it all in Optimizely Graph. There are a couple of gotchas in the Graph section that are worth knowing before you start building filters.&lt;/p&gt;
&lt;h2&gt;Where to find it&lt;/h2&gt;
&lt;p&gt;Categories live under &lt;strong&gt;Settings &amp;gt; Taxonomy &amp;gt; Categories&lt;/strong&gt;. On a fresh instance you get an empty grid with a locale switcher, a search box and a &lt;strong&gt;Create&lt;/strong&gt; button. Ours already had a single test category in it.&lt;/p&gt;
&lt;p&gt;&lt;img style="max-width: 100%; height: auto;" src="/link/c964d4ca3af645e19f354892ffd30a8a.aspx" alt="The Categories screen under Settings" /&gt;&lt;/p&gt;
&lt;p&gt;The grid shows Display name, Key, Description, Visible, Selectable, Usage and the usual audit columns. It is wide, so on a laptop you will be scrolling sideways to reach the actions menu at the end of each row.&lt;/p&gt;
&lt;h2&gt;Creating a structure&lt;/h2&gt;
&lt;p&gt;I went with a typical B2B example: an &lt;strong&gt;Industry&lt;/strong&gt; parent, three sectors under it, and a third level under Financial Services. I prefixed everything with "Demo" so it is easy to clean up later.&lt;/p&gt;
&lt;p&gt;Click &lt;strong&gt;Create&lt;/strong&gt; and you get a row-based dialog.&lt;/p&gt;
&lt;p&gt;&lt;img style="max-width: 100%; height: auto;" src="/link/a7242fc675134e2085c15665f4527625.aspx" alt="Creating a top-level category" /&gt;&lt;/p&gt;
&lt;p&gt;Each category has these fields:&lt;/p&gt;
&lt;table style="border-collapse: collapse; width: 100%; margin: 8px 0 20px 0;"&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style="border: 1px solid #c9d6cc; padding: 8px 10px; vertical-align: top; text-align: left; white-space: nowrap; font-weight: 600; background-color: #eef3ee;"&gt;Field&lt;/th&gt;
&lt;th style="border: 1px solid #c9d6cc; padding: 8px 10px; vertical-align: top; text-align: left; background-color: #eef3ee;"&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #c9d6cc; padding: 8px 10px; vertical-align: top; text-align: left; white-space: nowrap; font-weight: 600;"&gt;Display name&lt;/td&gt;
&lt;td style="border: 1px solid #c9d6cc; padding: 8px 10px; vertical-align: top; text-align: left;"&gt;What editors see. Up to 255 characters. Translatable.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #c9d6cc; padding: 8px 10px; vertical-align: top; text-align: left; white-space: nowrap; font-weight: 600;"&gt;Key&lt;/td&gt;
&lt;td style="border: 1px solid #c9d6cc; padding: 8px 10px; vertical-align: top; text-align: left;"&gt;The identifier you use in code and Graph. Up to 100 characters, unique, not translatable. It auto-fills from the display name with spaces swapped for underscores.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #c9d6cc; padding: 8px 10px; vertical-align: top; text-align: left; white-space: nowrap; font-weight: 600;"&gt;Description&lt;/td&gt;
&lt;td style="border: 1px solid #c9d6cc; padding: 8px 10px; vertical-align: top; text-align: left;"&gt;Optional, up to 500 characters. Translatable.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #c9d6cc; padding: 8px 10px; vertical-align: top; text-align: left; white-space: nowrap; font-weight: 600;"&gt;Parent&lt;/td&gt;
&lt;td style="border: 1px solid #c9d6cc; padding: 8px 10px; vertical-align: top; text-align: left;"&gt;Read only. It is set by where you start the create action.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #c9d6cc; padding: 8px 10px; vertical-align: top; text-align: left; white-space: nowrap; font-weight: 600;"&gt;Visible&lt;/td&gt;
&lt;td style="border: 1px solid #c9d6cc; padding: 8px 10px; vertical-align: top; text-align: left;"&gt;Whether the category shows in the editor's picker.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #c9d6cc; padding: 8px 10px; vertical-align: top; text-align: left; white-space: nowrap; font-weight: 600;"&gt;Selectable&lt;/td&gt;
&lt;td style="border: 1px solid #c9d6cc; padding: 8px 10px; vertical-align: top; text-align: left;"&gt;Whether editors can assign it to content.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #c9d6cc; padding: 8px 10px; vertical-align: top; text-align: left; white-space: nowrap; font-weight: 600;"&gt;Usage&lt;/td&gt;
&lt;td style="border: 1px solid #c9d6cc; padding: 8px 10px; vertical-align: top; text-align: left;"&gt;Public or Internal.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The key is the field to get right first time. It is what your front end will depend on, so agree a naming convention with the team before anyone starts typing. The auto-generated value is fine for most cases, but I would not leave it to chance on a real project.&lt;/p&gt;
&lt;p&gt;To add children, open the &lt;strong&gt;...&lt;/strong&gt; menu at the end of a row and choose &lt;strong&gt;Create child&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;img style="max-width: 100%; height: auto;" src="/link/37ea2d123b8b4b63b98596920b2445c1.aspx" alt="The row actions menu with Create child" /&gt;&lt;/p&gt;
&lt;p&gt;The nice touch here is the &lt;strong&gt;+&lt;/strong&gt; button at the end of each row. You can add several categories in one go rather than opening the dialog for each one. The Parent column is pre-filled and locked.&lt;/p&gt;
&lt;p&gt;&lt;img style="max-width: 100%; height: auto;" src="/link/517f9dd973394bfc848188feaea668ec.aspx" alt="Creating three child categories in one go" /&gt;&lt;/p&gt;
&lt;p&gt;I repeated that on Financial Services to add Banking and Insurance as a third level.&lt;/p&gt;
&lt;p&gt;&lt;img style="max-width: 100%; height: auto;" src="/link/12cd1c737ecb4a64a7a3ee8a758cad88.aspx" alt="Adding a third level under Financial Services" /&gt;&lt;/p&gt;
&lt;p&gt;The result is an expandable tree.&lt;/p&gt;
&lt;p&gt;&lt;img style="max-width: 100%; height: auto;" src="/link/793c99fd2bf743268154ca9cf5a60e71.aspx" alt="The finished category tree" /&gt;&lt;/p&gt;
&lt;p&gt;A few things from the documentation that are worth calling out, because they will bite you if you do not plan for them:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;You cannot move a category to a different parent after it is created.&lt;/strong&gt; You have to delete it and recreate it. Get the hierarchy agreed before you load it.&lt;/li&gt;
&lt;li&gt;Optimizely recommend keeping trees to &lt;strong&gt;three or four levels&lt;/strong&gt;. I agree. Deeper than that and editors stop being able to find anything.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Visible and Selectable do not cascade.&lt;/strong&gt; Hiding a parent does not hide its children. This is useful, though. Clearing both flags deprecates a category without deleting it. Leaving Visible on and clearing Selectable gives you a "historic" category that still shows on old content but cannot be applied to anything new.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Usage is metadata only.&lt;/strong&gt; Marking a category as Internal does not stop it being indexed or returned by Graph. It is a hint for your front end to filter on, not a security control. You also cannot create a Public child under an Internal parent.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;My advice: deprecate rather than delete. Deleting a category removes it from every piece of content it was tagged on.&lt;/p&gt;
&lt;h2&gt;Translating categories&lt;/h2&gt;
&lt;p&gt;The &lt;strong&gt;Locale&lt;/strong&gt; dropdown at the top of the grid lists the languages enabled for CMS content. Our instance has English as the master, plus Chinese, Spanish and Swedish.&lt;/p&gt;
&lt;p&gt;&lt;img style="max-width: 100%; height: auto;" src="/link/3ce101818a484b71a964c7dcc856ea7e.aspx" alt="The locale switcher on the Categories screen" /&gt;&lt;/p&gt;
&lt;p&gt;Switch to another language and anything without a translation falls back to the master value. It is shown in italics with a small &lt;strong&gt;EN&lt;/strong&gt; badge so you can see at a glance what still needs doing. This fallback behaviour came in with a fix on 7 October. Before that, untranslated rows just showed blank.&lt;/p&gt;
&lt;p&gt;&lt;img style="max-width: 100%; height: auto;" src="/link/4a619cfb1bfa402d8012c6c1496ec3be.aspx" alt="Untranslated categories fall back to English with an EN badge" /&gt;&lt;/p&gt;
&lt;p&gt;Open &lt;strong&gt;... &amp;gt; Edit&lt;/strong&gt; on a row and you get an "Edit espa&amp;ntilde;ol translation" dialog. The master values appear as placeholder text. That placeholder is never saved as a translation, so you will not end up with English silently copied into your Spanish data. The key is greyed out because it is shared across all languages.&lt;/p&gt;
&lt;p&gt;&lt;img style="max-width: 100%; height: auto;" src="/link/699b2e55574841d1bd4d17252d0c4d0c.aspx" alt="Editing the Spanish translation of a category" /&gt;&lt;/p&gt;
&lt;p&gt;Once translated, the tree shows the Spanish names. I translated the whole Demo Industry branch. Note that the grid re-sorts by the translated display name, so rows move about as you work through them.&lt;/p&gt;
&lt;p&gt;&lt;img style="max-width: 100%; height: auto;" src="/link/a08186c57e9d43ddaaca6381cad7f7dd.aspx" alt="The translated Spanish category tree" /&gt;&lt;/p&gt;
&lt;p&gt;Translation is one row at a time for now. Fine for a demo. For a real taxonomy of a few hundred terms across several languages, I would want a bulk option, or I would script it. The 22 September release also lets you ask Mark, the Agent Platform assistant, to create and update categories for you if you have CMS admin rights. I have not tried that for translations yet.&lt;/p&gt;
&lt;h2&gt;Tagging pages with categories&lt;/h2&gt;
&lt;p&gt;This is the part that surprised me. &lt;strong&gt;There is no Category property to add to your content types.&lt;/strong&gt; I went looking under &lt;strong&gt;Add Property&lt;/strong&gt; on our Blog post type and there is nothing taxonomy related in the list. Categories are built in.&lt;/p&gt;
&lt;p&gt;Open any page in the editor and a &lt;strong&gt;Category&lt;/strong&gt; field is already there. Start typing and it searches across the whole tree, showing the full path for each match. That path matters when you have two terms with similar names in different branches.&lt;/p&gt;
&lt;p&gt;&lt;img style="max-width: 100%; height: auto;" src="/link/5f787f7632a5429882946199bb6f4c6f.aspx" alt="Searching for a category on a page shows the full path" /&gt;&lt;/p&gt;
&lt;p&gt;Tick as many as you need. Each one becomes a removable chip.&lt;/p&gt;
&lt;p&gt;&lt;img style="max-width: 100%; height: auto;" src="/link/64da6975a7b24a44bf6d3aaa6b5fe941.aspx" alt="Banking and Retail selected on the page" /&gt;&lt;/p&gt;
&lt;p&gt;Only categories that are both Visible and Selectable show up here, which is exactly what you want for the deprecated and historic cases above.&lt;/p&gt;
&lt;p&gt;Two behaviours from the docs to be aware of. Categories are assigned in the master language, and the localised names appear on translated versions automatically. Categories are also versioned with the content, so a change on a draft only goes live when that draft is published.&lt;/p&gt;
&lt;p&gt;Our Blog post type still has an old "Topic" select list from before this feature existed. If you have something similar, I would plan to migrate it. One managed taxonomy beats a dozen hard-coded option lists scattered across content types.&lt;/p&gt;
&lt;h2&gt;Querying categories in Optimizely Graph&lt;/h2&gt;
&lt;p&gt;I published the demo page and moved over to &lt;strong&gt;Settings &amp;gt; GraphiQL&lt;/strong&gt;. The documentation for this part is thin right now, so everything below comes from introspecting the schema and testing queries.&lt;/p&gt;
&lt;h3&gt;Querying the category tree&lt;/h3&gt;
&lt;p&gt;Categories are indexed as their own items under a new root query: &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;_TaxonomyTerm&lt;/span&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-graphql hljs"&gt;query CategoryTree {
  _TaxonomyTerm(
    locale: en
    where: { _metadata: { taxonomy: { eq: "categories" } } }
    orderBy: { _metadata: { displayName: ASC } }
  ) {
    total
    items {
      _metadata {
        key
        displayName
        parent
        usage
      }
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img style="max-width: 100%; height: auto;" src="/link/79d85a6e14de41a59f9fae1a604cae70.aspx" alt="Querying the category tree in GraphiQL" /&gt;&lt;/p&gt;
&lt;p&gt;The useful fields on &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;_metadata&lt;/span&gt; are &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;key&lt;/span&gt;, &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;displayName&lt;/span&gt;, &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;description&lt;/span&gt;, &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;parent&lt;/span&gt;, &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;usage&lt;/span&gt;, &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;locale&lt;/span&gt;, &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;status&lt;/span&gt; and &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;taxonomy&lt;/span&gt;. &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;parent&lt;/span&gt; holds the parent's key, or &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;null&lt;/span&gt; for a root category. That gives you everything you need to rebuild the tree on the front end.&lt;/p&gt;
&lt;p&gt;&lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;taxonomy&lt;/span&gt; returns &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;"categories"&lt;/span&gt; for everything today. My guess is that it is there so Optimizely can add more than one taxonomy later. Filtering on it now costs nothing and saves a refactor if that happens.&lt;/p&gt;
&lt;p&gt;To get the direct children of a term, filter on &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;parent&lt;/span&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-graphql hljs"&gt;query ChildrenOfIndustry {
  _TaxonomyTerm(
    locale: en
    where: { _metadata: { parent: { eq: "Demo_Industry" } } }
  ) {
    items {
      _metadata {
        key
        displayName
      }
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Localised category names&lt;/h3&gt;
&lt;p&gt;Pass a locale and you get the translated values back:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-graphql hljs"&gt;query LocalisedCategory {
  _TaxonomyTerm(
    locale: sv
    where: { _metadata: { key: { eq: "Demo_Industry" } } }
  ) {
    items {
      _metadata {
        key
        displayName
        description
        locale
      }
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img style="max-width: 100%; height: auto;" src="/link/29476a5b777842a88034a33b2318b7a2.aspx" alt="A Swedish translation returned from Graph" /&gt;&lt;/p&gt;
&lt;p&gt;Two gotchas here.&lt;/p&gt;
&lt;p&gt;First, &lt;strong&gt;the master language fallback you see in the CMS does not apply in Graph.&lt;/strong&gt; I translated the display name into Swedish but left the description empty. The CMS shows the English description in italics. Graph returns &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;null&lt;/span&gt;. If you need a fallback, you have to build it yourself, either with a second query on the master locale or in your front-end mapping.&lt;/p&gt;
&lt;p&gt;Second, &lt;strong&gt;my Spanish translations never appeared in Graph.&lt;/strong&gt; Querying with &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;locale: es&lt;/span&gt; fails validation because &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;es&lt;/span&gt; is not in the schema's &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;Locales&lt;/span&gt; enum.&lt;/p&gt;
&lt;p&gt;&lt;img style="max-width: 100%; height: auto;" src="/link/4669857275f4402ebb576922c7b13389.aspx" alt="Spanish is missing from the Locales enum" /&gt;&lt;/p&gt;
&lt;p&gt;On our instance the only languages in the enum are &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;en&lt;/span&gt;, &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;sv&lt;/span&gt; and &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;zh&lt;/span&gt;. Those are the languages we already have content in. Spanish is enabled in the CMS, but nothing else on the site is published in Spanish. Swedish translations, on the other hand, showed up within ten seconds of saving.&lt;/p&gt;
&lt;p&gt;My working theory is that a language has to exist in Graph's index before category translations in that language are picked up. I have not confirmed that with Optimizely, so treat it as an observation rather than documented behaviour. If your translated categories are not coming through, check the &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;Locales&lt;/span&gt; enum in GraphiQL first.&lt;/p&gt;
&lt;h3&gt;Querying content by category&lt;/h3&gt;
&lt;p&gt;This is the bit you will use most, and it is not where I expected it to be. Categories are &lt;strong&gt;not&lt;/strong&gt; on the usual &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;_metadata&lt;/span&gt; field. They are on a separate field called &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;_itemMetadata&lt;/span&gt;, which exists on every content type in the schema.&lt;/p&gt;
&lt;p&gt;The values are URIs rather than plain keys, in the format &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;cms://taxonomy/categories/&amp;lt;Key&amp;gt;&lt;/span&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-json"&gt;"_itemMetadata": {
  "categories": [
    "cms://taxonomy/categories/Retail",
    "cms://taxonomy/categories/Banking"
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So to find every blog post tagged with Banking or Insurance, and get facet counts back at the same time:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-graphql hljs"&gt;query PagesByCategory {
  BlogPostPage(
    where: {
      _itemMetadata: {
        categories: {
          in: [
            "cms://taxonomy/categories/Banking"
            "cms://taxonomy/categories/Insurance"
          ]
        }
      }
    }
  ) {
    total
    items {
      Heading
      _metadata {
        url {
          default
        }
      }
      _itemMetadata {
        categories
      }
    }
    facets {
      _itemMetadata {
        categories {
          name
          count
        }
      }
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img style="max-width: 100%; height: auto;" src="/link/52279e31b5f545b3a58bad1c54b62a59.aspx" alt="Filtering pages by category with facets" /&gt;&lt;/p&gt;
&lt;p&gt;&lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;eq&lt;/span&gt;, &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;in&lt;/span&gt;, &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;startsWith&lt;/span&gt; and &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;exist&lt;/span&gt; all worked as you would expect on a string array. Swap &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;BlogPostPage&lt;/span&gt; for &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;_Content&lt;/span&gt; and the same filter works across every content type, which is handy for a site-wide "everything tagged Banking" listing.&lt;/p&gt;
&lt;p&gt;The facets come back keyed by URI, so your front end needs to strip the prefix and join to the &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;_TaxonomyTerm&lt;/span&gt; results to get display names. Something like this does the job:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;const keyFromUri = (uri: string) =&amp;gt; uri.split("/").pop() ?? uri;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;No roll-up through the hierarchy&lt;/h3&gt;
&lt;p&gt;This is the gotcha most likely to catch people out. &lt;strong&gt;Filtering on a parent does not return content tagged with its children.&lt;/strong&gt; The demo page is tagged Banking, which sits under Financial Services. Filtering on &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;cms://taxonomy/categories/Financial_Services&lt;/span&gt; returns zero results.&lt;/p&gt;
&lt;p&gt;The hierarchy exists in the taxonomy, but content only knows about the exact terms it was tagged with. If you want "show me everything in Financial Services", you need to:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Query &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;_TaxonomyTerm&lt;/span&gt; to collect the keys of the parent and all its descendants.&lt;/li&gt;
&lt;li&gt;Turn them into URIs.&lt;/li&gt;
&lt;li&gt;Pass the list to an &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;in&lt;/span&gt; filter on &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;_itemMetadata.categories&lt;/span&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Two round trips, but both are cheap and the taxonomy query caches well. Build it once as a helper and reuse it everywhere.&lt;/p&gt;
&lt;h2&gt;Final thoughts&lt;/h2&gt;
&lt;p&gt;This is a good first release. The admin UI is quick to work with, the multi-row create saves real time, and having categories built into every content item removes a pile of modelling work we used to do by hand. The Graph side gives you everything you need. It just needs a bit of plumbing.&lt;/p&gt;
&lt;p&gt;What I would like to see next:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Graph docs that cover &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;_TaxonomyTerm&lt;/span&gt; and &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;_itemMetadata&lt;/span&gt;, including the URI format.&lt;/li&gt;
&lt;li&gt;An option to roll content up through the hierarchy in Graph.&lt;/li&gt;
&lt;li&gt;Master language fallback in Graph to match the CMS.&lt;/li&gt;
&lt;li&gt;Bulk import and export of categories and translations.&lt;/li&gt;
&lt;li&gt;The ability to change a category's parent without deleting it.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you are starting a new SaaS build, I would use categories from day one rather than rolling your own taxonomy. If you have an existing build with select-list or block-based tagging, it is worth planning the migration now, before the content volume makes it painful.&lt;/p&gt;
&lt;h3&gt;Further reading&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.optimizely.com/cms-saas/docs/manage-categories"&gt;Manage categories (CMS SaaS docs)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.optimizely.com/cms-saas/docs/manage-content-content-managers#categorize-content"&gt;Categorise content (CMS SaaS docs)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.optimizely.com/cms-saas/release-notes/2026-cms-saas-release-notes"&gt;2026 CMS (SaaS) release notes&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</summary></entry><entry><title>Exploring Cached Templates and Cursor Pagination in the Optimizely Graph C# SDK</title><link href="https://world.optimizely.com/blogs/mardy-abdelkareem/dates/2026/10/exploring-cached-templates-and-cursor-pagination-in-the-optimizely-graph-c-sdk/" /><id>https://world.optimizely.com/blogs/mardy-abdelkareem/dates/2026/10/exploring-cached-templates-and-cursor-pagination-in-the-optimizely-graph-c-sdk/</id><published>2026-10-19T04:22:59.0000000Z</published><updated>2026-10-19T04:22:59.0000000Z</updated><author><name>mardy.abdelkareem@firstlinesoftware.com</name></author><category term="Blog post" /><summary type="html">&lt;p&gt;While experimenting with the&amp;nbsp;&lt;strong&gt;Optimizely Graph C# SDK (13.3.0)&lt;/strong&gt; in my local &lt;strong&gt;Optimizely CMS 13 Alloy&lt;/strong&gt; project, I came across two interesting findings: how cached templates are handled by the SDK, and how cursor-based pagination differs between typed and untyped queries.&lt;/p&gt;
&lt;p&gt;I used &lt;strong&gt;ILSpy&lt;/strong&gt; to inspect the SDK implementation and &lt;strong&gt;Fiddler&lt;/strong&gt; to verify the outgoing HTTP requests.&lt;/p&gt;
&lt;p&gt;Here are my findings.&lt;/p&gt;
&lt;h2&gt;1. Cached Templates: What Does Enabled by Default Mean?&lt;/h2&gt;
&lt;p&gt;The &lt;a href="https://docs.optimizely.com/graph/docs/cached-templates"&gt;Optimizely Graph cached templates documentation&lt;/a&gt; includes the following note:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Cached templates are enabled by default for queries created on or after January 1, 2025.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;This caught my attention while testing queries through the C# SDK.&lt;/p&gt;
&lt;p&gt;For example, consider this query:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;var query = _graphClient
    .QueryContent&amp;lt;ISearchableContent&amp;gt;()
    .Limit(5)
    .IncludeTotal()
    .Fields&amp;lt;SearchResult&amp;gt;(x =&amp;gt; x.SearchTitle);

var result = await query.GetAsync();&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;After inspecting the outgoing request using Fiddler, I noticed that the SDK sends a request to:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;POST https://cg.optimizely.com/content/v2&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;However, the request didn't include either of the parameters documented for explicitly enabling cached templates:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;?stored=true
cg-stored-query: template&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Looking Inside the SDK&lt;/h3&gt;
&lt;p&gt;Using ILSpy, I inspected the request construction inside &lt;span style="font-family: Consolas, Monaco, monospace; background-color: #f2f3f5; padding: 2px 4px; border-radius: 3px;"&gt;Optimizely.Graph.Core.Client.GraphClient&lt;/span&gt;.&lt;/p&gt;
&lt;p&gt;The relevant code looked like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;var fullQuery = new { query, variables };

var request = new HttpRequestMessage(
    HttpMethod.Post,
    allowCachedResults
        ? "content/v2"
        : "content/v2?cache=false")
{
    Content = JsonContent.Create(fullQuery)
};

AddCustomHeaders(request, customHeaders);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In the execution path I inspected, the SDK didn't explicitly append &lt;span style="font-family: monospace; background-color: #f2f3f5; padding: 2px 4px;"&gt;stored=true&lt;/span&gt; or add the &lt;span style="font-family: monospace; background-color: #f2f3f5; padding: 2px 4px;"&gt;cg-stored-query&lt;/span&gt; header.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Does this mean cached templates aren't enabled?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Not necessarily.&lt;/p&gt;
&lt;p&gt;The documentation describes default behavior, which may be handled by the Graph service itself without requiring these parameters from the client.&lt;/p&gt;
&lt;p&gt;My observation confirms only that the SDK doesn't explicitly send them in this scenario.&lt;/p&gt;
&lt;p&gt;Since the SDK communicates with the hosted Graph service, I wouldn't assume that running CMS 13 locally explains the difference.&lt;/p&gt;
&lt;p&gt;This remains an interesting point to clarify.&lt;/p&gt;
&lt;h2&gt;2. Explicitly Enabling Cached Templates with a DelegatingHandler&lt;/h2&gt;
&lt;p&gt;To experiment with explicit cached-template requests without modifying the SDK package, I used a custom &lt;span style="font-family: monospace; background-color: #f2f3f5; padding: 2px 4px;"&gt;DelegatingHandler&lt;/span&gt;.&lt;/p&gt;
&lt;p&gt;The idea was straightforward: intercept outgoing Graph requests and add the required query parameter and header.&lt;/p&gt;
&lt;h3&gt;Custom HTTP Handler&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;public sealed class GraphCachedTemplateHandler : DelegatingHandler
{
    protected override Task&amp;lt;HttpResponseMessage&amp;gt; SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        if (request.Method == HttpMethod.Post &amp;amp;&amp;amp;
            request.RequestUri?.AbsolutePath.TrimEnd('/') == "/content/v2")
        {
            var uri = new UriBuilder(request.RequestUri);

            var query = uri.Query.TrimStart('?');

            uri.Query = string.IsNullOrEmpty(query)
                ? "stored=true"
                : $"{query}&amp;amp;stored=true";

            request.RequestUri = uri.Uri;

            request.Headers.TryAddWithoutValidation(
                "cg-stored-query", "template");
        }

        return base.SendAsync(request, cancellationToken);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Registering the Handler&lt;/h3&gt;
&lt;p&gt;After registering the Graph SDK, I attached the handler to its named HTTP client:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;services.AddContentGraph();

services.AddTransient&amp;lt;GraphCachedTemplateHandler&amp;gt;();

services
    .AddHttpClient("Optimizely.Graph.Core.Client.Authenticated")
    .AddHttpMessageHandler&amp;lt;GraphCachedTemplateHandler&amp;gt;();&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Verifying with Fiddler&lt;/h3&gt;
&lt;p&gt;After executing the same Graph query, I confirmed the outgoing request:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;POST https://cg.optimizely.com/content/v2?stored=true
cg-stored-query: template&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This demonstrated that I could explicitly request cached-template behavior without changing the SDK source code.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Important:&lt;/strong&gt; This handler applies to matching requests made through the configured HTTP client. It is not a per-query opt-in.&lt;/p&gt;
&lt;h3&gt;What About a Fluent WithCachedTemplate() Extension?&lt;/h3&gt;
&lt;p&gt;I also explored adding a custom extension method to support syntax such as:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;var query = _graphClient
    .QueryContent&amp;lt;ISearchableContent&amp;gt;()
    .Limit(5)
    .WithCachedTemplate()
    .Fields&amp;lt;SearchResult&amp;gt;(x =&amp;gt; x.SearchTitle);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;However, there's an important limitation.&lt;/p&gt;
&lt;p&gt;The SDK's existing fluent methods, such as &lt;span style="font-family: monospace; background-color: #f2f3f5; padding: 2px 4px;"&gt;WithoutCache()&lt;/span&gt;, update internal query state that is later used during execution.&lt;/p&gt;
&lt;p&gt;Although a custom &lt;span style="font-family: monospace; background-color: #f2f3f5; padding: 2px 4px;"&gt;WithCachedTemplate()&lt;/span&gt; extension can store metadata against a query, the SDK's existing &lt;span style="font-family: monospace; background-color: #f2f3f5; padding: 2px 4px;"&gt;GetAsync()&lt;/span&gt; doesn't automatically read that metadata or propagate it to the HTTP layer.&lt;/p&gt;
&lt;p&gt;As a result, the extension method can compile and fit naturally into the fluent API, &lt;strong&gt;but it doesn't change the outgoing HTTP request&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The custom &lt;span style="font-family: monospace; background-color: #f2f3f5; padding: 2px 4px;"&gt;DelegatingHandler&lt;/span&gt; demonstrated earlier works at the HTTP-client level, but it cannot determine which individual query opted into cached templates without additional integration.&lt;/p&gt;
&lt;p&gt;A proper per-query implementation would therefore require an execution wrapper or, ideally, native SDK support to propagate the setting through the execution pipeline.&lt;/p&gt;
&lt;h2&gt;3. Cursor-Based Pagination: Typed vs. Untyped Queries&lt;/h2&gt;
&lt;p&gt;The second finding came while experimenting with cursor-based pagination.&lt;/p&gt;
&lt;p&gt;Initially, I tried to use &lt;span style="font-family: monospace; background-color: #f2f3f5; padding: 2px 4px;"&gt;SetCursor()&lt;/span&gt; with the strongly typed query API:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;var query = _graphClient
    .QueryContent&amp;lt;ISearchableContent&amp;gt;()
    .SetCursor(null);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;However, this doesn't compile because &lt;span style="font-family: monospace; background-color: #f2f3f5; padding: 2px 4px;"&gt;SetCursor()&lt;/span&gt; isn't exposed through the typed &lt;span style="font-family: monospace; background-color: #f2f3f5; padding: 2px 4px;"&gt;ISearchableContentQuery&amp;lt;T&amp;gt;&lt;/span&gt; interface in the SDK version I tested.&lt;/p&gt;
&lt;p&gt;After inspecting the implementation, I found that &lt;span style="font-family: monospace; background-color: #f2f3f5; padding: 2px 4px;"&gt;SetCursor()&lt;/span&gt; is available through the untyped query API:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;_graphClient.Query("ISearchableContent")&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;First Page&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;var query = _graphClient
    .Query("ISearchableContent")
    .SetCursor(null)
    .Limit(5)
    .IncludeTotal()
    .Fields("SearchTitle");

var result = await query.GetAsync();&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Passing &lt;span style="font-family: monospace; background-color: #f2f3f5; padding: 2px 4px;"&gt;null&lt;/span&gt; enables the cursor field without specifying a previous cursor.&lt;/p&gt;
&lt;h3&gt;Next Page&lt;/h3&gt;
&lt;p&gt;For subsequent pages, the cursor returned by the previous response can be passed to &lt;span style="font-family: monospace; background-color: #f2f3f5; padding: 2px 4px;"&gt;SetCursor()&lt;/span&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;var query = _graphClient
    .Query("ISearchableContent")
    .SetCursor(nextCursor)
    .Limit(5)
    .Fields("SearchTitle");

var result = await query.GetAsync();&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here, &lt;span style="font-family: monospace; background-color: #f2f3f5; padding: 2px 4px;"&gt;nextCursor&lt;/span&gt; represents the cursor obtained from the previous response.&lt;/p&gt;
&lt;p&gt;Both requests use the same page size and content selection. The cursor value is &lt;span style="font-family: monospace; background-color: #f2f3f5; padding: 2px 4px;"&gt;null&lt;/span&gt; for the initial request and the cursor returned by the previous response for subsequent requests.&lt;/p&gt;
&lt;h3&gt;Looking Inside SetCursor()&lt;/h3&gt;
&lt;p&gt;The implementation is straightforward:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public IQuery SetCursor(string? cursor)
{
    _queryExpression.IncludeCursor = true;
    _queryExpression.Cursor = cursor;

    return this;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It performs two operations:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Enables inclusion of the cursor in the GraphQL response.&lt;/li&gt;
&lt;li&gt;Assigns the supplied cursor for pagination.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This explains why &lt;span style="font-family: monospace; background-color: #f2f3f5; padding: 2px 4px;"&gt;SetCursor(null)&lt;/span&gt; is useful for the initial request.&lt;/p&gt;
&lt;h3&gt;An SDK API Difference&lt;/h3&gt;
&lt;p&gt;The distinction is worth highlighting:&lt;/p&gt;
&lt;table style="border-collapse: collapse; width: 100%;"&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style="border: 1px solid #dddddd; padding: 8px; text-align: left;"&gt;Query API&lt;/th&gt;
&lt;th style="border: 1px solid #dddddd; padding: 8px; text-align: left;"&gt;Cursor support&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #dddddd; padding: 8px; text-align: left;"&gt;&lt;span style="font-family: monospace; background-color: #f2f3f5; padding: 2px 4px;"&gt;QueryContent&amp;lt;T&amp;gt;()&lt;/span&gt;&lt;/td&gt;
&lt;td style="border: 1px solid #dddddd; padding: 8px; text-align: left;"&gt;&lt;span style="font-family: monospace; background-color: #f2f3f5; padding: 2px 4px;"&gt;SetCursor()&lt;/span&gt; not exposed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #dddddd; padding: 8px; text-align: left;"&gt;&lt;span style="font-family: monospace; background-color: #f2f3f5; padding: 2px 4px;"&gt;Query("TypeName")&lt;/span&gt;&lt;/td&gt;
&lt;td style="border: 1px solid #dddddd; padding: 8px; text-align: left;"&gt;&lt;span style="font-family: monospace; background-color: #f2f3f5; padding: 2px 4px;"&gt;SetCursor()&lt;/span&gt; available&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;This is a difference in the SDK's public fluent interfaces, rather than evidence that the Graph service doesn't support cursor pagination for typed content.&lt;/p&gt;
&lt;h2&gt;4. Final Thoughts&lt;/h2&gt;
&lt;p&gt;These experiments helped me better understand how the Optimizely Graph C# SDK builds and executes queries.&lt;/p&gt;
&lt;p&gt;A few takeaways:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The SDK doesn't explicitly send cached-template parameters in the execution path I inspected, even though the documentation describes cached templates as enabled by default for newer queries.&lt;/li&gt;
&lt;li&gt;A custom &lt;span style="font-family: monospace; background-color: #f2f3f5; padding: 2px 4px;"&gt;DelegatingHandler&lt;/span&gt; can add the documented parameters to outgoing Graph requests.&lt;/li&gt;
&lt;li&gt;A fluent per-query &lt;span style="font-family: monospace; background-color: #f2f3f5; padding: 2px 4px;"&gt;WithCachedTemplate()&lt;/span&gt; extension requires more than storing a flag; the setting must reach the HTTP execution layer.&lt;/li&gt;
&lt;li&gt;Cursor pagination is exposed through the untyped &lt;span style="font-family: monospace; background-color: #f2f3f5; padding: 2px 4px;"&gt;Query("TypeName")&lt;/span&gt; API, while the typed &lt;span style="font-family: monospace; background-color: #f2f3f5; padding: 2px 4px;"&gt;QueryContent&amp;lt;T&amp;gt;()&lt;/span&gt; interface doesn't expose &lt;span style="font-family: monospace; background-color: #f2f3f5; padding: 2px 4px;"&gt;SetCursor()&lt;/span&gt; in version 13.3.0.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These are relatively small observations, but they illustrate why exploring the SDK implementation and inspecting actual HTTP traffic can be useful when working with Graph.&lt;/p&gt;
&lt;p&gt;I'd be interested to hear from the Optimizely Graph team how the documented default behavior for cached templates applies to queries executed through the C# SDK, particularly when the outgoing requests don't explicitly include the cached-template parameters.&lt;/p&gt;
&lt;h2&gt;References&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.optimizely.com/graph/docs/cached-templates"&gt;Optimizely Graph &amp;ndash; Cached Templates&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.optimizely.com/graph/docs/cursor-based-pagination"&gt; Optimizely Graph &amp;ndash; Cursor-Based Pagination &lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</summary></entry><entry><title>EPiServer.ImageLibrary.ImageSharp 2.0.7 delisted from NuGet feed</title><link href="https://world.optimizely.com/blogs/phu-nguyen/dates/2026/10/episerver.imagelibrary.imagesharp-2.0.7-delisted-from-nuget-feed" /><id>https://world.optimizely.com/blogs/phu-nguyen/dates/2026/10/episerver.imagelibrary.imagesharp-2.0.7-delisted-from-nuget-feed</id><published>2026-10-19T01:15:34.0000000Z</published><updated>2026-10-19T01:15:34.0000000Z</updated><author><name>phu.nguyen@optimizely.com</name></author><category term="Blog post" /><summary type="html">&lt;p&gt;We have &lt;strong&gt;delisted &lt;/strong&gt;version &lt;strong&gt;2.0.7&lt;/strong&gt; of the EPiServer.ImageLibrary.ImageSharp package from the NuGet feed due to a packaging issue.&lt;/p&gt;
&lt;p&gt;This version was intended to depend on&amp;nbsp;&lt;strong&gt;SixLabors.ImageSharp 3.2.0&lt;/strong&gt;, but due to a build error, it still depends on &lt;strong&gt;SixLabors.ImageSharp 3.1.12&lt;/strong&gt;, which has a known CVE.&lt;/p&gt;
&lt;p&gt;If you're already using EPiServer.ImageLibrary.ImageSharp 2.0.7, please wait for version &lt;strong&gt;2.0.8&lt;/strong&gt;, which will correctly depend on the patched SixLabors.ImageSharp 3.2.0 and will be released shortly.&lt;/p&gt;
&lt;p&gt;We apologize for the inconvenience and appreciate your understanding!&lt;br /&gt;&lt;br /&gt;&lt;strong&gt;Update Oct-21&lt;/strong&gt;: the package EPiServer.ImageLibrary.ImageSharp 2.0.8 is now available on our public feed https://nuget.optimizely.com/packages/episerver.imagelibrary.imagesharp/2.0.8&lt;/p&gt;</summary></entry><entry><title>Surviving a Dirty Feed. Part 3: Error Handling and Event Batching</title><link href="https://szolkowski.github.io/2026/10/16/surviving-a-dirty-feed-part-3-error-handling-and-event-batching" /><id>https://szolkowski.github.io/2026/10/16/surviving-a-dirty-feed-part-3-error-handling-and-event-batching</id><published>2026-10-16T08:00:00.0000000Z</published><updated>2026-10-16T08:31:08.0000000Z</updated><author><name>stanislaw.szolkowski@firstlinesoftware.com</name></author><category term="Blog post" /><summary type="html">Committing a 250,000-row Optimizely catalog import row by row, returning a per-row failure report, and coalescing catalog events into one per batch.</summary></entry><entry><title>Building UI Extensions for Optimizely SaaS CMS</title><link href="https://world.optimizely.com/blogs/mark-stott/dates/2026/10/building-ui-extensions-for-optimizely-saas-cms/" /><id>https://world.optimizely.com/blogs/mark-stott/dates/2026/10/building-ui-extensions-for-optimizely-saas-cms/</id><published>2026-10-15T16:59:39.0000000Z</published><updated>2026-10-15T16:59:39.0000000Z</updated><author><name>Mark.Stott@netcel.com</name></author><category term="Blog post" /><summary type="html">&lt;h2&gt;Introduction&lt;/h2&gt;
&lt;p&gt;Developers have been extending the functionality of Optimizely PaaS CMS for years using addons built in C# and deployed as NuGet packages. Where you have control of the code for the CMS, this is the optimum approach for seamless integration. As Optimizely SaaS CMS becomes a more common approach for composable architecture, the way we extend the CMS has had to change. To fill this gap, Optimizely have created a new feature for Optimizely Connect Platform (OCP) and SaaS CMS called CMS UI Extensions which is in beta at the time of writing.&lt;/p&gt;
&lt;p&gt;A CMS UI Extension is an application built in &lt;strong&gt;Node.js&lt;/strong&gt; and &lt;strong&gt;JavaScript&lt;/strong&gt; that is deployed into the OCP. From there, your app can be installed into any SaaS CMS that is within your organisation. There are currently three ways that your CMS UI Extension can manifest within your SaaS CMS:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Sidebar&lt;/strong&gt;: Appears within the CMS editor interface within a panel mounted to the right-hand side of the screen.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;View&lt;/strong&gt;: Mounts a standalone full-width page that is accessed via the Extensions option in the left-hand menu.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Property Editor&lt;/strong&gt;: Replaces the built-in editor for a single content property of a declared type.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Setting Up Your Environment&lt;/h2&gt;
&lt;p&gt;To build apps for OCP, you need to be an Optimizely Partner or Customer with Developer access to an OCP environment. You may need to raise a ticket with Optimizely Support to activate the developer functionality for OCP for the first time. From there additional members of your team can be granted developer access by assigning the Developer role for OCP in the Admin Centre.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://www.stott.pro/assets/ocp-developer-portal.png" alt="A screen shot of the OCP Developer Portal" /&gt;&lt;/p&gt;
&lt;p&gt;Once you have access to the OCP Developer Portal, generate an API key for the OCP CLI. Save the key to a credentials file at &lt;code class="language-plaintext highlighter-rouge"&gt;~/.ocp/credentials.json&lt;/code&gt; and then install the CLI:&lt;/p&gt;
&lt;p&gt;Windows:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-Powershell"&gt;# Create the folder
New-Item -Path "~/.ocp" -Name "home dir" -ItemType "directory"

# Create the credentials file
Write-Output '{"apiKey": "&amp;lt;your-api-key&amp;gt;"}' | out-file "~/.ocp/credentials.json" -encoding utf8

# Install OCP CLI
iwr -useb https://cli.ocp.optimizely.com/install.ps1 | iex
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Mac:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-Bash"&gt;# Create the folder
mkdir ~/.ocp (home dir)

# Create the credentials file
echo '{"apiKey": "&amp;lt;your-api-key&amp;gt;"}' &amp;gt; ~/.ocp/credentials.json

# Install OCP CLI
curl -fsSL https://cli.ocp.optimizely.com/install.sh | bash
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You should then be able to verify access with the whoami command:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-Bash"&gt;ocp accounts whoami
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Before Building Your Solution&lt;/h2&gt;
&lt;p&gt;Before you get started with building your application you should look at installing the &lt;a href="https://github.com/optimizely/ocp-skills/"&gt;Optimizely OCP Skills&lt;/a&gt; for your AI coding assistant. Whether you clone these into your own repo or install them as a plugin is going to be your own decision. It does come with 4 skills, 2 of which are migration skills. If you are new to OCP app development, you can focus on the &lt;strong&gt;ocp-app-development&lt;/strong&gt; and &lt;strong&gt;ocp-local-testing&lt;/strong&gt; skills.&lt;/p&gt;
&lt;p style="padding-left: 20pt;"&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; These AI skills know how to build your app, how to register it, how to publish it for developer testing, beta testing and for production review. It is highly recommended that you use them.&lt;/p&gt;
&lt;p&gt;Optimizely have also created a sample OCP CMS UI Extension called &lt;a href="https://github.com/optimizely/unsplash-viewer-cms-ui-extension-ocp-app"&gt;Unsplash Viewer&lt;/a&gt; that has both a &lt;strong&gt;sidebar&lt;/strong&gt; panel and a full-page &lt;strong&gt;view&lt;/strong&gt;. It is worth looking at this sample repository to see how the two entry points are configured and to understand the general architecture of a CMS UI Extension.&lt;/p&gt;
&lt;h2&gt;Thinking About Data&lt;/h2&gt;
&lt;p&gt;When building add-ons for Optimizely PaaS CMS, you had access to your own tables within the CMS database through tooling such as Entity Framework or by using the &lt;a href="https://docs.optimizely.com/cms-12/docs/dynamic-data-store"&gt;Dynamic Data Store&lt;/a&gt;. These options are not available to us when building CMS UI Extensions. OCP does however provide access to the following data stores which are key-value pairs:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Secrets Store&lt;/strong&gt;: Allows the storing of primitive data types only and is your general purpose storage for sensitive data such as API keys. This store is accessible from the backend of your OCP application.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Settings Store&lt;/strong&gt;: Allows the storing of primitive data types only and is the preferred place to store application settings, including sensitive data such as API keys. This data store can be accessed either by the backend of your application or as the settings form directly with the application settings within OCP.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Key Value Store&lt;/strong&gt;: Allows the storing of primitive data types and complex JSON objects for short-term and long-term storage. Do not store sensitive data here. This data store is typically accessed by the backend of your application.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Shared Key Value Store&lt;/strong&gt;: Works like the Key Value Store, but is accessible by both your application and third-party applications.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you are planning to store a lot of data to the Key Value Store, consider co-locating the data as a JSON object. Each entry in the Key Value Store can be up to 400 KB in size and storing data together can reduce the number of API calls needed by your application.&lt;/p&gt;
&lt;p&gt;If the data you are storing to the Key Value Store is intended to have a limited lifespan, e.g. 1 hour, then data should be saved with a TTL (Time to Live). Data saved without a TTL will have a lifespan limited to the application installation.&lt;/p&gt;
&lt;p&gt;If you require data permenance, then you need to consider an alternative place to store your data. The Key Value Store is expected to be rebuildable in the same way that the Content Graph index is expected to be rebuildable. Any value without a TTL will be persisted for the life span of the application. If you need to reinstall or update the application or move it from one regional store to another, then the Key Value Store is completely reset.&lt;/p&gt;
&lt;p&gt;Options for permance should include:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Using SDKs to store your data as content in the CMS or CMP.&lt;/li&gt;
&lt;li&gt;Using a third-party data store accessible via API using an API key stored within the Settings Store.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Reviewing the Stott Security for SaaS CMS Solution&lt;/h2&gt;
&lt;p&gt;Stott Security for SaaS CMS is a CMS UI Extension port of the popular &lt;a href="https://www.stott.pro/article/list/stott-security"&gt;Stott Security&lt;/a&gt; add-on for Optimizely PaaS CMS. This has been built as a full-width page with a publicly accessible endpoint to be consumed by a separate headless frontend.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://www.stott.pro/assets/stott-security-saas-response-headers-screen.png" alt="The Response headers tab of Stott Security for SaaS CMS, showing standard security headers with behaviour, value and preview options." /&gt;&lt;/p&gt;
&lt;h3&gt;Solution Structure&lt;/h3&gt;
&lt;p&gt;The following is a folder and file structure for the Stott Security for SaaS CMS solution. I&amp;rsquo;m only including core files here to prevent information overload, but do check out my solution or the Optimizely Unsplash Viewer example.&lt;/p&gt;
&lt;div class="language-plaintext highlighter-rouge"&gt;
&lt;div class="highlight"&gt;
&lt;pre class="highlight"&gt;&lt;code&gt;- assets
  - icon.svg
  - logo.png
  - directory
    - overview.md
- forms
  - settings.yml
- src
  - backend
    - functions
      - CmsExtension.ts
      - CompiledHeaders.ts
  - cms-ui-extensions
    - SecurityConsole.view.tsx
- app.yml
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;You may notice that the code is split into two separate runtimes, the backend and the CMS UI Extensions:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Frontend
&lt;ul&gt;
&lt;li&gt;Runs within an iFrame hosted within Optimizely CMS&lt;/li&gt;
&lt;li&gt;Code lives in &lt;strong&gt;src/cms-ui-extensions/&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Talks to the backend through &lt;strong&gt;context.extension.invokeFunction()&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Exposes endpoints through &lt;strong&gt;ExtensionContext&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Backend
&lt;ul&gt;
&lt;li&gt;Runs on OCP in the &lt;strong&gt;node22-cms-ext&lt;/strong&gt; context&lt;/li&gt;
&lt;li&gt;Code lives in &lt;strong&gt;src/backend/&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Talks to
&lt;ul&gt;
&lt;li&gt;External APIs over HTTPS&lt;/li&gt;
&lt;li&gt;OCP storage&lt;/li&gt;
&lt;li&gt;Lifecycle APIs&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Understanding the app.yml&lt;/h3&gt;
&lt;p&gt;The app.yml file contains the manifest for your CMS UI Extension. You can see the full YAML file for this solution on &lt;a href="https://github.com/GeekInTheNorth/Stott.Security.Optimizely.SaaS"&gt;GitHub&lt;/a&gt;. The metadata for the application and all of its entry points and functions are declared within this file.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-YML"&gt;meta:
  app_id: stott_security_saas
  display_name: Stott Security
  vendor: mark_stott
  summary: Manage Content Security Policy and response headers for Optimizely CMS (SaaS).
  categories:
    - Content Management
  availability:
    - all

runtime: node22-cms-ext

ui_extensions:
  view:
    - name: stott-security-console
      entry_point: SecurityConsole
      display_name: Stott Security

functions:
  cms_extension:
    entry_point: CmsExtension
    description: Backend for the Stott Security console (draft CRUD and publish).
    accepts: cms_ui_extension

  compiled_headers:
    entry_point: CompiledHeaders
    description: Serves compiled security headers to the site head.

&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;app_id&lt;/strong&gt;: This must be a globally unique name for your application and it is permanent. Changing this name is essentially publishing the application a second time.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;vendor&lt;/strong&gt;: This is your vendor name and must match the vendor associated with your partner or customer organisation.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;runtime&lt;/strong&gt;: This must be &lt;strong&gt;node22-cms-ext&lt;/strong&gt; for a CMS UI Extension and tells OCP that this application will be run in the CMS Extensions runtime operating on Node.js 22.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;ui_extensions&lt;/strong&gt;: This is where you declare your entry points into the CMS and can be declared as:
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;view&lt;/strong&gt;: A full-width embedded page.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;sidebar&lt;/strong&gt;: A sidebar embedded in a panel on the right-hand side of the editor interface.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;property-editor&lt;/strong&gt;: A replacement UI for a specific property type.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;functions&lt;/strong&gt;: This is where you declare your backend functions.
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;entry_point&lt;/strong&gt;: This is the name of your entry point.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;accepts&lt;/strong&gt;: If you set this to &lt;strong&gt;cms_ui_extension&lt;/strong&gt;, then it means the endpoint has no public URL and can only be accessed by the frontend using the &lt;strong&gt;invokeFunction()&lt;/strong&gt; method. Omit this if you want to provide a public endpoint.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;SDKs&lt;/h3&gt;
&lt;p&gt;A CMS UI Extension uses three Optimizely packages, plus a design system for the UI:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href="https://www.npmjs.com/package/@zaiusinc/app-sdk"&gt;@zaiusinc/app-sdk&lt;/a&gt;&lt;/strong&gt; (Backend): The OCP App SDK, which provides the framework for functions, storage and application lifecycle.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href="https://www.npmjs.com/package/@optimizely/ocp-cms-ui-extensions-sdk"&gt;@optimizely/ocp-cms-ui-extensions-sdk&lt;/a&gt;&lt;/strong&gt; (Build and Backend): A plugin for the OCP App SDK that adds support for defining and validating your app.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href="https://www.npmjs.com/package/@optimizely/cms-extensibility-sdk"&gt;@optimizely/cms-extensibility-sdk&lt;/a&gt;&lt;/strong&gt; (Frontend): The SDK for the CMS side of your extension that provides registration, delivery and access to your backend.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href="https://optimizely-axiom.github.io/optiaxiom/"&gt;@optiaxiom/react&lt;/a&gt;&lt;/strong&gt; (Frontend): The React implementation of Optimizely&amp;rsquo;s Axiom design system, which helps your extension look and feel like part of the CMS.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The plugin must be registered within &lt;strong&gt;ocp-app.config.mjs&lt;/strong&gt;, otherwise your &lt;strong&gt;ui_extensions&lt;/strong&gt; declarations will not be recognised and &lt;strong&gt;ocp app validate&lt;/strong&gt; will fail:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-JavaScript"&gt;import { defineConfig } from '@zaiusinc/app-sdk';
import { cmsUiExtensions } from '@optimizely/ocp-cms-ui-extensions-sdk';

export default defineConfig({
  plugins: [cmsUiExtensions()]
});
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Backend&lt;/h3&gt;
&lt;p&gt;Each function declared within the &lt;strong&gt;functions &lt;/strong&gt;section of your &lt;strong&gt;app.yml&lt;/strong&gt; has a matching entry file within &lt;strong&gt;src/backend/functions/&lt;/strong&gt;. The &lt;strong&gt;entry_point &lt;/strong&gt;must match the name of the exported class, and that class must extend &lt;strong&gt;App.Function&lt;/strong&gt; from the &lt;strong&gt;@zaiusinc/app-sdk&lt;/strong&gt; package and implement a &lt;strong&gt;perform()&lt;/strong&gt; method that returns an &lt;strong&gt;App.Response&lt;/strong&gt;. One thing to watch out for is that the status code is the first argument for &lt;strong&gt;App.Response&lt;/strong&gt;, which is the opposite of the standard web Response object.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-TypeScript"&gt;import * as App from '@zaiusinc/app-sdk';

export class HelloWorld extends App.Function {
  public async perform(): Promise&amp;lt;App.Response&amp;gt; {
    return new App.Response(200, { message: 'Hello World' });
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Stott Security for SaaS CMS has two backend functions, one private and one public.&lt;/p&gt;
&lt;h4&gt;CmsExtension.ts&lt;/h4&gt;
&lt;p&gt;This is the private backend for the security console. Because it is declared with &lt;strong&gt;accepts: cms_ui_extension&lt;/strong&gt;, it has no public URL and can only be called by the frontend using &lt;strong&gt;context.extension.invokeFunction()&lt;/strong&gt;. Rather than creating a function per operation, the convention is to have a single function that accepts an action and a set of params and then switches on the action:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-TypeScript"&gt;export class CmsExtension extends App.Function {
  public async perform(): Promise&amp;lt;App.Response&amp;gt; {
    const body = (this.request.bodyJSON ?? {}) as RequestBody;
    const action = typeof body.action === 'string' ? body.action : '';
    const params = body.params ?? {};

    switch (action) {
      case Actions.GetDraft:
        return ok(await this.getDraft(params));

      case Actions.SaveDraft:
        return ok(await this.saveDraft(params));

      case Actions.Publish:
        return ok(await this.publish(params));

      // ... additional actions

      default:
        return fail(400, ErrorCodes.UnknownAction, `Unknown action '${action}'.`);
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This function is responsible for reading and writing the draft configuration, validating it, compiling it into a set of headers and then publishing the compiled headers. All of the data is held as JSON documents within the Key Value Store using &lt;strong&gt;storage.kvStore&lt;/strong&gt; from the app SDK. Saving a draft and publishing are deliberately separate actions as an incorrect Content Security Policy can break a website, so making changes live should be an explicit act by the user.&lt;/p&gt;
&lt;p&gt;Every response is wrapped in a consistent envelope of &lt;strong&gt;{ ok: true, result }&lt;/strong&gt; or &lt;strong&gt;{ ok: false, error, message }&lt;/strong&gt; so that the frontend has a single and predictable way of handling success and failure.&lt;/p&gt;
&lt;p&gt;The function also uses &lt;strong&gt;functions.getEndpoints()&lt;/strong&gt; to resolve the public URL of the &lt;strong&gt;compiled_headers&lt;/strong&gt; function for the current installation. This allows the console to show the editor exactly which URL their website should consume.&lt;/p&gt;
&lt;h4&gt;CompiledHeaders.ts&lt;/h4&gt;
&lt;p&gt;This is the public endpoint that the head of the website calls in order to retrieve the published security headers. As &lt;strong&gt;accepts &lt;/strong&gt;is omitted within the &lt;strong&gt;app.yml&lt;/strong&gt;, OCP gives this function a public URL which contains a unique identifier for the installation. This matters because a regular &lt;strong&gt;App.Function&lt;/strong&gt; resolves the installation from that URL and can therefore read the same Key Value Store that &lt;strong&gt;CmsExtension &lt;/strong&gt;writes to. If this were declared as a global function, it would have no installation context and could only reach the Shared Key Value Store.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-TypeScript"&gt;export class CompiledHeaders extends App.Function {
  public async perform(): Promise&amp;lt;App.Response&amp;gt; {
    if (this.request.method !== 'GET') {
      return new App.Response(405, { error: 'Only GET is supported.' });
    }

    try {
      const compiled = await readCompiled(scope);

      return new App.Response(200, {
        headers: compiled?.headers ?? [],
        publishedAt: compiled?.publishedAt ?? null,
        cacheSeconds: CACHE_SECONDS
      });
    } catch (error) {
      logger.error(`Failed to read compiled headers: ${String(error)}`);

      return new App.Response(503, { error: 'Compiled headers are temporarily unavailable.' });
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A few design decisions are worth highlighting here:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The endpoint only reads the &lt;strong&gt;compiled&lt;/strong&gt; headers, all of the work to compile the configuration happens once on publish rather than on every request.&lt;/li&gt;
&lt;li&gt;The response includes a &lt;strong&gt;cacheSeconds &lt;/strong&gt;value to encourage the consuming website to cache the response. Without this, every page request on the head would result in a request to OCP.&lt;/li&gt;
&lt;li&gt;Having nothing published yet returns an empty array with a &lt;strong&gt;200 &lt;/strong&gt;status so the head can apply its own defaults, while a genuine failure returns a &lt;strong&gt;503 &lt;/strong&gt;so the head can tell the difference and fall back gracefully.&lt;/li&gt;
&lt;li&gt;The response contract is identical to the compiled headers endpoint in the PaaS version of Stott Security, allowing a single consumer implementation to work with either product.&lt;/li&gt;
&lt;li&gt;The endpoint is anonymous by design. These headers are visible on every response from the website anyway, but this is a deliberate decision and you should consider carefully what data you expose from any public function. If your data has privacy needs, then consider requiring an API Key.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;📰 Please note that I will cover consuming this endpoint in a headless solution in a future article so as to not detract from the topic of this article.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;Frontend&lt;/h3&gt;
&lt;p&gt;Each entry within the &lt;strong&gt;ui_extensions &lt;/strong&gt;section of your &lt;strong&gt;app.yml&lt;/strong&gt; has a matching entry file within &lt;strong&gt;src/cms-ui-extensions/&lt;/strong&gt;. For Stott Security for SaaS CMS, this is a single view with an entry point of &lt;strong&gt;SecurityConsole&lt;/strong&gt;, which maps to &lt;strong&gt;SecurityConsole.view.tsx&lt;/strong&gt;. The frontend is built with React and is rendered within an iFrame inside of the CMS.&lt;/p&gt;
&lt;p&gt;Your CMS UI Extension may have multiple entry points that manifest within the CMS in different ways. The naming convention for a UI entry file is as follows:&lt;/p&gt;
&lt;div class="language-plaintext highlighter-rouge"&gt;
&lt;div class="highlight"&gt;
&lt;pre class="highlight"&gt;&lt;code&gt;&amp;lt;Name&amp;gt;.&amp;lt;injection-point&amp;gt;.{ts,tsx,js,jsx}

// Examples
MyExtension.view.tsx
MyExtension.sidebar.ts
MyExtension.property-editor.js
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h4&gt;SecurityConsole.view.tsx&lt;/h4&gt;
&lt;p&gt;The most important line in this file is at the very bottom. The &lt;strong&gt;register() &lt;/strong&gt;method from the &lt;strong&gt;@optimizely/cms-extensibility-sdk&lt;/strong&gt; package is how your extension is mounted into the CMS. This must be called exactly once at the top level of the module and never inside of a component or effect. The CMS passes an &lt;strong&gt;ExtensionContext &lt;/strong&gt;into the callback, which you then pass down into your components:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-TypeScript"&gt;import { register, type ExtensionContext } from '@optimizely/cms-extensibility-sdk';

function SecurityConsole({ context }: { context: ExtensionContext }): React.JSX.Element {
  useEffect(() =&amp;gt; {
    void context.extension.setReady();
  }, [context]);

  // ... render the console
}

register((context) =&amp;gt; &amp;lt;SecurityConsole context={context} /&amp;gt;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;strong&gt;ExtensionContext &lt;/strong&gt;is your bridge between the frontend and both the CMS and your backend. Within the console, this is used for two key purposes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;context.extension.setReady()&lt;/strong&gt; is called once the component has mounted to let the CMS know that the extension has loaded.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;context.extension.invokeFunction() &lt;/strong&gt;is used to call the private &lt;strong&gt;CmsExtension &lt;/strong&gt;backend function.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;All backend calls are wrapped within a small typed client, so the components never need to know about the transport:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-TypeScript"&gt;const result = await context.extension.invokeFunction('cms_extension', { action, params });
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A nice benefit of this approach is that there are no URLs, authentication cookies, CORS rules or CSRF tokens to manage within the frontend. The call never leaves the CMS host and OCP takes care of routing it to your backend function. It is worth noting that &lt;strong&gt;invokeFunction()&lt;/strong&gt; will only reject on a transport failure, application errors are returned as a normal response with a non-2xx status code, so you will want to handle both.&lt;/p&gt;
&lt;p&gt;The &lt;strong&gt;ExtensionContext &lt;/strong&gt;offers more than the console needs. Which members are available depends on where your extension manifests within the CMS:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;context.extension&lt;/strong&gt;: Available to all extensions.
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;setReady()&lt;/strong&gt; tells the CMS that your extension has finished loading. The CMS may show a loading indicator until this is called.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;getDefinition()&lt;/strong&gt; returns the id, display name, type and configuration of your extension.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;invokeFunction(functionId, parameters)&lt;/strong&gt; calls a backend function. The &lt;strong&gt;functionId &lt;/strong&gt;is the key under &lt;strong&gt;functions &lt;/strong&gt;in your &lt;strong&gt;app.yml&lt;/strong&gt; (e.g. &lt;strong&gt;cms_extension&lt;/strong&gt;) and not the class name.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;context.content&lt;/strong&gt;: Only meaningful within a &lt;strong&gt;sidebar&lt;/strong&gt;.
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;get()&lt;/strong&gt; returns a snapshot of the content currently being edited, including its key, version, locale and public URL.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;subscribe(callback)&lt;/strong&gt; notifies you when the content being edited changes.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;context.property&lt;/strong&gt;: Only bound within a &lt;strong&gt;property-editor&lt;/strong&gt;.
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;get()&lt;/strong&gt; returns the current value of the property, whether it is read-only and any property settings.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;set(value)&lt;/strong&gt; updates the value of the property. This resolves to &lt;strong&gt;{ success, error } &lt;/strong&gt;rather than throwing an error, so make sure you check success.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;subscribe(callback)&lt;/strong&gt; notifies you when the value of the property changes.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For both &lt;strong&gt;content &lt;/strong&gt;and &lt;strong&gt;property&lt;/strong&gt;, the &lt;strong&gt;subscribe()&lt;/strong&gt; method only fires on subsequent changes. You should call &lt;strong&gt;get()&lt;/strong&gt; to read the initial value and then &lt;strong&gt;subscribe()&lt;/strong&gt; to receive later changes, otherwise your component will remain empty until something changes. The &lt;strong&gt;subscribe()&lt;/strong&gt; method also returns a function that you should call to unsubscribe when your component unmounts.&lt;/p&gt;
&lt;p&gt;The rest of the file is a fairly standard React component. The UI is built with &lt;a href="https://www.npmjs.com/package/@optiaxiom/react"&gt;Optimizely Axiom&lt;/a&gt; (&lt;strong&gt;@optiaxiom/react&lt;/strong&gt;), which is Optimizely&amp;rsquo;s own design system, and helps the extension look and feel like a native part of the CMS. Every Axiom component reads its design tokens from the &lt;strong&gt;AxiomProvider&lt;/strong&gt;, so this needs to wrap your entire extension or the components will render unstyled.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-TypeScript"&gt;function Shell({ children }: { children: React.ReactNode }): React.JSX.Element {
  return (
    &amp;lt;AxiomProvider&amp;gt;
      &amp;lt;main&amp;gt;{children}&amp;lt;/main&amp;gt;
    &amp;lt;/AxiomProvider&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There are a few other things to be aware of when building a &lt;strong&gt;view&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;view &lt;/strong&gt;is a single mounted bundle. For my multi-tab interface I manage what is visible through component state rather than using a client side router.&lt;/li&gt;
&lt;li&gt;The CMS already renders a title for your extension, so there is no need to repeat it within your own UI.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;ExtensionContext &lt;/strong&gt;does not currently carry the identity of the user, so you cannot rely on it for attributing actions to a named editor.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;App Directory Information&lt;/h3&gt;
&lt;p&gt;The assets folder of your repository is used to provide additional information for when your app is added to the app directory:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;assets/logo.png&lt;/strong&gt; and &lt;strong&gt;assets/icon.svg&lt;/strong&gt; provide a logo to be used both within the card on the App Directory and within the summary card on the app page.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;assets/directory/overview.md&lt;/strong&gt; provides the content to be displayed in the Overview tab within OCP.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src="https://www.stott.pro/assets/ocp-cms-ui-extensions-1.png" alt="The CMS UI Extensions application detail screen within OCP." /&gt;&lt;/p&gt;
&lt;h3&gt;Application Settings&lt;/h3&gt;
&lt;p&gt;Your app must have a &lt;strong&gt;forms/settings.yml&lt;/strong&gt; which is used to declare the settings form within OCP for your app. This allows you to manage data within the Settings Store for your installation of the app directly within OCP.&lt;/p&gt;
&lt;p&gt;As Stott Security for SaaS CMS does not require any data from the Settings Store, my entry contains a single information entry:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-YML"&gt;sections:
  - key: about
    label: Stott Security
    elements:
      - type: instructions
        text: &amp;gt;
          Everything is configured in the security console...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Other options you can add to your settings.yml include the following, you can see a full description and examples of each option by viewing &lt;a href="https://docs.optimizely.com/optimizely-connect-platform/docs/form-elements-ocp2"&gt;OCP Form Elements&lt;/a&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Toggle&lt;/strong&gt;: An option you can toggle on or off.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Text&lt;/strong&gt;: A plain input box, additional options can be set to change the input type to text, email, number or phone.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Secret&lt;/strong&gt;: A secure field for sensitive data that will be displayed as asterisks. Data added to one of these fields cannot be retrieved through the settings form interface.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Select&lt;/strong&gt;: A single-select field that renders differently based on the number of options provided.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Multi-Select&lt;/strong&gt;: A multi-select field that renders differently based on the number of options provided.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Button&lt;/strong&gt;: Allows for an action to be triggered within the form e.g. &amp;ldquo;Begin Integration&amp;rdquo;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Link Button&lt;/strong&gt;: Allows for a link to an external URL and supports Mustache syntax for URL variation.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;OAuth Button&lt;/strong&gt;: Supports authentication with a third party in order to support integrations.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Divider&lt;/strong&gt;: Renders a simple divider mid-form.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Instructions&lt;/strong&gt;: Supports instructions to be displayed to the user written in Markdown.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Summary&lt;/h2&gt;
&lt;p&gt;CMS UI Extensions are a significant leap forward for Optimizely SaaS CMS. For the first time, we can extend the SaaS editor interface in the same way that we have been extending PaaS CMS with add-ons for years, whether that is a full-width tool, a sidebar that works alongside the editor or a custom property editor.&lt;/p&gt;
&lt;p&gt;The technology choice also makes a lot of sense. CMS UI Extensions are built with Node.js &amp;amp; JavaScript potentially with frameworks such as TypeScript and React. These are the same tools that developers building headless solutions on SaaS CMS are already using every day. This means the barrier to entry is low for the teams most likely to want to build them.&lt;/p&gt;
&lt;p&gt;There are still some challenges to work through. Data storage is limited to key value stores that are tied to the lifespan of the application installation, so anything that needs permenance requires some careful thought. As this is a new feature, best practices are also still emerging.&lt;/p&gt;
&lt;p&gt;Overall, CMS UI Extensions are a really good addition to Optimizely SaaS CMS, and I&amp;rsquo;m looking forward to seeing what the community builds with them. If you are about to build your first CMS UI Extension, here are a few tips to help you get started:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Install the &lt;a href="https://github.com/optimizely/ocp-skills/"&gt;Optimizely OCP Skills&lt;/a&gt; as this will help your AI assistant to structure your application correctly.&lt;/li&gt;
&lt;li&gt;Review Optimizely&amp;rsquo;s example CMS UI Extension: &lt;a href="https://github.com/optimizely/unsplash-viewer-cms-ui-extension-ocp-app"&gt;Unsplash Viewer&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Consider app ownership and naming very carefully. App names must be unique and are permanent.&lt;/li&gt;
&lt;li&gt;Consider whether you need data permanence and where that data will live.&lt;/li&gt;
&lt;li&gt;Build for performance and leverage caching wherever possible.&lt;/li&gt;
&lt;li&gt;If you are using the Key Value Store, co-locate your data as JSON to reduce the number of API calls.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Appendix&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.optimizely.com/optimizely-connect-platform/docs/cms-ui-extensions-ocp2"&gt;Optimizely Docs : CMS UI extensions&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.optimizely.com/optimizely-connect-platform/docs/get-started-with-the-ocp2-developer-platform"&gt;Optimizely Docs : Get started with the OCP developer platform&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.optimizely.com/optimizely-connect-platform/docs/configure-your-development-environment-ocp2"&gt;Optimizely Docs : Configure your OCP development environment&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.optimizely.com/optimizely-connect-platform/docs/form-elements-ocp2"&gt;Optimizely Docs : OCP Form Elements&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/optimizely/ocp-skills/"&gt;GitHub : Optimizely OCP Skills&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/optimizely/unsplash-viewer-cms-ui-extension-ocp-app"&gt;GitHub : Unsplash Viewer&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/GeekInTheNorth/Stott.Security.Optimizely.SaaS"&gt;GitHub : Stott Security for SaaS CMS&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;p&gt;I'm a Platinum Optimizely MVP and the maintainer of two open source add-ons: Stott Security for &lt;a href="https://github.com/GeekInTheNorth/Stott.Security.Optimizely.SaaS"&gt;SaaS CMS&lt;/a&gt; and &lt;a href="https://github.com/GeekInTheNorth/Stott.Security.Optimizely"&gt;PaaS CMS 12 &amp;amp; 13&lt;/a&gt;, and &lt;a href="https://github.com/GeekInTheNorth/Stott.Optimizely.RobotsHandler"&gt;Stott Robots Handler&lt;/a&gt; for PaaS CMS 12 &amp;amp; 13. All of my writing is collated at &lt;a href="https://www.stott.pro/"&gt;https://www.stott.pro&lt;/a&gt;.&lt;/p&gt;</summary></entry><entry><title>Cheapskate AI Translator: Because Translation Should Not Require a Second Mortgage</title><link href="https://world.optimizely.com/blogs/linh-doan-cuu/dates/2026/10/cheapskate-ai-translator-because-translation-should-not-require-a-second-mortgage/" /><id>https://world.optimizely.com/blogs/linh-doan-cuu/dates/2026/10/cheapskate-ai-translator-because-translation-should-not-require-a-second-mortgage/</id><published>2026-10-15T08:06:38.0000000Z</published><updated>2026-10-15T08:06:38.0000000Z</updated><author><name>linh.doan@optimizely.com</name></author><category term="Blog post" /><summary type="html">&lt;p&gt;There is a particular kind of software project that begins with a detailed architecture document, a product roadmap, and a carefully assembled budget.&lt;/p&gt;
&lt;p&gt;This is not that kind of project.&lt;/p&gt;
&lt;p&gt;This project began with a simpler question:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Can I create language versions in Optimizely CMS without paying more for an LLM than I absolutely have to?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The answer is now an open source add-on: &lt;strong&gt;Cheapskate.AITranslator&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;You can find the source code on &lt;em&gt;&lt;a href="https://github.com/birgitzprinz/Cheapskate.AITranslator"&gt;GitHub&lt;/a&gt; &lt;/em&gt;and install the published package from the&lt;em&gt; &lt;a href="https://nuget.optimizely.com/packages/cheapskate.aitranslator/"&gt;Optimizely NuGet feed&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;The honest motivation&lt;/h2&gt;
&lt;p&gt;I am a cheapskate.&lt;/p&gt;
&lt;p&gt;Not in the charming, coupon-clipping sense. In the practical engineering sense. If a free tier, a generous quota, or a small amount of careful request batching can do the job, I would prefer that to adding another monthly AI bill to the household budget.&lt;/p&gt;
&lt;p&gt;The goal is not to avoid paying for good services forever. The goal is to pay as little as I can get away with while still delivering useful translations and a maintainable CMS workflow.&lt;/p&gt;
&lt;p&gt;That means the add-on is designed around choice:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Google Gemini-compatible chat completions&lt;/li&gt;
&lt;li&gt;GroqCloud-compatible chat completions&lt;/li&gt;
&lt;li&gt;Microsoft Azure Translator&lt;/li&gt;
&lt;li&gt;DeepL API Free&lt;/li&gt;
&lt;li&gt;Custom OpenAI-compatible providers&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Some providers offer free tiers, some offer free quotas, and some are inexpensive enough to be useful when a free option is not appropriate. The add-on does not assume that one provider, one pricing model, or one API will remain the best choice forever.&lt;/p&gt;
&lt;h2&gt;What the add-on does&lt;/h2&gt;
&lt;p&gt;Optimizely content editors can create draft language versions from the editor and choose whether to translate them with AI after the drafts are created.&lt;/p&gt;
&lt;p&gt;The add-on can:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Create all missing language branches or one selected target language&lt;/li&gt;
&lt;li&gt;Keep the new versions as drafts rather than publishing them&lt;/li&gt;
&lt;li&gt;Optionally follow linked content in &lt;span style="font-family: 'courier new', courier, monospace;"&gt;ContentArea&lt;/span&gt; properties&lt;/li&gt;
&lt;li&gt;Translate language-specific text and Rich Text content&lt;/li&gt;
&lt;li&gt;Preserve HTML structure while translating visible text&lt;/li&gt;
&lt;li&gt;Run translation in the background after draft creation&lt;/li&gt;
&lt;li&gt;Use different providers through one CMS settings screen&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The workflow is intentionally modest. It creates drafts, starts translation work, and reports the job status. It does not pretend that an automated translation should bypass editorial review.&lt;/p&gt;
&lt;p&gt;That last part is important. A translation engine can be very good and still occasionally produce something that makes a human editor stare at the screen for a while. Drafts are a useful place for that staring to happen.&lt;/p&gt;
&lt;h2&gt;The small trick that saves API calls&lt;/h2&gt;
&lt;p&gt;The add-on can combine multiple translatable properties into one JSON request instead of making one API request per property.&lt;/p&gt;
&lt;p&gt;Without batching, a page with a title, teaser, body, call to action, and several blocks can turn into a small parade of API calls. That is inefficient, costs more, and can hit provider rate limits faster than necessary.&lt;/p&gt;
&lt;p&gt;With batching enabled, the worker groups translation entries into bounded requests according to configured input and output token limits. The provider receives a structured payload, translates the values, and returns the translated entries for the background worker to write back to the draft.&lt;/p&gt;
&lt;p&gt;In plain English:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Fewer trips to the API means fewer opportunities for the API to say, "Please slow down."&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Batching does not remove provider limits, and it does not make an unlimited service out of a limited one. It simply uses the available quota more sensibly. The request-size limits remain configurable because different providers have different capabilities and pricing rules.&lt;/p&gt;
&lt;h2&gt;Why support several providers?&lt;/h2&gt;
&lt;p&gt;Because depending on one provider is an excellent way to discover that provider's outage page at the worst possible moment.&lt;/p&gt;
&lt;p&gt;More seriously, different projects have different constraints:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A project may already have an Azure subscription and prefer Azure Translator.&lt;/li&gt;
&lt;li&gt;A developer may want to use a free Gemini or GroqCloud quota.&lt;/li&gt;
&lt;li&gt;A team may prefer DeepL for a particular language pair.&lt;/li&gt;
&lt;li&gt;An organization may have its own OpenAI-compatible gateway.&lt;/li&gt;
&lt;li&gt;A provider's model, pricing, quota, or terms may change.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The add-on keeps the CMS workflow stable while allowing the translation backend to change. Provider credentials and options live in CMS settings rather than in source control, and the custom provider option gives developers room to connect a compatible internal service.&lt;/p&gt;
&lt;h2&gt;This is not Optimizely Language Manager&lt;/h2&gt;
&lt;p&gt;A very important clarification: &lt;strong&gt;Cheapskate.AITranslator is not intended to replace Optimizely's official Language Manager add-on.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Language Manager is the official product for teams that need its supported translation-management workflow, integrations, governance, and product support. This add-on has a narrower and more developer-oriented purpose.&lt;/p&gt;
&lt;p&gt;Cheapskate.AITranslator brings the implementation into the developer's control. The source code is available, the behavior can be inspected, and the add-on can be customized for a particular Optimizely CMS solution.&lt;/p&gt;
&lt;p&gt;That may mean changing:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Which content properties are translated&lt;/li&gt;
&lt;li&gt;How linked content is traversed&lt;/li&gt;
&lt;li&gt;How prompts are written&lt;/li&gt;
&lt;li&gt;Which provider is called&lt;/li&gt;
&lt;li&gt;How batches are sized&lt;/li&gt;
&lt;li&gt;How translation jobs are queued or reported&lt;/li&gt;
&lt;li&gt;Which editorial safeguards are required&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If your project needs an officially supported translation-management product, use Language Manager. If your project needs a small, inspectable starting point that your team can adapt, this add-on may be useful.&lt;/p&gt;
&lt;p&gt;It is not a product-versus-product argument. It is a choice between a supported official solution and a source-controlled developer tool with a smaller scope.&lt;/p&gt;
&lt;h2&gt;Open source is the point&lt;/h2&gt;
&lt;p&gt;The most valuable part of this add-on is not the skate icon or the settings page. It is the ability to see what happens between clicking &lt;strong&gt;Create Language Versions&lt;/strong&gt; and saving a translated draft.&lt;/p&gt;
&lt;p&gt;Developers can review the code, change the translation rules, add provider behavior, adjust batching, and fit the workflow to their own content model. The add-on is deliberately not a sealed box that asks everyone to accept the same assumptions.&lt;/p&gt;
&lt;p&gt;That also means responsibility comes with the freedom:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Review generated translations before publishing.&lt;/li&gt;
&lt;li&gt;Protect provider credentials.&lt;/li&gt;
&lt;li&gt;Check provider terms and quotas.&lt;/li&gt;
&lt;li&gt;Test prompts and content types in a non-production environment.&lt;/li&gt;
&lt;li&gt;Keep the add-on aligned with the CMS version used by the project.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Cheap is good. Cheap and mysterious is less good.&lt;/p&gt;
&lt;h2&gt;Getting started&lt;/h2&gt;
&lt;p&gt;The project targets Optimizely CMS 13 and .NET 10 on the &lt;span style="font-family: 'courier new', courier, monospace;"&gt;main&lt;/span&gt; branch. The legacy CMS 12 implementation remains on the&amp;nbsp;&lt;span style="font-family: 'courier new', courier, monospace;"&gt;CMS-12-base&lt;/span&gt; branch.&lt;/p&gt;
&lt;p&gt;Install the CMS 13 package with:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-powershell"&gt;dotnet add .\YourCmsProject.csproj package Cheapskate.AITranslator --version 2.0.0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then configure a provider under &lt;strong&gt;Settings &amp;gt; AI Translator&lt;/strong&gt;, open localizable content in the editor, and use the &lt;strong&gt;Create Language Versions&lt;/strong&gt; command.&lt;/p&gt;
&lt;p&gt;The resulting versions are drafts. The AI translation runs in the background when enabled, and the editor remains responsible for the final content review.&lt;/p&gt;
&lt;h2&gt;Final accounting&lt;/h2&gt;
&lt;p&gt;The philosophy is simple:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Use the provider that fits the project.&lt;/li&gt;
&lt;li&gt;Use free quotas where they are enough.&lt;/li&gt;
&lt;li&gt;Batch requests so the quota lasts longer.&lt;/li&gt;
&lt;li&gt;Keep the workflow in developer-controlled source code.&lt;/li&gt;
&lt;li&gt;Spend money when spending money is actually justified.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;That is the entire cheapskate strategy. It is not glamorous, but neither is discovering a surprisingly large AI invoice after a bulk translation job.&lt;/p&gt;
&lt;p&gt;Cheapskate.AITranslator is open source under the MIT license: a small Optimizely add-on for teams that want draft language creation, optional AI translation, multiple provider choices, and a little more control over where the money goes.&lt;/p&gt;</summary></entry><entry><title>Computed Fields in CMS 13 Content Contracts with Optimizely Graph</title><link href="https://world.optimizely.com/blogs/mardy-abdelkareem/dates/2026/10/computed-fields-in-cms-13-content-contracts-a-practical-guide-with-optimizely-graph-c-sdk/" /><id>https://world.optimizely.com/blogs/mardy-abdelkareem/dates/2026/10/computed-fields-in-cms-13-content-contracts-a-practical-guide-with-optimizely-graph-c-sdk/</id><published>2026-10-14T05:01:48.0000000Z</published><updated>2026-10-14T05:01:48.0000000Z</updated><author><name>mardy.abdelkareem@firstlinesoftware.com</name></author><category term="Blog post" /><summary type="html">&lt;p&gt;Content Contracts in Optimizely CMS 13 allow different content types to share a common structure when querying Optimizely Graph.&lt;/p&gt;
&lt;p&gt;But what if the shared field doesn't exist as a stored CMS property, and its value needs to be calculated differently depending on the content type?&lt;/p&gt;
&lt;p&gt;For example, imagine a search feature that includes articles, events, and images:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Articles use &lt;strong&gt;Heading&lt;/strong&gt; as their search title.&lt;/li&gt;
&lt;li&gt;Events use &lt;strong&gt;EventName&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Images use &lt;strong&gt;ImageTitle&lt;/strong&gt;, falling back to their name.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;We want all three to expose a common &lt;strong&gt;SearchTitle&lt;/strong&gt; field in Graph, without adding another persisted property to each content type.&lt;/p&gt;
&lt;p&gt;Here's a working approach tested with &lt;strong&gt;Optimizely CMS 13.3.0&lt;/strong&gt;.&lt;/p&gt;
&lt;h2&gt;1. Define a Content Contract&lt;/h2&gt;
&lt;p&gt;First, define a Content Contract with a method that each implementing content type uses to provide its search title.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[ContentType]
public interface ISearchableContent : IContentData
{
    string GetSearchTitle();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each content type implements the same contract but calculates the search title using its own properties. For example, an article page uses &lt;strong&gt;Heading&lt;/strong&gt;, while an event page uses &lt;strong&gt;EventName&lt;/strong&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public class ArticlePage : PageData, ISearchableContent
{
    public virtual string Heading { get; set; }

    public string GetSearchTitle() =&amp;gt;
        string.IsNullOrWhiteSpace(Heading) ? Name : Heading;
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;public class EventPage : PageData, ISearchableContent
{
    public virtual string EventName { get; set; }

    public string GetSearchTitle() =&amp;gt;
        string.IsNullOrWhiteSpace(EventName) ? Name : EventName;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Both content types implement the same contract but calculate &lt;strong&gt;SearchTitle&lt;/strong&gt; differently. The same approach works for media types, such as images, using their own properties.&lt;/p&gt;
&lt;p&gt;Make sure the relevant content types implement or are associated with the Content Contract in CMS so they can be queried together in Graph.&lt;/p&gt;
&lt;p&gt;This keeps the calculation close to each content type and avoids maintaining a central switch statement.&lt;/p&gt;
&lt;h2&gt;2. Expose the Computed Field in Graph&lt;/h2&gt;
&lt;p&gt;Next, create an extension method and register it using Graph indexing conventions.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public static class SearchExtensions
{
    public static string SearchTitle(this ISearchableContent content)
        =&amp;gt; content.GetSearchTitle();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Register the computed field:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;services.AddContentGraph(configureConventions: conventions =&amp;gt;
{
    conventions.ForInstancesOf&amp;lt;ISearchableContent&amp;gt;()
        .IncludeField(
            x =&amp;gt; x.SearchTitle(),
            IndexingType.Searchable);
});&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The important detail is that &lt;strong&gt;SearchTitle is computed during indexing&lt;/strong&gt;. We don't need to add a persisted SearchTitle property to our CMS models.&lt;/p&gt;
&lt;p&gt;The resulting Graph schema exposes the field through &lt;strong&gt;ISearchableContent&lt;/strong&gt;, regardless of the underlying content type.&lt;/p&gt;
&lt;h2&gt;3. Query It with the Graph C# SDK&lt;/h2&gt;
&lt;p&gt;The interesting part is that the computed field can also be used in strongly typed SDK queries.&lt;/p&gt;
&lt;p&gt;First, define a lightweight result model:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public class SearchResult
{
    public string SearchTitle { get; set; }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then query the Content Contract:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;var query = graphClient
    .QueryContent&amp;lt;ISearchableContent&amp;gt;()
    .Where(x =&amp;gt; x.SearchTitle().Contains("Optimizely"))
    .Limit(20)
    .IncludeTotal()
    .Fields&amp;lt;SearchResult&amp;gt;(x =&amp;gt; x.SearchTitle);

var result = await query.GetAsync();&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In our test, the SDK translated the filtering expression into GraphQL:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;query ISearchableContentQuery {
  ISearchableContent(
    where: { SearchTitle: { contains: "Optimizely" } }
    limit: 20
  ) {
    items {
      SearchTitle
    }
    total(all: true)
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The query returned one matching event with &lt;strong&gt;SearchTitle&lt;/strong&gt; equal to &lt;strong&gt;Optimizely Developer Webinar&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Here's the interesting detail: &lt;strong&gt;SearchTitle()&lt;/strong&gt; is a C# extension method, not a stored CMS property. Graph indexing conventions expose it as a searchable field, and the Graph C# SDK translates the expression into a server-side GraphQL filter rather than filtering an in-memory collection.&lt;/p&gt;
&lt;p&gt;We also verified sorting by the computed field, language-specific values, and recalculation after updating and publishing the underlying content.&lt;/p&gt;
&lt;h2&gt;Key Takeaway&lt;/h2&gt;
&lt;p&gt;By combining &lt;strong&gt;CMS 13 Content Contracts, Graph indexing conventions, and the Graph C# SDK&lt;/strong&gt;, we can expose a shared searchable field across different content types while keeping the calculation specific to each type.&lt;/p&gt;
&lt;p&gt;No additional persisted CMS properties are required.&lt;/p&gt;
&lt;p&gt;Keep computed field calculations lightweight and deterministic, since they run as part of the indexing process.&lt;/p&gt;
&lt;h3&gt;References&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.optimizely.com/cms-13/docs/indexing-conventions"&gt;Optimizely CMS 13 &amp;mdash; Indexing Conventions&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="/link/5ffa0c0d7b184f049358e62d7fcad94f.aspx"&gt;Introducing Optimizely CMS 13 Graph SDK &amp;mdash; Jake Minard&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</summary></entry><entry><title>Opal Workflow Agents And The Graph Webhook Trap</title><link href="https://world.optimizely.com/blogs/ritu-madan/dates/2026/10/opal-workflow-agents-and-the-graph-webhook-trap/" /><id>https://world.optimizely.com/blogs/ritu-madan/dates/2026/10/opal-workflow-agents-and-the-graph-webhook-trap/</id><published>2026-10-14T01:06:16.0000000Z</published><updated>2026-10-14T01:06:16.0000000Z</updated><author><name>ritu.madan@valtech.com</name></author><category term="Blog post" /><summary type="html">&lt;h2&gt;What we did?&lt;/h2&gt;
&lt;p class="FirstParagraph"&gt;&lt;span style="mso-bookmark: what-we-were-building;"&gt;We built a compliance review workflow agent in Opal. The job was simple: when an editor submits a page for review in Optimizely CMS 13, the agent reads the content, checks it against defined compliance rules, and writes its findings back onto the item so a human reviewer sees them before approving.&lt;/span&gt;&lt;/p&gt;
&lt;p class="MsoBodyText"&gt;&lt;span style="mso-bookmark: what-we-were-building;"&gt;One editorial action in, one review out. That was the whole contract. A complete run costs 100&amp;ndash;130 credits and takes 2-4 minutes. This makes a single run fairly expensive on credits. Now picture that same run firing over and over, several at once, and the consumption climbs fast.&lt;/span&gt;&lt;/p&gt;
&lt;p class="MsoBodyText"&gt;The trigger was the only open question. &lt;span style="text-decoration: underline;"&gt;&lt;span style="color: rgb(35, 111, 161); text-decoration: underline;"&gt;&lt;a style="color: rgb(35, 111, 161); text-decoration: underline;" title="Opal workflow agents can fire a few ways" href="https://support.optimizely.com/hc/en-us/articles/39865892823693-Workflow-agent-triggers"&gt;Opal workflow agents can fire a few ways&lt;/a&gt;&lt;/span&gt;&lt;/span&gt; &amp;mdash; chat input, a schedule, email, or a webhook &amp;mdash; but for a run that kicks off automatically when something happens in the CMS, the webhook is the one that fits. So something in Optimizely CMS had to notice &amp;ldquo;this page entered review&amp;rdquo; and POST to the trigger URL. The obvious source was &lt;span style="text-decoration: underline;"&gt;&lt;span style="color: rgb(35, 111, 161); text-decoration: underline;"&gt;&lt;a style="color: rgb(35, 111, 161); text-decoration: underline;" title="Optimizely Graph" href="https://docs.developers.optimizely.com/platform-optimizely/docs/manage-webhooks"&gt;Optimizely Graph&lt;/a&gt;&lt;/span&gt;&lt;/span&gt;. It&amp;rsquo;s pure configuration &amp;mdash; register a webhook through the API, no CMS code to deploy &amp;mdash; and it already models content changes, which is more or less what we were reacting to. So we started there: a Graph subscription pointed at the Opal trigger, filtered to the InReview status.&lt;/p&gt;
&lt;h2&gt;What we didn&amp;rsquo;t see coming?&lt;/h2&gt;
&lt;p class="FirstParagraph"&gt;&lt;span style="mso-bookmark: what-we-didnt-see-coming;"&gt;The first run worked exactly as intended. An editor submitted a page, the status went to InReview, Graph synced, the webhook fired, and the agent ran its compliance check.&lt;/span&gt;&lt;/p&gt;
&lt;p class="MsoBodyText"&gt;&lt;span style="mso-bookmark: what-we-didnt-see-coming;"&gt;Then the agent did the one thing it was built to do: it wrote its findings back onto the page.&lt;/span&gt;&lt;/p&gt;
&lt;p class="MsoBodyText"&gt;&lt;span style="mso-bookmark: what-we-didnt-see-coming;"&gt;That write-back was a save. The save synced to Graph. The content was still InReview &amp;mdash; writing findings doesn&amp;rsquo;t change an approval status &amp;mdash; so Graph delivered a webhook for an InReview document, which is exactly the event we&amp;rsquo;d subscribed to. The agent fired again. Checked again. Wrote back again. Synced again.&lt;/span&gt;&lt;/p&gt;
&lt;p class="MsoBodyText"&gt;&lt;span style="mso-bookmark: what-we-didnt-see-coming;"&gt;By the time we were looking at it, we had four pages of execution logs: the same agent, on the same page, running over and over, several instances going at once. We started cancelling runs manually to stop them burning through credits. The agent was triggering itself, and nothing in the loop was going to stop it.&lt;/span&gt;&lt;/p&gt;
&lt;p class="MsoBodyText"&gt;&lt;span style="mso-bookmark: what-we-didnt-see-coming;"&gt;Nothing was broken, which is what made it hard. Graph was doing exactly what Graph does.&lt;/span&gt;&lt;/p&gt;
&lt;h2&gt;Why it looped?&lt;/h2&gt;
&lt;p class="FirstParagraph"&gt;&lt;span style="mso-bookmark: why-it-looped;"&gt;Seeing the behaviour was easy. Finding the cause took a while. Two facts about Graph, neither of them a bug, combined into the loop:&lt;/span&gt;&lt;/p&gt;
&lt;p class="MsoBodyText"&gt;&lt;span style="mso-bookmark: why-it-looped;"&gt;&lt;a title="Graph syncs on save, not on intent." href="https://docs.developers.optimizely.com/platform-optimizely/docs/synchronize-content-events-to-optimizely-graph"&gt;&lt;strong&gt;&lt;span style="text-decoration: underline; color: rgb(35, 111, 161);"&gt;Graph syncs on save, not on intent.&lt;/span&gt;&lt;/strong&gt;&lt;/a&gt; It re-indexes whenever content is saved, published, moved, deleted, or expired. Our agent&amp;rsquo;s write-back is a save like any other, so every write re-syncs.&lt;/span&gt;&lt;/p&gt;
&lt;p class="MsoBodyText"&gt;&lt;span style="mso-bookmark: why-it-looped;"&gt;&lt;strong&gt;Status is part of a document&amp;rsquo;s identity.&lt;/strong&gt; Graph identifies an indexed document as &lt;/span&gt;&lt;span style="mso-bookmark: why-it-looped;"&gt;&lt;em&gt;&lt;span class="VerbatimChar" style="font-size: 10pt;"&gt;{contentGuid}_{version}_{locale}_{status}&lt;/span&gt;&lt;/em&gt;. Our write-back changed the content but left the status at InReview &amp;mdash; so every re-sync produced the &lt;em&gt;same&lt;/em&gt; InReview document, indistinguishable from the submit event we&amp;rsquo;d subscribed to. There was no status change to mark the work as handled, and no state anywhere saying &amp;ldquo;this one&amp;rsquo;s done.&amp;rdquo; So each sync looked like a fresh reason to run.&lt;/span&gt;&lt;/p&gt;
&lt;p class="MsoBodyText"&gt;&lt;span style="mso-bookmark: why-it-looped;"&gt;Underneath it, the mistake was conceptual: we were inferring an editorial decision from index activity. But the index was churning every time our own agent touched the page &amp;mdash; and the agent never stopped touching the page.&lt;/span&gt;&lt;/p&gt;
&lt;h2&gt;The fix: subscribe to the decision, not the side effect&lt;/h2&gt;
&lt;p class="FirstParagraph"&gt;&lt;span style="mso-bookmark: X8862362e12c60d9a9215084724db4b43eeea817;"&gt;Optimizely CMS already raises an event for the exact moment we cared about. The &lt;span style="text-decoration: underline; color: rgb(35, 111, 161);"&gt;&lt;a style="color: rgb(35, 111, 161); text-decoration: underline;" title="approval engine" href="https://docs.developers.optimizely.com/content-management-system/v13.0.0-CMS/docs/content-approvals"&gt;approval engine&lt;/a&gt;&lt;/span&gt; exposes &lt;/span&gt;&lt;span style="mso-bookmark: X8862362e12c60d9a9215084724db4b43eeea817;"&gt;&lt;em&gt;&lt;span class="VerbatimChar" style="font-size: 10pt;"&gt;IApprovalEngineEvents&lt;/span&gt;&lt;/em&gt;, and its &lt;/span&gt;&lt;span style="mso-bookmark: X8862362e12c60d9a9215084724db4b43eeea817;"&gt;&lt;em&gt;&lt;span class="VerbatimChar" style="font-size: 10pt;"&gt;Started&lt;/span&gt;&lt;/em&gt; event fires once, when an approval instance begins &amp;mdash; i.e.&amp;nbsp;when a human submits for review. It&amp;rsquo;s raised by the approval lifecycle, not the persistence layer. Background saves, forced writes, and our own agent&amp;rsquo;s write-back don&amp;rsquo;t start approval instances, so they don&amp;rsquo;t raise it.&lt;/span&gt;&lt;/p&gt;
&lt;p class="MsoBodyText"&gt;&lt;span style="mso-bookmark: X8862362e12c60d9a9215084724db4b43eeea817;"&gt;So instead of inferring the decision from Graph, we subscribed to it directly in the CMS solution and POSTed to the Opal trigger ourselves:&lt;/span&gt;&lt;/p&gt;
&lt;p class="SourceCode"&gt;&lt;span style="color: rgb(35, 111, 161);"&gt;&lt;code&gt;&lt;span style="mso-bookmark: X8862362e12c60d9a9215084724db4b43eeea817;"&gt;&lt;span class="OperatorTok"&gt;[&lt;/span&gt;&lt;span class="NormalTok"&gt;InitializableModule&lt;/span&gt;&lt;span class="OperatorTok"&gt;]&lt;/span&gt;&lt;br /&gt;&lt;span class="OperatorTok"&gt;[&lt;/span&gt;&lt;span class="FunctionTok"&gt;ModuleDependency&lt;/span&gt;&lt;span class="OperatorTok"&gt;(&lt;/span&gt;&lt;span class="KeywordTok"&gt;typeof&lt;/span&gt;&lt;span class="OperatorTok"&gt;(&lt;/span&gt;&lt;span class="NormalTok"&gt;EPiServer&lt;/span&gt;&lt;span class="OperatorTok"&gt;.&lt;/span&gt;&lt;span class="FunctionTok"&gt;Web&lt;/span&gt;&lt;span class="OperatorTok"&gt;.&lt;/span&gt;&lt;span class="FunctionTok"&gt;InitializationModule&lt;/span&gt;&lt;span class="OperatorTok"&gt;))]&lt;/span&gt;&lt;br /&gt;&lt;span class="KeywordTok"&gt;public&lt;/span&gt; &lt;span class="KeywordTok"&gt;class&lt;/span&gt;&lt;span class="NormalTok"&gt; ComplianceReviewTriggerModule &lt;/span&gt;&lt;span class="OperatorTok"&gt;:&lt;/span&gt;&lt;span class="NormalTok"&gt; IInitializableModule&lt;/span&gt;&lt;br /&gt;&lt;span class="OperatorTok"&gt;{&lt;/span&gt;&lt;br /&gt;&lt;span class="NormalTok"&gt;&lt;span style="mso-spacerun: yes;"&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp; &lt;/span&gt;&lt;/span&gt;&lt;span class="KeywordTok"&gt;private&lt;/span&gt;&lt;span class="NormalTok"&gt; IApprovalEngineEvents _approvalEvents&lt;/span&gt;&lt;span class="OperatorTok"&gt;;&lt;/span&gt;&lt;br /&gt;&lt;span class="NormalTok"&gt;&lt;span style="mso-spacerun: yes;"&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp; &lt;/span&gt;&lt;/span&gt;&lt;span class="CommentTok"&gt;// IContentLoader, IHttpClientFactory, config resolved in Initialize()&lt;/span&gt;&lt;br /&gt;&lt;br /&gt;&lt;span class="NormalTok"&gt;&lt;span style="mso-spacerun: yes;"&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp; &lt;/span&gt;&lt;/span&gt;&lt;span class="KeywordTok"&gt;public&lt;/span&gt; &lt;span class="DataTypeTok"&gt;void&lt;/span&gt; &lt;span class="FunctionTok"&gt;Initialize&lt;/span&gt;&lt;span class="OperatorTok"&gt;(&lt;/span&gt;&lt;span class="NormalTok"&gt;InitializationEngine context&lt;/span&gt;&lt;span class="OperatorTok"&gt;)&lt;/span&gt;&lt;br /&gt;&lt;span class="NormalTok"&gt;&lt;span style="mso-spacerun: yes;"&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp; &lt;/span&gt;&lt;/span&gt;&lt;span class="OperatorTok"&gt;{&lt;/span&gt;&lt;br /&gt;&lt;span class="NormalTok"&gt;&lt;span style="mso-spacerun: yes;"&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; &lt;/span&gt;_approvalEvents &lt;/span&gt;&lt;span class="OperatorTok"&gt;=&lt;/span&gt;&lt;span class="NormalTok"&gt; context&lt;/span&gt;&lt;span class="OperatorTok"&gt;.&lt;/span&gt;&lt;span class="FunctionTok"&gt;Services&lt;/span&gt;&lt;span class="OperatorTok"&gt;.&lt;/span&gt;&lt;span class="FunctionTok"&gt;GetRequiredService&lt;/span&gt;&lt;span class="OperatorTok"&gt;&amp;lt;&lt;/span&gt;&lt;span class="NormalTok"&gt;IApprovalEngineEvents&lt;/span&gt;&lt;span class="OperatorTok"&gt;&amp;gt;();&lt;/span&gt;&lt;br /&gt;&lt;span class="NormalTok"&gt;&lt;span style="mso-spacerun: yes;"&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; &lt;/span&gt;_approvalEvents&lt;/span&gt;&lt;span class="OperatorTok"&gt;.&lt;/span&gt;&lt;span class="FunctionTok"&gt;Started&lt;/span&gt; &lt;span class="OperatorTok"&gt;+=&lt;/span&gt;&lt;span class="NormalTok"&gt; OnApprovalStarted&lt;/span&gt;&lt;span class="OperatorTok"&gt;;&lt;/span&gt;&lt;span class="NormalTok"&gt;&lt;span style="mso-spacerun: yes;"&gt;&amp;nbsp;&amp;nbsp; &lt;/span&gt;&lt;/span&gt;&lt;span class="CommentTok"&gt;// raised once, on submit&lt;/span&gt;&lt;br /&gt;&lt;span class="NormalTok"&gt;&lt;span style="mso-spacerun: yes;"&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp; &lt;/span&gt;&lt;/span&gt;&lt;span class="OperatorTok"&gt;}&lt;/span&gt;&lt;br /&gt;&lt;br /&gt;&lt;span class="NormalTok"&gt;&lt;span style="mso-spacerun: yes;"&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp; &lt;/span&gt;&lt;/span&gt;&lt;span class="KeywordTok"&gt;private&lt;/span&gt; &lt;span class="DataTypeTok"&gt;void&lt;/span&gt; &lt;span class="FunctionTok"&gt;OnApprovalStarted&lt;/span&gt;&lt;span class="OperatorTok"&gt;(&lt;/span&gt;&lt;span class="DataTypeTok"&gt;object&lt;/span&gt;&lt;span class="NormalTok"&gt; sender&lt;/span&gt;&lt;span class="OperatorTok"&gt;,&lt;/span&gt;&lt;span class="NormalTok"&gt; ApprovalEventArgs e&lt;/span&gt;&lt;span class="OperatorTok"&gt;)&lt;/span&gt;&lt;br /&gt;&lt;span class="NormalTok"&gt;&lt;span style="mso-spacerun: yes;"&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp; &lt;/span&gt;&lt;/span&gt;&lt;span class="OperatorTok"&gt;{&lt;/span&gt;&lt;br /&gt;&lt;span class="NormalTok"&gt;&lt;span style="mso-spacerun: yes;"&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; &lt;/span&gt;&lt;/span&gt;&lt;span class="KeywordTok"&gt;if&lt;/span&gt; &lt;span class="OperatorTok"&gt;(!&lt;/span&gt;&lt;span class="NormalTok"&gt;e&lt;/span&gt;&lt;span class="OperatorTok"&gt;.&lt;/span&gt;&lt;span class="FunctionTok"&gt;IsContentApproval&lt;/span&gt;&lt;span class="OperatorTok"&gt;())&lt;/span&gt; &lt;span class="KeywordTok"&gt;return&lt;/span&gt;&lt;span class="OperatorTok"&gt;;&lt;/span&gt;&lt;span class="NormalTok"&gt;&lt;span style="mso-spacerun: yes;"&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; &lt;/span&gt;&lt;/span&gt;&lt;span class="CommentTok"&gt;// approvals are generic&lt;/span&gt;&lt;br /&gt;&lt;span class="NormalTok"&gt;&lt;span style="mso-spacerun: yes;"&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; &lt;/span&gt;&lt;/span&gt;&lt;span class="DataTypeTok"&gt;var&lt;/span&gt;&lt;span class="NormalTok"&gt; contentLink &lt;/span&gt;&lt;span class="OperatorTok"&gt;=&lt;/span&gt;&lt;span class="NormalTok"&gt; e&lt;/span&gt;&lt;span class="OperatorTok"&gt;.&lt;/span&gt;&lt;span class="FunctionTok"&gt;GetContentLink&lt;/span&gt;&lt;span class="OperatorTok"&gt;();&lt;/span&gt;&lt;span class="NormalTok"&gt;&lt;span style="mso-spacerun: yes;"&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; &lt;/span&gt;&lt;/span&gt;&lt;span class="CommentTok"&gt;// extension method&lt;/span&gt;&lt;br /&gt;&lt;span class="NormalTok"&gt;&lt;span style="mso-spacerun: yes;"&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; &lt;/span&gt;&lt;/span&gt;&lt;span class="KeywordTok"&gt;if&lt;/span&gt; &lt;span class="OperatorTok"&gt;(&lt;/span&gt;&lt;span class="NormalTok"&gt;ContentReference&lt;/span&gt;&lt;span class="OperatorTok"&gt;.&lt;/span&gt;&lt;span class="FunctionTok"&gt;IsNullOrEmpty&lt;/span&gt;&lt;span class="OperatorTok"&gt;(&lt;/span&gt;&lt;span class="NormalTok"&gt;contentLink&lt;/span&gt;&lt;span class="OperatorTok"&gt;))&lt;/span&gt; &lt;span class="KeywordTok"&gt;return&lt;/span&gt;&lt;span class="OperatorTok"&gt;;&lt;/span&gt;&lt;br /&gt;&lt;br /&gt;&lt;span class="NormalTok"&gt;&lt;span style="mso-spacerun: yes;"&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; &lt;/span&gt;&lt;/span&gt;&lt;span class="KeywordTok"&gt;if&lt;/span&gt; &lt;span class="OperatorTok"&gt;(!&lt;/span&gt;&lt;span class="NormalTok"&gt;_contentLoader&lt;/span&gt;&lt;span class="OperatorTok"&gt;.&lt;/span&gt;&lt;span class="FunctionTok"&gt;TryGet&lt;/span&gt;&lt;span class="OperatorTok"&gt;&amp;lt;&lt;/span&gt;&lt;span class="NormalTok"&gt;MyCustomPage&lt;/span&gt;&lt;span class="OperatorTok"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="NormalTok"&gt;contentLink&lt;/span&gt;&lt;span class="OperatorTok"&gt;,&lt;/span&gt; &lt;span class="KeywordTok"&gt;out&lt;/span&gt; &lt;span class="DataTypeTok"&gt;var&lt;/span&gt;&lt;span class="NormalTok"&gt; page&lt;/span&gt;&lt;span class="OperatorTok"&gt;))&lt;/span&gt;&lt;br /&gt;&lt;span class="NormalTok"&gt;&lt;span style="mso-spacerun: yes;"&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; &lt;/span&gt;&lt;/span&gt;&lt;span class="KeywordTok"&gt;return&lt;/span&gt;&lt;span class="OperatorTok"&gt;;&lt;/span&gt;&lt;span class="NormalTok"&gt;&lt;span style="mso-spacerun: yes;"&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; &lt;/span&gt;&lt;/span&gt;&lt;span class="CommentTok"&gt;// scope to our content type&lt;/span&gt;&lt;br /&gt;&lt;br /&gt;&lt;span class="NormalTok"&gt;&lt;span style="mso-spacerun: yes;"&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; &lt;/span&gt;&lt;/span&gt;&lt;span class="CommentTok"&gt;// Mirror Graph's docId shape so the workflow is source-agnostic.&lt;/span&gt;&lt;br /&gt;&lt;span class="NormalTok"&gt;&lt;span style="mso-spacerun: yes;"&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; &lt;/span&gt;&lt;/span&gt;&lt;span class="DataTypeTok"&gt;var&lt;/span&gt;&lt;span class="NormalTok"&gt; docId &lt;/span&gt;&lt;span class="OperatorTok"&gt;=&lt;/span&gt;&lt;span class="NormalTok"&gt; $&lt;/span&gt;&lt;span class="StringTok"&gt;"{page.ContentGuid}_{contentLink.WorkID}_{page.Language.Name}_InReview"&lt;/span&gt;&lt;span class="OperatorTok"&gt;;&lt;/span&gt;&lt;br /&gt;&lt;span class="NormalTok"&gt;&lt;span style="mso-spacerun: yes;"&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; &lt;/span&gt;_ &lt;/span&gt;&lt;span class="OperatorTok"&gt;=&lt;/span&gt;&lt;span class="NormalTok"&gt; Task&lt;/span&gt;&lt;span class="OperatorTok"&gt;.&lt;/span&gt;&lt;span class="FunctionTok"&gt;Run&lt;/span&gt;&lt;span class="OperatorTok"&gt;(()&lt;/span&gt; &lt;span class="OperatorTok"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="FunctionTok"&gt;TriggerOpalAsync&lt;/span&gt;&lt;span class="OperatorTok"&gt;(&lt;/span&gt;&lt;span class="NormalTok"&gt;docId&lt;/span&gt;&lt;span class="OperatorTok"&gt;));&lt;/span&gt;&lt;span class="NormalTok"&gt;&lt;span style="mso-spacerun: yes;"&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp; &lt;/span&gt;&lt;/span&gt;&lt;span class="CommentTok"&gt;// fire and forget, with a timeout&lt;/span&gt;&lt;br /&gt;&lt;span class="NormalTok"&gt;&lt;span style="mso-spacerun: yes;"&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp; &lt;/span&gt;&lt;/span&gt;&lt;span class="OperatorTok"&gt;}&lt;/span&gt;&lt;br /&gt;&lt;span class="OperatorTok"&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p class="FirstParagraph"&gt;&lt;span style="mso-bookmark: X8862362e12c60d9a9215084724db4b43eeea817;"&gt;A few things that mattered in practice:&lt;/span&gt;&lt;/p&gt;
&lt;p class="Compact" style="margin-left: .5in; text-indent: -24.0pt; mso-list: l0 level1 lfo1;"&gt;&lt;span style="mso-bookmark: X8862362e12c60d9a9215084724db4b43eeea817;"&gt;&lt;!-- [if !supportLists]--&gt;&lt;span style="mso-bidi-font-family: Cambria; mso-bidi-theme-font: minor-latin;"&gt;&lt;span style="mso-list: Ignore;"&gt;&amp;bull;&lt;span style="font: 7.0pt 'Times New Roman';"&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; &lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;!--[endif]--&gt;&lt;em&gt;&lt;span class="VerbatimChar" style="font-size: 10pt;"&gt;&lt;strong&gt;ApprovalEventArgs&lt;/strong&gt;&lt;/span&gt;&lt;/em&gt;&lt;strong&gt; has no &lt;/strong&gt;&lt;/span&gt;&lt;span style="mso-bookmark: X8862362e12c60d9a9215084724db4b43eeea817;"&gt;&lt;em&gt;&lt;span class="VerbatimChar" style="font-size: 10pt;"&gt;&lt;strong&gt;ContentLink&lt;/strong&gt;&lt;/span&gt;&lt;/em&gt;&lt;strong&gt;.&lt;/strong&gt; Approvals are a general mechanism; content approvals are one implementation. You reach the item through the &lt;/span&gt;&lt;span style="mso-bookmark: X8862362e12c60d9a9215084724db4b43eeea817;"&gt;&lt;em&gt;&lt;span class="VerbatimChar" style="font-size: 10pt;"&gt;IsContentApproval()&lt;/span&gt;&lt;/em&gt; and &lt;/span&gt;&lt;span style="mso-bookmark: X8862362e12c60d9a9215084724db4b43eeea817;"&gt;&lt;em&gt;&lt;span class="VerbatimChar" style="font-size: 10pt;"&gt;GetContentLink()&lt;/span&gt;&lt;/em&gt; extension methods in &lt;/span&gt;&lt;span style="mso-bookmark: X8862362e12c60d9a9215084724db4b43eeea817;"&gt;&lt;em&gt;&lt;span class="VerbatimChar" style="font-size: 10pt;"&gt;EPiServer.Approvals.ContentApprovals&lt;/span&gt;&lt;/em&gt;.&lt;/span&gt;&lt;/p&gt;
&lt;p class="Compact" style="margin-left: .5in; text-indent: -24.0pt; mso-list: l0 level1 lfo1;"&gt;&lt;span style="mso-bookmark: X8862362e12c60d9a9215084724db4b43eeea817;"&gt;&lt;!-- [if !supportLists]--&gt;&lt;span style="mso-bidi-font-family: Cambria; mso-bidi-theme-font: minor-latin;"&gt;&lt;span style="mso-list: Ignore;"&gt;&amp;bull;&lt;span style="font: 7.0pt 'Times New Roman';"&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; &lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;!--[endif]--&gt;&lt;strong&gt;The Opal trigger wants auth.&lt;/strong&gt; The webhook trigger takes an Auth Key and sends it as a header (e.g.&amp;nbsp;&lt;/span&gt;&lt;span style="mso-bookmark: X8862362e12c60d9a9215084724db4b43eeea817;"&gt;&lt;em&gt;&lt;span class="VerbatimChar" style="font-size: 10pt;"&gt;Authorization: Bearer {token}&lt;/span&gt;&lt;/em&gt;). Our POST sets that header from config; a request that doesn&amp;rsquo;t match is rejected.&lt;/span&gt;&lt;/p&gt;
&lt;p class="Compact" style="margin-left: .5in; text-indent: -24.0pt; mso-list: l0 level1 lfo1;"&gt;&lt;span style="mso-bookmark: X8862362e12c60d9a9215084724db4b43eeea817;"&gt;&lt;!-- [if !supportLists]--&gt;&lt;span style="mso-bidi-font-family: Cambria; mso-bidi-theme-font: minor-latin;"&gt;&lt;span style="mso-list: Ignore;"&gt;&amp;bull;&lt;span style="font: 7.0pt 'Times New Roman';"&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; &lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;!--[endif]--&gt;&lt;strong&gt;We mirrored Graph&amp;rsquo;s &lt;/strong&gt;&lt;/span&gt;&lt;span style="mso-bookmark: X8862362e12c60d9a9215084724db4b43eeea817;"&gt;&lt;em&gt;&lt;span class="VerbatimChar" style="font-size: 10pt;"&gt;&lt;strong&gt;docId&lt;/strong&gt;&lt;/span&gt;&lt;/em&gt;&lt;strong&gt;&lt;em&gt;&lt;span style="font-size: 10pt;"&gt; &lt;/span&gt;&lt;/em&gt;shape&lt;/strong&gt; in the payload, so the downstream workflow didn&amp;rsquo;t need to change. If we ever switch sources again, the agent doesn&amp;rsquo;t care.&lt;/span&gt;&lt;/p&gt;
&lt;p class="Compact" style="margin-left: .5in; text-indent: -24.0pt; mso-list: l0 level1 lfo1;"&gt;&lt;span style="mso-bookmark: X8862362e12c60d9a9215084724db4b43eeea817;"&gt;&lt;!-- [if !supportLists]--&gt;&lt;span style="mso-bidi-font-family: Cambria; mso-bidi-theme-font: minor-latin;"&gt;&lt;span style="mso-list: Ignore;"&gt;&amp;bull;&lt;span style="font: 7.0pt 'Times New Roman';"&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; &lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;!--[endif]--&gt;&lt;strong&gt;Fire-and-forget has a cost.&lt;/strong&gt; Blocking the editor&amp;rsquo;s submit on an outbound call is a bad idea, so we don&amp;rsquo;t. The trade is that if Opal is unreachable the moment &lt;/span&gt;&lt;span style="mso-bookmark: X8862362e12c60d9a9215084724db4b43eeea817;"&gt;&lt;em&gt;&lt;span style="font-size: 10pt;"&gt;&lt;span class="VerbatimChar"&gt;Started&lt;/span&gt; &lt;/span&gt;&lt;/em&gt;fires, that trigger is lost &amp;mdash; at-most-once. For a review flow that&amp;rsquo;s fine, because an editor can resubmit. If it weren&amp;rsquo;t, we&amp;rsquo;d add retries or a durable queue.&lt;/span&gt;&lt;/p&gt;
&lt;p class="FirstParagraph"&gt;&lt;span style="mso-bookmark: X8862362e12c60d9a9215084724db4b43eeea817;"&gt;Result: one submit, one execution. The write-back still syncs to Graph, but we&amp;rsquo;re no longer listening to Graph for this trigger, and writing content doesn&amp;rsquo;t start an approval &amp;mdash; so there&amp;rsquo;s nothing left to re-fire. The loop was gone, not because we out-guarded it, but because we stopped listening to the wrong layer.&lt;/span&gt;&lt;/p&gt;
&lt;h2&gt;So when is Graph the right source?&lt;/h2&gt;
&lt;p class="FirstParagraph"&gt;&lt;span style="mso-bookmark: so-when-is-graph-the-right-source;"&gt;The lesson isn&amp;rsquo;t &amp;ldquo;Graph is wrong.&amp;rdquo; Graph is excellent &amp;mdash; for the thing it actually reports, which is &lt;em&gt;content as the published world sees it&lt;/em&gt;.&lt;/span&gt;&lt;/p&gt;
&lt;p class="MsoBodyText"&gt;&lt;span style="mso-bookmark: so-when-is-graph-the-right-source;"&gt;&lt;strong&gt;Reach for Graph when:&lt;/strong&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p class="Compact" style="margin-left: .5in; text-indent: -24.0pt; mso-list: l0 level1 lfo1;"&gt;&lt;span style="mso-bookmark: so-when-is-graph-the-right-source;"&gt;&lt;!-- [if !supportLists]--&gt;&lt;span style="mso-bidi-font-family: Cambria; mso-bidi-theme-font: minor-latin;"&gt;&lt;span style="mso-list: Ignore;"&gt;&amp;bull;&lt;span style="font: 7.0pt 'Times New Roman';"&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; &lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;!--[endif]--&gt;You&amp;rsquo;re reacting to &lt;strong&gt;content state&lt;/strong&gt;, not editorial intent &amp;mdash; a page got published, purge the CDN; rebuild the static site. This is Graph&amp;rsquo;s canonical use case, and duplicate deliveries are harmless because the second purge is a no-op.&lt;/span&gt;&lt;/p&gt;
&lt;p class="Compact" style="margin-left: .5in; text-indent: -24.0pt; mso-list: l0 level1 lfo1;"&gt;&lt;span style="mso-bookmark: so-when-is-graph-the-right-source;"&gt;&lt;!-- [if !supportLists]--&gt;&lt;span style="mso-bidi-font-family: Cambria; mso-bidi-theme-font: minor-latin;"&gt;&lt;span style="mso-list: Ignore;"&gt;&amp;bull;&lt;span style="font: 7.0pt 'Times New Roman';"&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; &lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;!--[endif]--&gt;The consumer is &lt;strong&gt;idempotent&lt;/strong&gt; &amp;mdash; ISR cache invalidation, deploy hooks, downstream sync. Firing twice costs nothing.&lt;/span&gt;&lt;/p&gt;
&lt;p class="Compact" style="margin-left: .5in; text-indent: -24.0pt; mso-list: l0 level1 lfo1;"&gt;&lt;span style="mso-bookmark: so-when-is-graph-the-right-source;"&gt;&lt;!-- [if !supportLists]--&gt;&lt;span style="mso-bidi-font-family: Cambria; mso-bidi-theme-font: minor-latin;"&gt;&lt;span style="mso-list: Ignore;"&gt;&amp;bull;&lt;span style="font: 7.0pt 'Times New Roman';"&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; &lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;!--[endif]--&gt;You &lt;strong&gt;don&amp;rsquo;t own the CMS codebase&lt;/strong&gt;, or you&amp;rsquo;re on &lt;strong&gt;SaaS CMS&lt;/strong&gt;. Graph is configuration, no deploy required.&lt;/span&gt;&lt;/p&gt;
&lt;p class="Compact" style="margin-left: .5in; text-indent: -24.0pt; mso-list: l0 level1 lfo1;"&gt;&lt;span style="mso-bookmark: so-when-is-graph-the-right-source;"&gt;&lt;!-- [if !supportLists]--&gt;&lt;span style="mso-bidi-font-family: Cambria; mso-bidi-theme-font: minor-latin;"&gt;&lt;span style="mso-list: Ignore;"&gt;&amp;bull;&lt;span style="font: 7.0pt 'Times New Roman';"&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; &lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;!--[endif]--&gt;The consuming system is &lt;strong&gt;decoupled from CMS&lt;/strong&gt; and you don&amp;rsquo;t want to couple to its internals.&lt;/span&gt;&lt;/p&gt;
&lt;p class="FirstParagraph"&gt;&lt;span style="mso-bookmark: so-when-is-graph-the-right-source;"&gt;&lt;strong&gt;Reach for a CMS server-side event (the code path) when:&lt;/strong&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p class="Compact" style="margin-left: .5in; text-indent: -24.0pt; mso-list: l0 level1 lfo1;"&gt;&lt;span style="mso-bookmark: so-when-is-graph-the-right-source;"&gt;&lt;!-- [if !supportLists]--&gt;&lt;span style="mso-bidi-font-family: Cambria; mso-bidi-theme-font: minor-latin;"&gt;&lt;span style="mso-list: Ignore;"&gt;&amp;bull;&lt;span style="font: 7.0pt 'Times New Roman';"&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; &lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;!--[endif]--&gt;The trigger is an &lt;strong&gt;editorial decision&lt;/strong&gt; &amp;mdash; submitted, approved, rejected. That lives in the approval engine, and Graph can&amp;rsquo;t model it.&lt;/span&gt;&lt;/p&gt;
&lt;p class="Compact" style="margin-left: .5in; text-indent: -24.0pt; mso-list: l0 level1 lfo1;"&gt;&lt;span style="mso-bookmark: so-when-is-graph-the-right-source;"&gt;&lt;!-- [if !supportLists]--&gt;&lt;span style="mso-bidi-font-family: Cambria; mso-bidi-theme-font: minor-latin;"&gt;&lt;span style="mso-list: Ignore;"&gt;&amp;bull;&lt;span style="font: 7.0pt 'Times New Roman';"&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; &lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;!--[endif]--&gt;Your workflow &lt;strong&gt;writes back to the item that triggered it&lt;/strong&gt;. This is the one that bit us; any index-based trigger risks the same loop.&lt;/span&gt;&lt;/p&gt;
&lt;p class="Compact" style="margin-left: .5in; text-indent: -24.0pt; mso-list: l0 level1 lfo1;"&gt;&lt;span style="mso-bookmark: so-when-is-graph-the-right-source;"&gt;&lt;!-- [if !supportLists]--&gt;&lt;span style="mso-bidi-font-family: Cambria; mso-bidi-theme-font: minor-latin;"&gt;&lt;span style="mso-list: Ignore;"&gt;&amp;bull;&lt;span style="font: 7.0pt 'Times New Roman';"&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; &lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;!--[endif]--&gt;&lt;strong&gt;Each execution is expensive.&lt;/strong&gt; When a run costs real credits and minutes, precise firing matters far more than easy setup.&lt;/span&gt;&lt;/p&gt;
&lt;p class="Compact" style="margin-left: .5in; text-indent: -24.0pt; mso-list: l0 level1 lfo1;"&gt;&lt;span style="mso-bookmark: so-when-is-graph-the-right-source;"&gt;&lt;!-- [if !supportLists]--&gt;&lt;span style="mso-bidi-font-family: Cambria; mso-bidi-theme-font: minor-latin;"&gt;&lt;span style="mso-list: Ignore;"&gt;&amp;bull;&lt;span style="font: 7.0pt 'Times New Roman';"&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; &lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;!--[endif]--&gt;You need &lt;strong&gt;business rules before firing&lt;/strong&gt; &amp;mdash; content type, language, site. Strongly typed C# beats filtering on payload shape.&lt;/span&gt;&lt;/p&gt;
&lt;p class="FirstParagraph"&gt;&lt;span style="mso-bookmark: so-when-is-graph-the-right-source;"&gt;For us it was a compliance review agent that wrote back to the page it reviewed, at 100+ credits a run. Three of those four boxes. Graph was the wrong tool for this job. We kept Graph for delivery-side concerns and moved the editorial trigger to the approval event. They answer different questions.&lt;/span&gt;&lt;/p&gt;
&lt;h2&gt;The takeaway&lt;/h2&gt;
&lt;p class="FirstParagraph"&gt;Both sources are honest. Graph faithfully reports that an indexed document changed. The approval engine faithfully reports that a person made a decision. Neither is broken.&lt;/p&gt;
&lt;p class="MsoBodyText"&gt;The real question is which of those you care about &amp;mdash; &lt;em&gt;a document changing&lt;/em&gt; or &lt;em&gt;a person deciding&lt;/em&gt; &amp;mdash; and subscribing at that layer instead of inferring it from the one below. When a workflow fires more often than the business event behind it, the instinct is to bolt on a guard. Sometimes you need one. But first ask what your event source is actually reporting.&lt;/p&gt;
&lt;p class="MsoBodyText"&gt;Ours was reporting every save. Our agent just happened to be the thing doing the saving.&lt;/p&gt;</summary></entry><entry><title>cms13-scan: A Deterministic CMS 13 Readiness Scanner</title><link href="https://www.adnanzameer.com/2026/10/cms13-scan-optimizely-cms-13-readiness-scanner.html" /><id>https://www.adnanzameer.com/2026/10/cms13-scan-optimizely-cms-13-readiness-scanner.html</id><published>2026-10-12T10:09:07.0000000Z</published><updated>2026-10-12T10:30:34.0000000Z</updated><author><name>adnan.zameer@episerver.com</name></author><category term="Blog post" /><summary type="html">&lt;!--excerpt: A free, open-source, deterministic .NET global tool that scans an Optimizely CMS 12 codebase for CMS 13 breaking changes before you touch a single line of upgrade code. No AI, no tokens, no API keys, just static analysis you can run in CI.--&gt;

Every CMS 12 to CMS 13 upgrade I have looked at starts the same way. Someone opens the solution and starts hunting. A text search for the APIs</summary></entry><entry><title>Setting Up an Optimizely CMS 13 MCP Server Locally with Codex</title><link href="https://world.optimizely.com/blogs/mardy-abdelkareem/dates/2026/10/getting-started-with-optimizely-cms-13-mcp-server-in-vs-code-using-codex/" /><id>https://world.optimizely.com/blogs/mardy-abdelkareem/dates/2026/10/getting-started-with-optimizely-cms-13-mcp-server-in-vs-code-using-codex/</id><published>2026-10-12T04:41:05.0000000Z</published><updated>2026-10-12T04:41:05.0000000Z</updated><author><name>mardy.abdelkareem@firstlinesoftware.com</name></author><category term="Blog post" /><summary type="html">&lt;p&gt;Optimizely CMS 13 supports &lt;a href="https://modelcontextprotocol.io/docs/2026-07-28/getting-started/intro"&gt;Model Context Protocol (MCP)&lt;/a&gt;, allowing AI tools such as Claude and Codex to interact with CMS content and metadata through an MCP server.&lt;/p&gt;
&lt;p&gt;In this post, I'll show how I configured the MCP server in my local CMS 13 Alloy project, connected it to Codex in Visual Studio Code, and used it to explore content types, create pages, and build a Visual Builder experience.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Prerequisites:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Optimizely CMS 13 (I'm using Alloy with CMS 13.3.0).&lt;/li&gt;
&lt;li&gt;An MCP-compatible AI client (I'm using Codex in VS Code).&lt;/li&gt;
&lt;li&gt;A local CMS instance running over HTTPS, for example, https://localhost:5000.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Configure the MCP Server&lt;/h2&gt;
&lt;p&gt;Install the &lt;strong&gt;Optimizely.Cms.Opal.Tools&lt;/strong&gt; NuGet package (version 13.2.0 or later):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;dotnet add package Optimizely.Cms.Opal.Tools --version 13.2.0&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Register the MCP server in &lt;strong&gt;Startup.cs&lt;/strong&gt; using client-secret authentication:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;services.AddCmsMcpServer()
    .WithClientSecretAuthentication();&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then map the MCP endpoint and require client-secret authorization:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;app.UseEndpoints(endpoints =&amp;gt;
{
    endpoints.MapContent();

    endpoints.MapMcp("/mcp")
        .RequireClientSecretAuthorization();
});&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The MCP endpoint is now available at &lt;strong&gt;https://localhost:5000/mcp&lt;/strong&gt;, or your application's configured HTTPS address.&lt;/p&gt;
&lt;h2&gt;Configure Client-Secret Authentication&lt;/h2&gt;
&lt;p&gt;Make sure a client secret is configured for your local CMS application. We'll use it to authenticate Codex when connecting to the MCP server.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Security note:&lt;/strong&gt; Never commit your client secret to source control or expose it in screenshots.&lt;/p&gt;
&lt;h2&gt;Connect Codex in VS Code&lt;/h2&gt;
&lt;p&gt;Open &lt;strong&gt;Codex Settings &amp;rarr; MCP servers &amp;rarr; Add server&lt;/strong&gt; and select &lt;strong&gt;Streamable HTTP&lt;/strong&gt;.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Name:&lt;/strong&gt; optimizely-cms&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;URL:&lt;/strong&gt; https://localhost:5000/mcp&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Bearer token env var:&lt;/strong&gt; Leave empty.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Header:&lt;/strong&gt; Authorization, with your configured client secret as the value.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Alternatively, configure the server in Codex's &lt;strong&gt;config.toml&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[mcp_servers.optimizely-cms]
type = "http"
url = "https://localhost:5000/mcp"

[mcp_servers.optimizely-cms.headers]
Authorization = "your-secret-value"&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Replace the placeholder with your actual secret and enable the server in Codex Settings. The secret is passed directly in the Authorization header, without a Bearer prefix.&lt;/p&gt;
&lt;p style="text-align: center;"&gt;&lt;img style="width: 100%; max-width: 500px; height: auto;" src="/link/1b35b3874d3149f7a6065da7c50869d3.aspx" alt="Codex MCP server configuration" /&gt;&lt;/p&gt;
&lt;p style="text-align: center;"&gt;&lt;em&gt;Configuring the local Optimizely MCP server in Codex.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;Verify the Integration&lt;/h2&gt;
&lt;p&gt;With the CMS running, open a new Codex chat in VS Code and try:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;List the available content types in my local Optimizely CMS instance using the MCP server.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Codex retrieved the registered content types, including &lt;strong&gt;StandardPage&lt;/strong&gt; and &lt;strong&gt;StandardExperienceData&lt;/strong&gt;, and could inspect their properties, locales, and available Visual Builder components.&lt;/p&gt;
&lt;p&gt;You can also retrieve existing content:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Find the published content items of type ProductTestPage and show their properties.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;These read-only operations confirm that Codex can access CMS content and metadata through MCP.&lt;/p&gt;
&lt;p style="text-align: center;"&gt;&lt;img style="width: 100%; max-width: 650px; height: auto;" src="/link/7ff3dbdcef3d48c59233445efb77db39.aspx" alt="Codex discovering Optimizely CMS content types" /&gt;&lt;/p&gt;
&lt;p style="text-align: center;"&gt;&lt;em&gt;Codex retrieving content types from the local CMS instance.&lt;/em&gt;&lt;/p&gt;
&lt;h3&gt;Create Content Using Codex&lt;/h3&gt;
&lt;p&gt;Next, I asked Codex to create an unpublished page:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Create "MCP Demo Page" under Start using StandardPage. Add a heading and paragraph to MainBody, and save it as an English draft.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Codex created the page and updated its content through the MCP server.&lt;/p&gt;
&lt;p&gt;I also tested Visual Builder:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Create an unpublished "Alloy Demo Experience" using StandardExperienceData, with a BlankSection containing a VisualBuilderTestBlock.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Codex created the experience using the existing CMS content types and Visual Builder components. Both items were saved as drafts without publishing.&lt;/p&gt;
&lt;p style="text-align: center;"&gt;&lt;img style="width: 100%; max-width: 750px; height: auto;" src="/link/058a28868a6d4b519226eaefffb487d1.aspx" alt="MCP Demo Page created in Optimizely CMS" /&gt;&lt;/p&gt;
&lt;p style="text-align: center;"&gt;&lt;em&gt;The MCP Demo Page created through Codex and saved as a draft.&lt;/em&gt;&lt;/p&gt;
&lt;h3&gt;Troubleshooting Content Permissions&lt;/h3&gt;
&lt;p&gt;During content creation, I encountered an authorization error:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Access denied: You do not have permission to create content in this location.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The MCP client authenticated successfully with a &lt;strong&gt;CmsAdmins&lt;/strong&gt; role claim, which was mapped to &lt;strong&gt;WebAdmins&lt;/strong&gt; in the application configuration. Although the Start page granted Create permission to WebAdmins, the MCP client was still denied access.&lt;/p&gt;
&lt;p&gt;The issue was resolved by explicitly granting the &lt;strong&gt;MCP Client&lt;/strong&gt; permissions on the Start page through CMS access rights. Afterward, Codex could create and update content.&lt;/p&gt;
&lt;p style="text-align: center;"&gt;&lt;img style="width: 100%; max-width: 750px; height: auto;" src="/link/28ac25ea3076497792a6660ed63434cc.aspx" alt="MCP Client permissions on the Start page" /&gt;&lt;/p&gt;
&lt;p style="text-align: center;"&gt;&lt;em&gt;Granting the MCP Client access on the Start page resolved the create denial.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Security note:&lt;/strong&gt; For this local demo, all permissions were enabled, including Publish and Administer. In a real environment, grant only the permissions needed for the intended operations.&lt;/p&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;With Codex connected to Optimizely CMS 13 through MCP, we were able to explore content types, create pages, and work with Visual Builder directly from VS Code.&lt;/p&gt;
&lt;p&gt;One thing worth noting is that connecting successfully doesn't necessarily mean the MCP client has permission to create or update content. You'll still need to configure the appropriate access rights in CMS.&lt;/p&gt;</summary></entry><entry><title>Handling SixLabors.ImageSharp vulnerability warnings in Optimizely CMS 12 (unofficial)</title><link href="https://world.optimizely.com/blogs/cuong-nguyen-inh/dates/2026/10/handling-sixlabors.imagesharp-vulnerability-warnings-in-optimizely-cms-12-unofficial" /><id>https://world.optimizely.com/blogs/cuong-nguyen-inh/dates/2026/10/handling-sixlabors.imagesharp-vulnerability-warnings-in-optimizely-cms-12-unofficial</id><published>2026-10-10T06:01:41.0000000Z</published><updated>2026-10-10T06:01:41.0000000Z</updated><author><name>cuong.nguyendinh@optimizely.com</name></author><category term="Blog post" /><summary type="html">&lt;p&gt;&lt;strong&gt;&lt;em&gt;&amp;nbsp;&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;div class="css-1fd6doq"&gt;
&lt;div&gt;
&lt;div class="_2O5Vij7dHp _3YEZNO7dHp _1bHrqEN91d _4ouGXIdnbC _1Uk34SOLYo"&gt;
&lt;div class="_39xV02N91d _0u2WMmrU95"&gt;
&lt;div class="css-3qfej8"&gt;
&lt;div class="ak-renderer-wrapper is-comment css-pw7jst"&gt;
&lt;div class="css-14y9ydk"&gt;
&lt;div class="ak-renderer-document"&gt;
&lt;p&gt;&lt;strong&gt;2nd UPDATED: we&amp;rsquo;ve gotten a SixLabors license file, CMS team is working on update ImageSharp to a safe version (both SaaS and Paas CMS) in this story CMS-57368&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;&lt;em&gt;&lt;br /&gt;Written by Claude Opus 5.5 (Anthropic) on behalf of Cuong Nguyen, Optimizely Technical Support.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A note from Cuong:&lt;/strong&gt; Thanks to Optimizely for giving me access to Claude, which made this kind of deep dive possible within a support ticket.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;SixLabors.ImageSharp&lt;/strong&gt; is one of the most popular image libraries in the .NET world. Chances are it's in your solution too, even if you never added it yourself. Then one morning you open the project, run a build like any other day, and the output is suddenly full of &lt;strong&gt;NU1902&lt;/strong&gt;/&lt;strong&gt;NU1903&lt;/strong&gt; warnings. Nothing changed in your code. If &lt;strong&gt;TreatWarningsAsErrors&lt;/strong&gt; is on, the build simply fails.&lt;/p&gt;
&lt;p&gt;That's the story behind a lot of tickets we've been getting in Optimizely Support lately. This post covers where the dependency comes from, why "just upgrade ImageSharp" doesn't work, and the four options you have today, including a SkiaSharp-based &lt;strong&gt;IImageService&lt;/strong&gt; sample.&lt;/p&gt;
&lt;h2&gt;1. What you'll see&lt;/h2&gt;
&lt;pre class="language-text"&gt;&lt;code&gt;$ dotnet list package --include-transitive --vulnerable

   Transitive Package          Resolved   Severity   Advisory URL
   &amp;gt; SixLabors.ImageSharp      3.1.11     Moderate   https://github.com/advisories/GHSA-gwg2-r3hj-4w44
                                          High       https://github.com/advisories/GHSA-j3p4-wp97-rph4
                                          High       https://github.com/advisories/GHSA-j9gm-c75j-xc9q
                                          Moderate   https://github.com/advisories/GHSA-wmxv-xphr-5c9g
                                          High       https://github.com/advisories/GHSA-jjfr-hcj7-qf5w
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You'll also get &lt;strong&gt;NU1902&lt;/strong&gt;/&lt;strong&gt;NU1903&lt;/strong&gt; during restore and build. With &lt;strong&gt;TreatWarningsAsErrors&lt;/strong&gt; enabled, the build fails.&lt;/p&gt;
&lt;h2&gt;2. Where the dependency comes from&lt;/h2&gt;
&lt;p&gt;The CMS uses &lt;strong&gt;IImageService&lt;/strong&gt; for thumbnails and the image editor. The default implementation lives in &lt;strong&gt;EPiServer.ImageLibrary.ImageSharp&lt;/strong&gt;, which depends on &lt;strong&gt;SixLabors.ImageSharp&lt;/strong&gt;:&lt;/p&gt;
&lt;pre class="language-text"&gt;&lt;code&gt;EPiServer.CMS 12.34.x
└── EPiServer.ImageLibrary.ImageSharp  (&amp;gt;= 1.0.6)   &amp;larr; NuGet picks the lowest matching version
    └── SixLabors.ImageSharp 2.1.11
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;NuGet resolves dependencies with the &lt;em&gt;lowest applicable version&lt;/em&gt; rule (&lt;a href="https://learn.microsoft.com/nuget/concepts/dependency-resolution#lowest-applicable-version"&gt;docs&lt;/a&gt;). So &lt;strong&gt;EPiServer.CMS&lt;/strong&gt; 12.34.8 resolves &lt;strong&gt;EPiServer.ImageLibrary.ImageSharp&lt;/strong&gt; &lt;strong&gt;1.0.6&lt;/strong&gt;, which brings in ImageSharp &lt;strong&gt;2.1.11&lt;/strong&gt;. This is standard NuGet behavior, not something specific to Optimizely packages.&lt;/p&gt;
&lt;p&gt;To get a newer version, reference the CMS components directly instead of the &lt;strong&gt;EPiServer.CMS&lt;/strong&gt; metapackage, and pin &lt;strong&gt;EPiServer.ImageLibrary.ImageSharp&lt;/strong&gt; to 2.0.6:&lt;/p&gt;
&lt;pre class="language-xml"&gt;&lt;code&gt;&amp;lt;PackageReference Include="EPiServer.CMS.UI" Version="12.34.6" /&amp;gt;
&amp;lt;PackageReference Include="EPiServer.Hosting" Version="12.24.1" /&amp;gt;
&amp;lt;!-- ... --&amp;gt;
&amp;lt;PackageReference Include="EPiServer.ImageLibrary.ImageSharp" Version="2.0.6" /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That moves you to ImageSharp 3.1.11, which clears the older advisories but not the five listed above.&lt;/p&gt;
&lt;h2&gt;3. Why not force a newer ImageSharp?&lt;/h2&gt;
&lt;p&gt;The advisories above are fixed in ImageSharp 4.x. Six Labors changed the licensing model in 4.x, and Release builds require a license key (&lt;a href="https://github.com/SixLabors/ImageSharp/issues/3205#issuecomment-6062121101"&gt;discussion&lt;/a&gt;). Forcing 4.x through &lt;strong&gt;Directory.Packages.props&lt;/strong&gt; therefore gives you a build error unless you have a Six Labors license. The 3.1.x line, the last one usable without a license, still carries the advisories.&lt;/p&gt;
&lt;h2&gt;4. Your options&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Pros&lt;/th&gt;
&lt;th&gt;Cons&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Remove &lt;strong&gt;EPiServer.ImageLibrary.ImageSharp&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No code, warnings disappear&lt;/td&gt;
&lt;td&gt;No image processing: thumbnails and the image editor stop working&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Provide your own &lt;a href="/CsClassLibraries/cms/EPiServer.ImageLibrary.IImageService?version=12"&gt;&lt;strong&gt;IImageService&lt;/strong&gt;&lt;/a&gt;, e.g. with &lt;a href="https://github.com/mono/SkiaSharp"&gt;SkiaSharp&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Clean audit, features kept&lt;/td&gt;
&lt;td&gt;Custom code that your team owns and maintains&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Suppress the audit warnings&lt;/td&gt;
&lt;td&gt;Quick&lt;/td&gt;
&lt;td&gt;The vulnerable package stays in your solution&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Wait for an updated &lt;strong&gt;EPiServer.ImageLibrary.ImageSharp&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No custom code&lt;/td&gt;
&lt;td&gt;No committed timeline today&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;On risk:&lt;/strong&gt; the image service only processes media that authenticated editors upload through the CMS. It doesn't handle arbitrary anonymous input. That lowers the exposure, and editors validating images on upload lowers it further. Whether that's acceptable is a decision for your own security policy and threat model.&lt;/p&gt;
&lt;h2&gt;5. Option 2: a custom &lt;strong&gt;IImageService&lt;/strong&gt; with SkiaSharp&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;IImageService&lt;/strong&gt; is a public extension point with a single method:&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;public interface IImageService
{
    void RenderImage(Stream input, Stream output, RenderRequest request);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The default implementation maps each &lt;strong&gt;ImageEditorCommand&lt;/strong&gt; like this, and a replacement should do the same:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;strong&gt;ImageEditorCommand&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;Default (ImageSharp)&lt;/th&gt;
&lt;th&gt;SkiaSharp equivalent&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Grayscale&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Grayscale()&lt;/strong&gt; (BT.709)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;SKColorFilter.CreateColorMatrix&lt;/strong&gt; with BT.709 coefficients&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Crop&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Crop(rect)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;DrawBitmap(src, -left, -top)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;FlipX&lt;/strong&gt; / &lt;strong&gt;FlipY&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Flip(Horizontal/Vertical)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;canvas.Scale(-1, 1, cx, 0)&lt;/strong&gt; / &lt;strong&gt;Scale(1, -1, 0, cy)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Resize&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;ResizeMode.Stretch&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;DrawBitmap(src, destRect, Mitchell)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ResizeKeepScale&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;ResizeMode.BoxPad&lt;/strong&gt; + &lt;strong&gt;BackgroundColor&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;fit the box, &lt;strong&gt;no upscaling&lt;/strong&gt;, center, &lt;strong&gt;Clear(bg)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Rotate&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Rotate(deg)&lt;/strong&gt;, canvas expands&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Translate&lt;/strong&gt; + &lt;strong&gt;RotateDegrees&lt;/strong&gt; on an expanded canvas&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;Packages&lt;/h3&gt;
&lt;p&gt;Remove &lt;strong&gt;EPiServer.ImageLibrary.ImageSharp&lt;/strong&gt; and add SkiaSharp. If you reference the &lt;strong&gt;EPiServer.CMS&lt;/strong&gt; metapackage, switch to individual component references first (see section 2). The metapackage brings &lt;strong&gt;EPiServer.ImageLibrary.ImageSharp&lt;/strong&gt; in transitively, so you can't remove it otherwise.&lt;/p&gt;
&lt;pre class="language-xml"&gt;&lt;code&gt;&amp;lt;!-- remove: &amp;lt;PackageReference Include="EPiServer.ImageLibrary.ImageSharp" Version="..." /&amp;gt; --&amp;gt;
&amp;lt;PackageReference Include="SkiaSharp" Version="4.153.1" /&amp;gt;
&amp;lt;!-- Required on Linux, containers and DXP: --&amp;gt;
&amp;lt;PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="4.153.1" /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;The service&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;ResizeKeepScale&lt;/strong&gt; matters most, because thumbnail generation calls it with &lt;strong&gt;BackgroundColor = "#00000000"&lt;/strong&gt;. The full implementation:&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;using EPiServer.ImageLibrary;
using SkiaSharp;

namespace YourSite.Business.Imaging;

/// &amp;lt;summary&amp;gt;
/// SkiaSharp-based replacement for EPiServer.ImageLibrary.ImageSharp's DefaultImageService,
/// which pulls in vulnerable SixLabors.ImageSharp versions. Mirrors the default behavior:
/// same commands, ResizeKeepScale = ImageSharp "BoxPad" + background color, ZoomFactor applied last.
/// &amp;lt;/summary&amp;gt;
/// &amp;lt;remarks&amp;gt;
/// Skia only encodes PNG, JPEG and WebP. GIF/BMP/TIFF output is not supported (NotSupportedException),
/// and animated GIF input is flattened to its first frame.
/// &amp;lt;/remarks&amp;gt;
public class SkiaImageService : IImageService
{
    private static readonly SKSamplingOptions ResizeSampling = new(SKCubicResampler.Mitchell);
    private static readonly SKSamplingOptions RotateSampling = new(SKFilterMode.Linear);
    private static readonly SKSamplingOptions CopySampling = new(SKFilterMode.Nearest);

    // ITU-R BT.709 luma, same coefficients as ImageSharp's Grayscale().
    private static readonly float[] GrayscaleMatrix =
    [
        0.2126f, 0.7152f, 0.0722f, 0, 0,
        0.2126f, 0.7152f, 0.0722f, 0, 0,
        0.2126f, 0.7152f, 0.0722f, 0, 0,
        0, 0, 0, 1, 0,
    ];

    public void RenderImage(Stream input, Stream output, RenderRequest request)
    {
        ArgumentNullException.ThrowIfNull(input);
        ArgumentNullException.ThrowIfNull(output);
        ArgumentNullException.ThrowIfNull(request);

        var image = Decode(input);
        try
        {
            foreach (var operation in request.Operations ?? [])
            {
                image = Replace(image, Apply(image, operation));
            }

            if (request.ZoomFactor != 1f)
            {
                var width = (int)Math.Floor(image.Width * request.ZoomFactor);
                var height = (int)Math.Floor(image.Height * request.ZoomFactor);
                image = Replace(image, Resize(image, width, height));
            }

            Encode(image, output, request);
        }
        finally
        {
            image.Dispose();
        }
    }

    private static SKBitmap Decode(Stream input)
    {
        using var codec = SKCodec.Create(input)
            ?? throw new NotSupportedException("The source image format is not supported.");

        var info = codec.Info
            .WithColorType(SKImageInfo.PlatformColorType)
            .WithAlphaType(SKAlphaType.Premul);

        return SKBitmap.Decode(codec, info)
            ?? throw new NotSupportedException("The source image could not be decoded.");
    }

    private static SKBitmap Replace(SKBitmap current, SKBitmap next)
    {
        current.Dispose();
        return next;
    }

    private static SKBitmap Apply(SKBitmap source, ImageOperation operation) =&amp;gt; operation.Command switch
    {
        ImageEditorCommand.Grayscale =&amp;gt; Grayscale(source),
        ImageEditorCommand.Crop =&amp;gt; Crop(source, operation),
        ImageEditorCommand.FlipX =&amp;gt; Draw(source.Width, source.Height, canvas =&amp;gt;
        {
            canvas.Scale(-1, 1, source.Width / 2f, 0);
            canvas.DrawBitmap(source, 0, 0, CopySampling);
        }),
        ImageEditorCommand.FlipY =&amp;gt; Draw(source.Width, source.Height, canvas =&amp;gt;
        {
            canvas.Scale(1, -1, 0, source.Height / 2f);
            canvas.DrawBitmap(source, 0, 0, CopySampling);
        }),
        ImageEditorCommand.Resize =&amp;gt; Resize(source, operation.Width, operation.Height),
        ImageEditorCommand.ResizeKeepScale =&amp;gt; ResizeKeepScale(source, operation),
        ImageEditorCommand.Rotate =&amp;gt; Rotate(source, operation.Angle),
        _ =&amp;gt; throw new InvalidOperationException($"Unknown command {operation.Command}"),
    };

    private static SKBitmap Draw(int width, int height, Action&amp;lt;SKCanvas&amp;gt; draw)
    {
        var bitmap = new SKBitmap(width, height, SKImageInfo.PlatformColorType, SKAlphaType.Premul);
        using var canvas = new SKCanvas(bitmap);
        canvas.Clear(SKColors.Transparent);
        draw(canvas);
        return bitmap;
    }

    private static SKBitmap Grayscale(SKBitmap source)
    {
        using var filter = SKColorFilter.CreateColorMatrix(GrayscaleMatrix);
        using var paint = new SKPaint { ColorFilter = filter };
        return Draw(source.Width, source.Height, canvas =&amp;gt; canvas.DrawBitmap(source, 0, 0, CopySampling, paint));
    }

    private static SKBitmap Crop(SKBitmap source, ImageOperation operation)
    {
        var area = SKRectI.Create(operation.Left, operation.Top, operation.Width, operation.Height);
        if (area.IsEmpty || !SKRectI.Create(source.Width, source.Height).Contains(area))
        {
            throw new ArgumentOutOfRangeException(nameof(operation), $"Crop area {area} is outside the image bounds.");
        }

        return Draw(area.Width, area.Height, canvas =&amp;gt; canvas.DrawBitmap(source, -area.Left, -area.Top, CopySampling));
    }

    private static SKBitmap Resize(SKBitmap source, int width, int height)
    {
        var size = TargetSize(source, width, height);
        return Draw(size.Width, size.Height, canvas =&amp;gt;
            canvas.DrawBitmap(source, SKRect.Create(size.Width, size.Height), ResizeSampling));
    }

    /// &amp;lt;summary&amp;gt;ImageSharp BoxPad: fit inside the box without upscaling, center, fill the rest with the background color.&amp;lt;/summary&amp;gt;
    private static SKBitmap ResizeKeepScale(SKBitmap source, ImageOperation operation)
    {
        var box = TargetSize(source, operation.Width, operation.Height);
        var scale = Math.Min(1f, Math.Min(box.Width / (float)source.Width, box.Height / (float)source.Height));
        var width = Math.Max(1, (int)MathF.Round(source.Width * scale));
        var height = Math.Max(1, (int)MathF.Round(source.Height * scale));
        var destination = SKRect.Create((box.Width - width) / 2, (box.Height - height) / 2, width, height);
        var background = ParseColor(operation.BackgroundColor);

        return Draw(box.Width, box.Height, canvas =&amp;gt;
        {
            canvas.Clear(background);
            canvas.DrawBitmap(source, destination, ResizeSampling);
        });
    }

    private static SKBitmap Rotate(SKBitmap source, double degrees)
    {
        var radians = degrees * Math.PI / 180;
        var cos = Math.Abs(Math.Cos(radians));
        var sin = Math.Abs(Math.Sin(radians));
        // Round before Ceiling so right angles don't grow by a pixel from floating point noise (50.0000001 -&amp;gt; 51).
        var width = (int)Math.Ceiling(Math.Round(source.Width * cos + source.Height * sin, 3));
        var height = (int)Math.Ceiling(Math.Round(source.Width * sin + source.Height * cos, 3));

        return Draw(width, height, canvas =&amp;gt;
        {
            canvas.Translate(width / 2f, height / 2f);
            canvas.RotateDegrees((float)degrees);
            canvas.DrawBitmap(source, -source.Width / 2f, -source.Height / 2f, RotateSampling);
        });
    }

    /// &amp;lt;summary&amp;gt;Same as ImageSharp: a 0 dimension is derived from the other one, keeping the aspect ratio.&amp;lt;/summary&amp;gt;
    private static SKSizeI TargetSize(SKBitmap source, int width, int height)
    {
        if (width == 0 &amp;amp;&amp;amp; height &amp;gt; 0)
        {
            width = Math.Max(1, (int)MathF.Round(source.Width * height / (float)source.Height));
        }

        if (height == 0 &amp;amp;&amp;amp; width &amp;gt; 0)
        {
            height = Math.Max(1, (int)MathF.Round(source.Height * width / (float)source.Width));
        }

        if (width &amp;lt;= 0 || height &amp;lt;= 0)
        {
            throw new ArgumentOutOfRangeException(nameof(width), $"Invalid target size {width}x{height}.");
        }

        return new SKSizeI(width, height);
    }

    /// &amp;lt;summary&amp;gt;Parses ImageSharp-style hex colors (#RGB, #RGBA, #RRGGBB, #RRGGBBAA). Empty means white.&amp;lt;/summary&amp;gt;
    private static SKColor ParseColor(string value)
    {
        if (string.IsNullOrEmpty(value))
        {
            return SKColors.White;
        }

        var hex = value.TrimStart('#');
        // Skia expects the alpha first (#ARGB / #AARRGGBB).
        var argb = hex.Length switch
        {
            4 =&amp;gt; hex[3..] + hex[..3],
            8 =&amp;gt; hex[6..] + hex[..6],
            _ =&amp;gt; hex,
        };

        return SKColor.TryParse(argb, out var color)
            ? color
            : throw new ArgumentException($"Invalid background color '{value}'.", nameof(value));
    }

    private static void Encode(SKBitmap image, Stream output, RenderRequest request)
    {
        using var pixmap = image.PeekPixels();
        var quality = Math.Clamp(request.Quality, 0, 100);

        var encoded = request.MimeType switch
        {
            "image/png" or "image/x-icon" =&amp;gt;
                pixmap.Encode(output, new SKPngEncoderOptions(SKPngEncoderFilterFlags.AllFilters, PngZLibLevel(request.Quality))),
            "image/jpg" or "image/jpe" or "image/jpeg" or "image/pjpeg" =&amp;gt;
                pixmap.Encode(output, new SKJpegEncoderOptions(quality)),
            "image/webp" =&amp;gt;
                pixmap.Encode(output, new SKWebpEncoderOptions(SKWebpEncoderCompression.Lossy, quality)),
            _ =&amp;gt; throw new NotSupportedException($"Not supported image format [{request.MimeType}] when creating images."),
        };

        if (!encoded)
        {
            throw new InvalidOperationException($"Failed to encode image as [{request.MimeType}].");
        }
    }

    /// &amp;lt;summary&amp;gt;Same quality -&amp;gt; compression mapping as the default service: 100 = level 0 ... &amp;amp;lt;20 = level 9.&amp;lt;/summary&amp;gt;
    private static int PngZLibLevel(int quality) =&amp;gt; quality &amp;lt; 0 ? 6 : Math.Clamp(10 - quality / 10, 0, 9);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two details that are easy to miss:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Channel order&lt;/strong&gt; (&lt;strong&gt;ParseColor&lt;/strong&gt;). ImageSharp parses &lt;strong&gt;#RRGGBBAA&lt;/strong&gt;, Skia parses &lt;strong&gt;#AARRGGBB&lt;/strong&gt;. Rotate the hex string before parsing, or &lt;strong&gt;#00FF0080&lt;/strong&gt; comes out as a different color.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;90&amp;deg; rotation&lt;/strong&gt; (&lt;strong&gt;Rotate&lt;/strong&gt;). &lt;strong&gt;cos(90&amp;deg;)&lt;/strong&gt; evaluates to &lt;strong&gt;6e-17&lt;/strong&gt;, not 0, so &lt;strong&gt;Math.Ceiling(50.00000000000001)&lt;/strong&gt; gives 51 and the output gains an extra pixel. Round before taking the ceiling.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Registration&lt;/h3&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;using EPiServer.ImageLibrary;
using YourSite.Business.Imaging;

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services
            .AddCmsAspNetIdentity&amp;lt;ApplicationUser&amp;gt;()
            .AddCms();

        // Replaces EPiServer.ImageLibrary.ImageSharp for thumbnails and the CMS image editor.
        services.AddSingleton&amp;lt;IImageService, SkiaImageService&amp;gt;();
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The CMS only registers a fallback &lt;strong&gt;IImageService&lt;/strong&gt; when none exists (&lt;strong&gt;TryAddSingleton&lt;/strong&gt;), so your implementation takes precedence. Without any registration, that fallback throws &lt;strong&gt;InvalidOperationException&lt;/strong&gt; the first time it's resolved.&lt;/p&gt;
&lt;pre class="language-text"&gt;&lt;code&gt;$ dotnet list package --include-transitive --vulnerable
The given project `Alloy` has no vulnerable packages given the current sources.
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Known limitations&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Skia encodes PNG, JPEG and WebP only. GIF, BMP or TIFF output throws &lt;strong&gt;NotSupportedException&lt;/strong&gt;, so the image editor won't work for those formats. Thumbnails aren't affected, because the CMS always stores them as &lt;strong&gt;.png&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Skia can't decode TIFF, so TIFF uploads get no thumbnail. The upload itself still succeeds, because the CMS handles thumbnail errors.&lt;/li&gt;
&lt;li&gt;Animated GIFs are flattened to the first frame.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; this sample is not an officially supported Optimizely package. It's provided as guidance, and you own it once it's in your solution. Test it against your own media library before you deploy.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;6. Option 3: suppress the warnings&lt;/h2&gt;
&lt;pre class="language-xml"&gt;&lt;code&gt;&amp;lt;ItemGroup&amp;gt;
  &amp;lt;NuGetAuditSuppress Include="https://github.com/advisories/GHSA-gwg2-r3hj-4w44" /&amp;gt;
  &amp;lt;NuGetAuditSuppress Include="https://github.com/advisories/GHSA-j3p4-wp97-rph4" /&amp;gt;
  &amp;lt;NuGetAuditSuppress Include="https://github.com/advisories/GHSA-j9gm-c75j-xc9q" /&amp;gt;
  &amp;lt;NuGetAuditSuppress Include="https://github.com/advisories/GHSA-wmxv-xphr-5c9g" /&amp;gt;
  &amp;lt;NuGetAuditSuppress Include="https://github.com/advisories/GHSA-jjfr-hcj7-qf5w" /&amp;gt;
&amp;lt;/ItemGroup&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Suppressing only hides the warning, the vulnerability is still there. Record the decision (reason, owner, review date) wherever your team tracks accepted risks.&lt;/p&gt;
&lt;h2&gt;7. Option 4: wait for an updated package&lt;/h2&gt;
&lt;p&gt;An updated &lt;strong&gt;EPiServer.ImageLibrary.ImageSharp&lt;/strong&gt; would remove the problem at the source. We can't share a timeline for that yet. If you go this route:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;Watch the Optimizely release notes for a new &lt;strong&gt;EPiServer.ImageLibrary.ImageSharp&lt;/strong&gt; version, or check from the command line:&lt;/p&gt;
&lt;pre class="language-bash"&gt;&lt;code&gt;dotnet list package --outdated --include-transitive | grep -i imagelibrary
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Combine it with option 3 in the meantime, and add a review date so the suppression gets removed once the update ships:&lt;/p&gt;
&lt;pre class="language-xml"&gt;&lt;code&gt;&amp;lt;!-- TEMP: waiting for an EPiServer.ImageLibrary.ImageSharp update without the vulnerable ImageSharp.
     Review by: 2026-12-31. Owner: @team-cms --&amp;gt;
&amp;lt;NuGetAuditSuppress Include="https://github.com/advisories/GHSA-j3p4-wp97-rph4" /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;If you're on option 2, moving to the official package later means deleting your &lt;strong&gt;IImageService&lt;/strong&gt; implementation and its registration.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;References&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;SixLabors/ImageSharp, 4.x license discussion: &lt;a href="https://github.com/SixLabors/ImageSharp/issues/3205#issuecomment-6062121101"&gt;https://github.com/SixLabors/ImageSharp/issues/3205#issuecomment-6062121101&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Optimizely &lt;strong&gt;IImageService&lt;/strong&gt; API: &lt;a href="/CsClassLibraries/cms/EPiServer.ImageLibrary.IImageService?version=12"&gt;https://world.optimizely.com/CsClassLibraries/cms/EPiServer.ImageLibrary.IImageService?version=12&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;SkiaSharp: &lt;a href="https://github.com/mono/SkiaSharp"&gt;https://github.com/mono/SkiaSharp&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;NuGet dependency resolution: &lt;a href="https://learn.microsoft.com/nuget/concepts/dependency-resolution"&gt;https://learn.microsoft.com/nuget/concepts/dependency-resolution&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;NuGet Audit: &lt;a href="https://learn.microsoft.com/nuget/concepts/auditing-packages"&gt;https://learn.microsoft.com/nuget/concepts/auditing-packages&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Advisories: &lt;a href="https://github.com/advisories/GHSA-gwg2-r3hj-4w44"&gt;GHSA-gwg2-r3hj-4w44&lt;/a&gt;, &lt;a href="https://github.com/advisories/GHSA-j3p4-wp97-rph4"&gt;GHSA-j3p4-wp97-rph4&lt;/a&gt;, &lt;a href="https://github.com/advisories/GHSA-j9gm-c75j-xc9q"&gt;GHSA-j9gm-c75j-xc9q&lt;/a&gt;, &lt;a href="https://github.com/advisories/GHSA-wmxv-xphr-5c9g"&gt;GHSA-wmxv-xphr-5c9g&lt;/a&gt;, &lt;a href="https://github.com/advisories/GHSA-jjfr-hcj7-qf5w"&gt;GHSA-jjfr-hcj7-qf5w&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</summary></entry><entry><title>Optimizely CMS 12 on .NET 10: Fixing a Siteimprove TypeMismatchException</title><link href="https://madhuanbalagan.com/?p=5093" /><id>https://madhuanbalagan.com/?p=5093</id><published>2026-10-09T21:58:00.0000000Z</published><updated>2026-10-12T02:30:54.0000000Z</updated><author><name>madhumidha.a@infosys.com</name></author><category term="Blog post" /><summary type="html">&lt;p&gt;Recently, while upgrading an Optimizely CMS 12 solution from .NET 8 to .NET 10, we encountered a frustrating exception that initially looked like a CMS&amp;#46;&amp;#46;&amp;#46;&lt;/p&gt;
&lt;p&gt;The post &lt;a href="https://madhuanbalagan.com/optimizely-cms-12-on-net-10-fixing-a-siteimprove-typemismatchexception"&gt;Optimizely CMS 12 on .NET 10: Fixing a Siteimprove TypeMismatchException&lt;/a&gt; appeared first on &lt;a href="https://madhuanbalagan.com"&gt;Madhu Anbalagan&amp;#039;s Blog&lt;/a&gt;.&lt;/p&gt;
</summary></entry><entry><title>What made Scheduled Jobs faster in Optimizely CMS 13.3.0</title><link href="https://www.gulla.net/en/blog/what-made-scheduled-jobs-faster-in-optimizely-cms-13.3.0/" /><id>https://www.gulla.net/en/blog/what-made-scheduled-jobs-faster-in-optimizely-cms-13.3.0/</id><published>2026-10-09T06:00:00.0000000Z</published><updated>2026-10-09T07:30:11.0000000Z</updated><author><name>tomas.h.gulla@novacare.no</name></author><category term="Blog post" /><summary type="html">A look under the hood at the update that made scheduled jobs faster.</summary></entry><entry><title>New DDS explorer addon for Optimizely CMS</title><link href="https://www.gulla.net/en/blog/new-dds-explorer-addon-for-optimizely-cms/" /><id>https://www.gulla.net/en/blog/new-dds-explorer-addon-for-optimizely-cms/</id><published>2026-10-08T18:27:59.0000000Z</published><updated>2026-10-08T19:30:22.0000000Z</updated><author><name>tomas.h.gulla@novacare.no</name></author><category term="Blog post" /><summary type="html">I wanted to be able to manage Dynamic Data Store from within Optimizely CMS, so I created another addon.</summary></entry><entry><title>Queryable or Searchable? How indexing type changes your Optimizely Graph query</title><link href="http://optimizelyfoundation.com/?p=694" /><id>http://optimizelyfoundation.com/?p=694</id><published>2026-10-08T13:06:39.0000000Z</published><updated>2026-10-08T13:30:45.0000000Z</updated><author><name>ckhanna@horizontal.com</name></author><category term="Blog post" /><summary type="html">The Property Indexing Type on a CMS field decides whether Optimizely Graph will return it, filter it, or run full-text search on it. Default, Queryable, Searchable, and Disabled each change the GraphQL query.</summary></entry></feed>