Alarms and the rules engine

Axiom Border decides locally. A probe that only forwards data is useless the moment the uplink drops, so alarm evaluation runs on the device, against data it already holds, with no dependency on the platform.

There are two ways to express what should raise an alarm:

Mechanism Use it for Defined via
Checks — declarative rules Thresholds, event matches, dead-machine detection /checks API
Scripts — rules written in JavaScript Anything with logic: correlation, state, arithmetic, calls to the AI model /scripts API

Checks cover most of what monitoring needs and need no code. Scripts exist for the cases checks cannot express.

Where you work with alarms

Alerts in the console is the operator’s view: the alarms currently raised, the history of what has been logged, and the Rules configuration button that opens the editor for the rules described below. An autoreload toggle keeps the list current while you watch an incident develop.

The Alerts view listing raised alarms with their severity and originating rule

Each row names the alarm, the check type behind it, the node it came from, the agent, the severity and a description that includes when the condition was first seen. ACK acknowledges the selected alarm.

Two tabs sit above the table. Recent reads the live feed — the most recent alarms the probe already holds, answered immediately. Range queries a time window from history instead, which is what you want when reconstructing an incident after the fact:

The Range tab, querying alarm history over a time window

Rules configuration opens the Alarm config screen, which is where both kinds of rule live — the declarative alarm rules in the upper table, and the JavaScript Expert System at the bottom with its own enable switch:

The Alarm config screen, listing alarm rules and the Expert System section

Everything on this page can be done from that screen or from the API, and both paths are shown together wherever they differ.

Checks — declarative rules

A check watches one event type from one agent over a time window, and raises an alarm when its condition holds.

flowchart TB
    EV["Event arrives"] --> MATCH{"Matches<br>a check?"}
    MATCH -->|no| DROP["Stored only"]
    MATCH -->|yes| TYPE{"Check type"}
    TYPE -->|count| CNT{"Count over<br>threshold?"}
    TYPE -->|deadmachine| SILENT{"Silent for<br>the window?"}
    CNT -->|no| WAIT["Keep counting"]
    CNT -->|yes| FIRE["Raise alarm"]
    SILENT -->|yes| FIRE
    FIRE --> COAL{"Alarm<br>already open?"}
    COAL -->|yes| INC["Increment count"]
    COAL -->|no| NEW["New alarm"]

The two check types

ctype Fires when threshold
count The event occurs at least threshold times within freqs Required
deadmachine No events arrive from the node within freqs Not used, send 0

deadmachine is the one worth calling out: it alarms on absence, which is how you detect a node that stopped reporting rather than a node reporting something bad.

Alarming on every occurrence

To raise an alarm whenever an event happens at all, use a count check with "threshold": 1 and a short freqs. The first matching event trips the condition immediately.

Creating a check

From Alerts → Rules configuration, use New alarm rule. The wizard walks three steps — Rule config, Alarm config and Summary — and collects exactly the fields described above:

Step one of the new alarm rule wizard

Rule type is the check type, Agent and event choose what to watch, Trigger sets the window and the count, and UUIDs selects the nodes. Only agents actually reporting in are offered, which is why a rule cannot be created before its agent has sent data.

curl -s -X POST http://192.168.1.10:8083/checks \
  -H "Authorization: Bearer <jwt-token>" \
  -H 'Content-Type: application/json' \
  -d '{
        "checkName": "ssh-bruteforce",
        "agent": "ssh",
        "ctype": "count",
        "event": "login_attempt_fail",
        "freqs": "5m",
        "threshold": 10,
        "level": "critical",
        "alarmName": "SSH brute force attempt",
        "alarmDescription": "More than 10 failed SSH logins in 5 minutes",
        "uuids": ["<machine-id>"],
        "enable": true
      }'

Every field is required on create. freqs is a duration string — 30s, 5m, 1h. level is info, low, medium, high or critical. uuids lists the nodes the check applies to, and accepts alias names when the request carries ?alias=true.

A dead-machine check for the same node:

curl -s -X POST http://192.168.1.10:8083/checks \
  -H "Authorization: Bearer <jwt-token>" \
  -H 'Content-Type: application/json' \
  -d '{
        "checkName": "node-silent",
        "agent": "metrics",
        "ctype": "deadmachine",
        "event": "cpu",
        "freqs": "10m",
        "threshold": 0,
        "level": "critical",
        "alarmName": "Node stopped reporting",
        "alarmDescription": "No metrics received from the node in 10 minutes",
        "uuids": ["<machine-id>"],
        "enable": true
      }'

