Beta . Ortha CMS is in public beta. Every plan is free until it ends.
Features Ortha CMS on GitHub

Alarms plugin

Alarms plugin @orthacms/alarms-server@orthacms/alarms-admin

Flagging content a workspace considers wrong — rules, findings, routes and tuning.

Documents 0.13.1 Updated Edit this page Report a problem

On this page

Non-blocking content rules. “A published article whose author is still a draft” is flagged wherever that content is touched — and never refuses a save or a publish.

  • A rule = content type + records-list filter + severity.
  • A finding = one rule’s verdict about one entry.
  • Severities error, warn, info only sort and colour. None blocks. If something must not publish, put it in the schema.

Install

npm install @orthacms/alarms-server @orthacms/alarms-admin

Both are installed in every app create-orthacms-app generates.

Register

// apps/server/src/plugins.ts
import { AlarmsPlugin } from '@orthacms/alarms-server';

// after ContentPlugin — it uses content's registry, filters and grants
AlarmsPlugin({ sweepIntervalMinutes: 15 }); // options are optional
// apps/admin/src/plugins.ts
import { AlarmsPlugin } from '@orthacms/alarms-admin';

AlarmsPlugin(); // after ContentPlugin()

The admin half adds a Checks block to the entry editor’s Properties panel, an optional Checks column, the toolbar’s Save as alarm action and an Alarms workspace page. Since 0.12.0 it also reports the record’s open findings as one status item in the editor’s header (“2 checks flagged”) — a warning when any is an error, else info, never one that blocks publishing — and adds an open-findings card to Home’s Needs your attention, summed across your workspaces and shown at 0 too.

Since 0.13.0 the Alarms page has two tabs under its per-severity strip:

  • Flagged — one group per alarm (its severity, name, “Articles · shows editors “…””, Re-check and Edit), each row the record by title, “flagged 2 weeks ago” and Open, and Show all in Records, which opens the records list filtered by the alarm’s own condition.
  • Alarms — each alarm as a card: its condition as chips, “N flagged” or “Nothing flagged”, when it last checked, an on/off switch and a ⋯ menu (Edit, Re-check, Delete). Pausing is the switch, not a delete; switching it back on rescans, and the toast says what it found.

New alarm opens a two-column editor: What to watch, Alarm name, What editors will see, Level and Flag a record when… on the left, and Right now on the right — the live count, up to four matching records by title, and the finding as editors will see it. Create alarm creates it; editing saves from the top bar (Save changes) and stays on the page. The editor has no notes field: it never sends description, so a stored one is kept.

Configuration

Options passed to AlarmsPlugin(...). No environment variables — rules live in the database.

OptionDefaultBounds
maxScanEntries20,000Entries one full rescan of one rule examines
scanBatchSize500Entries read per batch (peak memory)
sweepIntervalMinutes60Background rescan interval. 0 turns it off
maxDependentsPerEvent500Fan-out of the reverse-relation pass

A zero, negative or fractional bound fails at boot (except sweepIntervalMinutes: 0).

Routes

Admin routes: session cookie and X-Workspace-Id — see calling admin routes.

MethodPathNeedsReturns
GET/api/alarms/rulesalarms:readAlarmRule[] with openCount
POST/api/alarms/rulesalarms:manage{ rule, scan } — scans immediately
POST/api/alarms/rules/previewalarms:manage{ matched, total, sampleIds, sample } — sample is { id, title }[]
PATCH/api/alarms/rules/:idalarms:manageThe rule plus scan — a filter change, or switching a disabled rule back on, rescans first; scan is null when none ran
DELETE/api/alarms/rules/:idalarms:manage204
POST/api/alarms/rules/:id/rescanalarms:manage{ ruleId, scanned, opened, resolved, open }
GET/api/alarms/findingsalarms:readPage of findings
GET/api/alarms/findings/by-entry?entryIds=a,balarms:read{ byEntry: { [id]: Finding[] } } (≤ 100 ids)
GET/api/alarms/findings/summaryalarms:read{ open: { error, warn, info }, openTotal }

Create a rule

