Ritu Madan
OMVPOpal Administrator CertificationPaaS CMS Administrator Certification +4
Oct 14, 2026
visibility 18
star star star star star
(0 votes)

Opal Workflow Agents And The Graph Webhook Trap

What we did?

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.

One editorial action in, one review out. That was the whole contract. A complete run costs 100–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.

The trigger was the only open question. Opal workflow agents can fire a few ways — chat input, a schedule, email, or a webhook — 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 “this page entered review” and POST to the trigger URL. The obvious source was Optimizely Graph. It’s pure configuration — register a webhook through the API, no CMS code to deploy — 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.

What we didn’t see coming?

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.

Then the agent did the one thing it was built to do: it wrote its findings back onto the page.

That write-back was a save. The save synced to Graph. The content was still InReview — writing findings doesn’t change an approval status — so Graph delivered a webhook for an InReview document, which is exactly the event we’d subscribed to. The agent fired again. Checked again. Wrote back again. Synced again.

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.

Nothing was broken, which is what made it hard. Graph was doing exactly what Graph does.

Why it looped?

Seeing the behaviour was easy. Finding the cause took a while. Two facts about Graph, neither of them a bug, combined into the loop:

Graph syncs on save, not on intent. It re-indexes whenever content is saved, published, moved, deleted, or expired. Our agent’s write-back is a save like any other, so every write re-syncs.

Status is part of a document’s identity. Graph identifies an indexed document as {contentGuid}_{version}_{locale}_{status}. Our write-back changed the content but left the status at InReview — so every re-sync produced the same InReview document, indistinguishable from the submit event we’d subscribed to. There was no status change to mark the work as handled, and no state anywhere saying “this one’s done.” So each sync looked like a fresh reason to run.

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 — and the agent never stopped touching the page.

The fix: subscribe to the decision, not the side effect

Optimizely CMS already raises an event for the exact moment we cared about. The approval engine exposes IApprovalEngineEvents, and its Started event fires once, when an approval instance begins — i.e. when a human submits for review. It’s raised by the approval lifecycle, not the persistence layer. Background saves, forced writes, and our own agent’s write-back don’t start approval instances, so they don’t raise it.

So instead of inferring the decision from Graph, we subscribed to it directly in the CMS solution and POSTed to the Opal trigger ourselves:

[InitializableModule]
[ModuleDependency(typeof(EPiServer.Web.InitializationModule))]
public class ComplianceReviewTriggerModule : IInitializableModule
{
    private IApprovalEngineEvents _approvalEvents;
    // IContentLoader, IHttpClientFactory, config resolved in Initialize()

    public void Initialize(InitializationEngine context)
    {
        _approvalEvents = context.Services.GetRequiredService<IApprovalEngineEvents>();
        _approvalEvents.Started += OnApprovalStarted;   // raised once, on submit
    }

    private void OnApprovalStarted(object sender, ApprovalEventArgs e)
    {
        if (!e.IsContentApproval()) return;             // approvals are generic
        var contentLink = e.GetContentLink();           // extension method
        if (ContentReference.IsNullOrEmpty(contentLink)) return;

        if (!_contentLoader.TryGet<MyCustomPage>(contentLink, out var page))
            return;                                     // scope to our content type

        // Mirror Graph's docId shape so the workflow is source-agnostic.
        var docId = $"{page.ContentGuid}_{contentLink.WorkID}_{page.Language.Name}_InReview";
        _ = Task.Run(() => TriggerOpalAsync(docId));    // fire and forget, with a timeout
    }
}

A few things that mattered in practice:

•             ApprovalEventArgs has no ContentLink. Approvals are a general mechanism; content approvals are one implementation. You reach the item through the IsContentApproval() and GetContentLink() extension methods in EPiServer.Approvals.ContentApprovals.

•             The Opal trigger wants auth. The webhook trigger takes an Auth Key and sends it as a header (e.g. Authorization: Bearer {token}). Our POST sets that header from config; a request that doesn’t match is rejected.

•             We mirrored Graph’s docId shape in the payload, so the downstream workflow didn’t need to change. If we ever switch sources again, the agent doesn’t care.

•             Fire-and-forget has a cost. Blocking the editor’s submit on an outbound call is a bad idea, so we don’t. The trade is that if Opal is unreachable the moment Started fires, that trigger is lost — at-most-once. For a review flow that’s fine, because an editor can resubmit. If it weren’t, we’d add retries or a durable queue.

Result: one submit, one execution. The write-back still syncs to Graph, but we’re no longer listening to Graph for this trigger, and writing content doesn’t start an approval — so there’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.

So when is Graph the right source?

The lesson isn’t “Graph is wrong.” Graph is excellent — for the thing it actually reports, which is content as the published world sees it.

Reach for Graph when:

•             You’re reacting to content state, not editorial intent — a page got published, purge the CDN; rebuild the static site. This is Graph’s canonical use case, and duplicate deliveries are harmless because the second purge is a no-op.

•             The consumer is idempotent — ISR cache invalidation, deploy hooks, downstream sync. Firing twice costs nothing.

•             You don’t own the CMS codebase, or you’re on SaaS CMS. Graph is configuration, no deploy required.

•             The consuming system is decoupled from CMS and you don’t want to couple to its internals.

Reach for a CMS server-side event (the code path) when:

•             The trigger is an editorial decision — submitted, approved, rejected. That lives in the approval engine, and Graph can’t model it.

•             Your workflow writes back to the item that triggered it. This is the one that bit us; any index-based trigger risks the same loop.

•             Each execution is expensive. When a run costs real credits and minutes, precise firing matters far more than easy setup.

•             You need business rules before firing — content type, language, site. Strongly typed C# beats filtering on payload shape.

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.

The takeaway

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.

The real question is which of those you care about — a document changing or a person deciding — 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.

Ours was reporting every save. Our agent just happened to be the thing doing the saving.

Oct 14, 2026

Comments

error Please login to comment.
Latest blogs
cms13-scan: A Deterministic CMS 13 Readiness Scanner

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

Adnan Zameer | Oct 12, 2026 |

Setting Up an Optimizely CMS 13 MCP Server Locally with Codex

Optimizely CMS 13 supports Model Context Protocol (MCP) , allowing AI tools such as Claude and Codex to interact with CMS content and metadata...

Mardy | Oct 12, 2026

Handling SixLabors.ImageSharp vulnerability warnings in Optimizely CMS 12 (unofficial)

UPDATED: Optimizely is officially working with SixLabors to find a solution, if possible; the work ID is CMS-57231. An official decision/announceme...

Cuong Nguyen Dinh | Oct 10, 2026

Optimizely CMS 12 on .NET 10: Fixing a Siteimprove TypeMismatchException

Recently, while upgrading an Optimizely CMS 12 solution from .NET 8 to .NET 10, we encountered a frustrating exception that initially looked like a...

Madhu | Oct 9, 2026 |

What made Scheduled Jobs faster in Optimizely CMS 13.3.0

A look under the hood at the update that made scheduled jobs faster.

Tomas Hensrud Gulla | Oct 9, 2026 |