Managing checks

Method Path Notes
GET /checks Lists all checks. Returns 204 when there are none
POST /checks Create. Returns 201
PUT /checks Update
DELETE /checks Body {"name": "<checkName>"}
Three fields cannot be updated

PUT /checks keeps the stored ctype, agent and event. To change what a check watches or how it evaluates, delete it and create it again. Everything else — threshold, window, level, alarm text, nodes, enablement — updates normally.

To pause a check without losing its definition, set enable: false rather than deleting it.

Alarm coalescing

A brute-force attempt that trips a threshold every five minutes for an hour should be one alarm with a count, not twelve identical alarms. Axiom Border coalesces repeated firings of the same rule against the same node into a single open alarm:

Field Meaning
firstSeen When the condition first held
lastSeen The most recent firing
count How many times it has fired

The alarm stays open and accumulating until someone acknowledges it. Acknowledgement is what closes the coalescing window — the next occurrence after an acknowledgement opens a fresh alarm, so the operator gets a new signal rather than a silent increment on something they already dealt with.

Acknowledging

curl -s -X PATCH 'http://192.168.1.10:8083/alarms?alarmid=<alarm-id>' \
  -H "Authorization: Bearer <jwt-token>"

This sets ack=1 and records ackTime. Acknowledging an already-acknowledged alarm returns 400, so a UI can treat that as “someone else got there first” rather than an error worth surfacing loudly.

Reading alarms

# Live feed
curl -s -X POST http://192.168.1.10:8083/recent/alarms \
  -H "Authorization: Bearer <jwt-token>" \
  -H 'Content-Type: application/json' \
  -d '{"limit": 50, "orderBy": "desc"}'

# Historical, paginated
curl -s -X POST http://192.168.1.10:8083/alarms \
  -H "Authorization: Bearer <jwt-token>" \
  -H 'Content-Type: application/json' \
  -d '{"from": "2026-08-01T00:00:00Z", "to": "2026-08-03T00:00:00Z", "pageSize": 100}'

An alarm carries alarmId, alarmName, alarmDescription, criticality, eventType, agent, agentUuid, time, ackTime, and the coalescing trio.

Alarms are stored in the eg_alarms bucket. Evaluation applies a tolerance window set by alerts.offset (default 10 s) to absorb clock skew and ingestion latency — without it, an event landing milliseconds outside a window boundary would be missed.

Rule scripts

For logic that a declarative check cannot express, Axiom Border runs rule scripts written in JavaScript. They are evaluated on the device, server-side, on events you did not trigger.

In the console these are the Expert System, edited from Alerts → Rules configuration → Edit script. The editor opens on a working skeleton, so you can see the shape a script must have before writing one:

The Expert System script editor, showing the process(metrics) skeleton

Note the Enable switch beside the editor on the Alarm config screen: a saved script does nothing until it is turned on.

How scripts are organised

A script is identified by name, and the name determines when it runs. Scripts named after an agent — ssh, iface, usb, metrics — run on events for that agent. Scripts named after a probe — network, vulns, snmp, sniff — run on that probe’s findings. A general-purpose rulesengine script runs over every ingested batch.

Every script has an enabled state, set with the ?enabled= query parameter when you upload it and reported by GET /scripts. A disabled script is stored but never invoked.

The script contract

A script must define a function called process, which receives the batch of metrics:

function process(metrics) {
  for (var i = 0; i < metrics.length; i++) {
    var metric = metrics[i];
    metric.SetT0();
    // your logic here
    metric.SetT1();
  }
}
metrics is not a real JavaScript array

Iterate it by index with .length, as above. .filter(), .map() and .forEach() are not available and will throw. This is the single most common mistake when writing a first rule.

SetT0() and SetT1() bracket your processing and are what populate the timing fields in the audit record. They are not required for the rule to work, but including them is the convention and it makes slow rules visible.

Available globals