Example language
curl -X POST https://cms.example.com/api/alarms/rules \
-b cookies.txt \
-H 'X-Workspace-Id: 3f7a…' \
-H 'Content-Type: application/json' \
-d '{
  "contentType": "article",
  "name": "Live articles point at published authors",
  "findingTitle": "Author is not published",
  "severity": "warn",
  "filter": { "and": [
    { "field": "status", "op": "eq", "value": "published" },
    { "field": "author.status", "op": "ne", "value": "published" }
  ] }
}'
curl -X POST https://cms.example.com/api/alarms/rules/preview \
-b cookies.txt \
-H 'X-Workspace-Id: 3f7a…' \
-H 'Content-Type: application/json' \
-d '{"contentType":"article","filter":{"and":[{"field":"status","op":"eq","value":"published"}]}}'

# { "matched": 12, "total": 340, "sampleIds": ["9c4b…", "…"],
#   "sample": [{ "id": "9c4b…", "title": "Spring launch" }, "…"] }
{
    "rule": {
        "id": "71c3…",
        "contentType": "article",
        "name": "Live articles point at published authors",
        "findingTitle": "Author is not published",
        "description": null,
        "severity": "warn",
        "filter": { "and": [ "…" ] },
        "enabled": true,
        "brokenReason": null,
        "lastScanAt": "2026-09-30T09:40:00.000Z",
        "createdAt": "2026-09-30T09:40:00.000Z",
        "updatedAt": "2026-09-30T09:40:00.000Z",
        "openCount": 3
    },
    "scan": { "ruleId": "71c3…", "scanned": 340, "opened": 3, "resolved": 0, "open": 3 }
}
  • filter is the object form of the records list’s ?filter= tree — see filtering. A rule means exactly what the list means.
  • name is unique per workspace (409 on a duplicate); name and findingTitle ≤ 120 chars, description ≤ 500.
  • contentType cannot change on PATCH — sending it is a 400.
  • A filter the engine rejects is a 400 with its message (e.g. an unknown field).
  • An unregistered and an ungranted type get the same 404.

Read findings

curl 'https://cms.example.com/api/alarms/findings?severity=error&page=1&pageSize=25' \
  -b cookies.txt -H 'X-Workspace-Id: 3f7a…'
{
    "items": [
        {
            "ruleId": "71c3…",
            "ruleName": "Live articles point at published authors",
            "title": "Author is not published",
            "contentType": "article",
            "entryId": "9c4b…",
            "entryTitle": "Spring launch",
            "severity": "warn",
            "state": "open",
            "detail": null,
            "firstSeenAt": "2026-07-01T10:00:00.000Z",
            "lastSeenAt": "2026-09-30T09:40:00.000Z"
        }
    ],
    "total": 1,
    "page": 1,
    "pageSize": 25
}

Query: state (open, resolved; omitted = everything but resolved), ruleId, severity, page, pageSize (default 25, ≤ 100).

Since 0.13.0 a finding on this page carries entryTitle — content’s own title rule (the first non-empty short text or select field), read once per content type on the page. It is null for an entry with no such value, or one that has gone, and a failed title read never loses the finding. The by-entry batch does not carry it.

How rules are evaluated

PathCovers
Entry lifecycle events from the outboxAnything being edited
An explicit rescan (and on create / filter edit)A new or changed rule
A periodic sweepEntries nobody touches — e.g. “not updated in 90 days”
  • Findings close themselves across relations. Publishing, unpublishing, deleting, restoring or purging an entry re-evaluates rules whose filter follows a relation to that type, for the entries linking to it (≤ maxDependentsPerEvent).
  • A rule whose filter stops parsing is marked broken (brokenReason) and skipped.
  • A rescan that hits maxScanEntries logs a warning instead of reporting clean.
  • entry.deleted resolves findings; entry.purged deletes them.
  • A finding is keyed (rule, entry), so repeat delivery is a no-op, and firstSeenAt survives a resolve-and-reopen.
  • The sweep is an in-process interval: several API processes simply sweep more often. Do not rely on it for anything that must run exactly once.
  • There is no mute. Narrow or switch off the rule instead. Switching it back on rescans before the PATCH answers, so its findings never describe the day it was paused.

Permissions

  • alarms:read — admin, contributor and viewer (findings show in the entry editor).
  • alarms:manage — admin only.
  • No API token scope carries either.

Copilot tool

admin_alarms_findings — a read of the same store. Copilot only, never MCP (a token could never hold alarms:read). Findings carry an entry id, so the natural next call is admin_content_get. There is no write tool.

Protection’s review state in a rule

With protection installed, reviewState is a filterable field, so a rule can flag entries still waiting on review:

{ "and": [{ "field": "reviewState", "op": "eq", "value": "awaiting" }] }