Developer Guide

Extending the CMS

Field options, validators, audience criteria and scheduled jobs — all found by deriving from a type.

Code that plugs into the CMS rather than rendering your site lives under Extensions/. Nothing in there is reached by a template and nothing in there has a URL.

Folder What lives there Contract
Extensions/Fields/ Field option providers, validators, audience criteria IFieldOptionsProvider, IFieldValidator, IAudienceCriterion
Extensions/Jobs/ Work the platform runs on a timer ScheduledJobBase

Deriving from the type is the registration

Every contract here is found by a scan of the assemblies your app was built against. A class is picked up on the next start with no call in Program.cs and no list to keep in sync.

The same scan finds contributions inside a NuGet package — which is the point of it. The compiler drops a reference to an assembly your code never mentions, and that is exactly the plug-in you are trying to load.

Options computed at runtime

[FieldOptions(typeof(RegionOptions))]
public DropDown? Region { get; set; }

The provider reads whatever it likes — configuration, a database, a service — so the list behind a dropdown changes when its source changes, with no rebuild and no schema reconcile.

Options that come from the CMS itself need no class of yours. The languages an installation is configured for already ship as a provider:

[FieldOptions(typeof(Cendia.AspNetCore.Schema.SiteCultureOptions))]

Scheduled jobs

Derive from ScheduledJobBase and the job appears on Settings → Scheduled Jobs on the next start, schedulable exactly like the ones the platform ships.

[ScheduledJob(
    DisplayName = "Report Stale Drafts",
    Description = "Lists drafts that nobody has touched for a fortnight.")]
public sealed class StaleDraftReportJob : ScheduledJobBase
{
    private readonly CmsDbContext _db;

    public StaleDraftReportJob(CmsDbContext db)
    {
        _db = db;

        // Only claim this if the work really checks, which this one does.
        IsStoppable = true;
    }
}

Note the plain constructor rather than a primary one. IsStoppable has to be set in a constructor body: discovery builds one instance at startup purely to read that property and decide whether the job's screen gets a Stop button.

Constructor injection works because the runner builds a new instance from the container for every run. Nothing registers the job.

Writing a job that behaves

  • Make it safe to run twice. It is on a timer, an administrator can press Start whenever they like, and a node that dies mid-run leaves work half done.
  • Return a sentence saying what changed. It is stored on the history row and is the only thing anyone reads when they ask what happened. "Reported 412 drafts" answers that; "Done" does not.
  • Honour Stop() if you claim IsStoppable, and watch the cancellation token as well. They are two ways of asking for the same graceful exit, and a job that watches only one will eventually be killed mid-write by the other.
  • Declare no DefaultEvery unless you mean it. Without one the job arrives Manual and runs only when somebody says so, which is the right default for a job just written.

You cannot choose its group, on purpose

New jobs land in Uncategorized and are filed by the administrator from Manage Groups. A category compiled into a package is a folder name the installation cannot change.

Every run is claimed once

A run is claimed by one atomic conditional update, so two nodes produce one run and pressing Start on an already-running job is refused rather than doubled. A claim whose heartbeat goes quiet is broken and recorded as Stopped, not Failed.

Repeats count from the start moment rather than the last finish, so a run that overruns does not walk a 02:00 job into the afternoon.

Credentials for AI

API keys are not an Extensions/ type and not an appsettings value. The next page is how a host chooses a secret store and which vendors Settings may offer.