Function Purpose
newAlarm(metric, criticality, description, alarmName, eventType) Raise an alarm
newPredict(payloadJSON, agentType) Call the AI model for inference
newGetMetrics(agentType) Fetch the AI model’s own metrics
filterByIp(metrics, ip) Subset of the batch matching an IP
filterByProbe(metrics, probe) Subset matching a probe name
distinctIps(metrics) Distinct IPs, in first-appearance order
print(...) Write to the log at debug level

Each metric exposes: GetTagByName(key), GetFieldByName(key), ExistsField(key), GetMetricName(), GetAliasName(), GetAgentType(), GetMonitoredEvents(), GetEventValues(), GetTime(), SetT0(), SetT1() and String().

A worked example, raising an alarm when a scan finds a risky port open:

var RISKY_PORTS = { 23: "telnet", 21: "ftp", 3389: "rdp", 445: "smb", 5900: "vnc" };

function process(metrics) {
  for (var i = 0; i < metrics.length; i++) {
    var m = metrics[i];
    m.SetT0();

    if (m.GetTagByName("kind") === "port" && m.GetFieldByName("portStatus") === "open") {
      var port = m.GetFieldByName("portNumber");
      if (RISKY_PORTS[port]) {
        newAlarm(m, "critical",
          "Risky service " + RISKY_PORTS[port] + " exposed on port " + port,
          "Risky port open", "port_discovery");
      }
    }

    m.SetT1();
  }
}

The tags and fields available per probe are documented in the header comment of each deployed script, so read the script with GET /scripts/{scriptname} before writing rules against it.

What probe rules can react to

Probe rules react to appearances and changes: a new host, a new open port, a changed SNMP value. To alarm on a node that stops responding, use a deadmachine check against that node’s agent data instead — that is exactly what checks on absence are for.

Managing scripts

Method Path Notes
GET /scripts Names and enabled state. 204 when there are none
GET /scripts/{scriptname} Source, as text/plain
POST /scripts/{scriptname}?enabled=<bool> Create. Body is the JavaScript source as text/plain
PUT /scripts/{scriptname}?enabled=<bool> Replace the source
DELETE /scripts/{scriptname} Delete
# Upload a rule for SSH events
curl -s -X POST 'http://192.168.1.10:8083/scripts/ssh?enabled=true' \
  -H "Authorization: Bearer <jwt-token>" \
  -H 'Content-Type: text/plain' \
  --data-binary @ssh-rule.js

# Read back what is deployed
curl -s http://192.168.1.10:8083/scripts/ssh \
  -H "Authorization: Bearer <jwt-token>"

Uploading through the API is the recommended path — it works on a probe you have no shell access to, and it is the same mechanism the web console uses.

Always set a script timeout

Each script invocation is bounded by gojaTimeout. Define it explicitly: without a value the limit is zero and no script runs at all, which shows up as missing alarms rather than as an error. The shipped value is 8s.

gojaTimeout: "8s"

Scripts can call the AI model

A script can invoke inference against a deployed AI capability, using the predict endpoint configured under trainerDetails.predict. This is what connects anomaly detection to alarm generation: the model scores an event, and the script decides whether that score warrants an alarm.

Timeouts and retries for that call come from trainerDetails.predict — default 10 s, one retry. Note that the call is bounded by gojaTimeout as well, so a predict timeout longer than the script timeout cannot complete. Keep gojaTimeout comfortably above the predict timeout if your rules use inference.

See AI capabilities for deploying a model in the first place.

Security probe results reach the rules engine too

Security probe findings are evaluated by the rules engine and audited exactly like agent metrics. A scan finding can therefore raise an alarm the same way a metric can — a newly discovered host, a critical vulnerability, a port that opened when it should not have. See Security probes.

Choosing between a check and a script

Reach for a check when the condition is “this event, this many times, this window”. It is declarative, visible in the API, and cannot fail in interesting ways.

Reach for a script when you need to remember something between events, combine two signals, compute a value, or ask the model. The cost is that scripts are code: they need a timeout that works, they fail in ways checks do not, and they are harder to audit at a glance.

If a check can express it, use the check.

Where alarms go next

Locally, alarms land in eg_alarms and surface through the feeds above and in the web console.

If OpenGate integration is enabled, alarms and executions also publish over the embedded MQTT broker and are forwarded to the platform, which is how a fleet of probes becomes a single operational picture. See MQTT and OpenGate operations.