Skip to content

Messaging

All inter-process communication goes through NATS. This page is the operator and integrator map: subject conventions, the messaging contexts, and every request-reply endpoint grouped by module.

Default module ids are shown. A deployment may override --id (for example recorder.prod); subjects then use that id in the first slot after local.

Subject conventions

Publish / subscribe

local.<moduleId>.<context>.<componentName>.<componentId>
Context Kind Typical producers Typical consumers
data pub/sub data sources recorder, aggregator, CLI subscribe
data_status pub/sub aggregator (per-batch stats on the source channel's ref) trigger, gate
status pub/sub every module trigger, gate, CLI
event2 pub/sub (core NATS) typed event producers trigger, observers
measurement pub/sub flower recorder
config pub/sub configurer every module's config handler
config_status pub/sub configurer and target modules CLI, API
cmd request-reply CLI, trigger, other modules the owning module

Events are fire-and-forget on core NATS: a consumer that is down misses them. Current state always lives on config status or periodic telemetry - see Events.

Commands

local.<moduleId>.cmd.<componentName>.<componentId>.<verb>

Six dot-separated segments after local. Wildcards appear in some registrations (*) but each live call resolves to a concrete subject.

Handlers use NATS micro services. Treat these response headers as failures:

  • Nats-Micro-Error - description
  • Nats-Micro-Error-Code - HTTP-like code (400, 404, 409, 429, 500, ...)

Successful replies are ordinary NATS responses (JSON, or raw bytes where noted).

What a declared command takes and answers is in the generated commands reference (RFC-040), rendered from the committed schema/command/ tree; a command not listed there is not declared in this build.

Readiness probe

Before other modules proceed they poll:

Subject Request Response
local.nats.cmd.app.main.status empty JSON status string: "OK" when the broker is ready

CLI to NATS

CLI command Subject
algol config ls / info local.cfg.cmd.store.main.list_config_status
algol config update local.cfg.cmd.store.main.update
algol config delete local.cfg.cmd.store.main.update (delete operation)
algol config patch local.cfg.cmd.store.main.patch
algol config commit local.cfg.cmd.store.main.commit
algol config delete --bundle local.cfg.cmd.store.main.delete_bundle
algol bundle update local.cfg.cmd.bundle.main.update
algol bundle activate local.cfg.cmd.bundle.main.activate
algol bundle delete local.cfg.cmd.bundle.main.delete
algol manifest import local.cfg.cmd.manifest.main.import
algol manifest export local.cfg.cmd.manifest.main.export
algol recording ls local.recorder.cmd.recstorage.main.recording_list
algol recording info local.recorder.cmd.recstorage.main.recording_get
algol trigger publish local.flower.cmd.flow.main.measurement_event_publish
algol nats ls local.nats.cmd.auth.main.ls
algol nats enrol local.nats.cmd.auth.main.enrol
algol nats revoke local.nats.cmd.auth.main.revoke
algol secret set local.cfg.cmd.store.main.update (the declaration, when there is none yet), then local.cfg.cmd.secret.<id>.set
algol secret get local.cfg.cmd.secret.<id>.get
algol command ls / info none: the registry compiled into the CLI (commands reference)
algol secret ls / rm config ls cfg.secret.* / config delete cfg.secret.<id>
algol command <ref> local.<moduleId>.cmd.<componentName>.<componentId>.<command> - any endpoint, by attribute ref

Sys, board-controller, audio, retx, and export start/stop have no verb of their own. Reach them with algol command (see CLI) or from a trigger command / jsonCommand action.


Configurer (cfg)

Health component: cfg.app.main. Bundle and manifest subjects use reserved pseudo-components cfg.bundle.main and cfg.manifest.main (not config types).

status

Subject local.cfg.cmd.app.main.status
Request empty
Response JSON component-status string ("OK", "WAIT", "ERROR", ...)
CLI none

list_config_status

Subject local.cfg.cmd.store.main.list_config_status
Request
{
  "filter": "sys.file.*",
  "include": {
    "startupConfig": false,
    "intentPublishedConfig": false,
    "intentConfigStatus": false,
    "effectivePublishedConfig": false,
    "effectiveConfigStatus": false
  }
}

| Response | {"result": [ ...records... ]} | | CLI | algol config ls, algol config info |

Each record carries ref, managedBy, bundle, and the included stage bodies. See Configuration for the five stages.

The summary include is a sixth view of those same five stages rather than a sixth stage: each one as a content digest plus what a row is rendered from - the enabled state, the request id, the status code and message, whether a status echoed the body it was sent - together with whether the intent is uncommitted and whether the config is read-only. It is what a listing asks for, and it asks for nothing else: the reply is a single message holding every config the filter matched, and a file config carrying inline content makes a body two orders of magnitude larger than a config body otherwise is. Bodies are read one ref at a time, which is what algol config info does.

update

Subject local.cfg.cmd.store.main.update
Request
{
  "ref": "<target component>",
  "op": 0,
  "version": 0,
  "requestId": "cli-...",
  "bundle": "",
  "config": { "type": "<configType>" },
  "force": false
}

op 0 is update. Delete uses the same subject with the delete operation and no config body.

| Response | ref, version, status, requestId, changedAt, config | | CLI | algol config update, algol config delete |

patch

Subject local.cfg.cmd.store.main.patch
Request ref, optional enabled, properties, version, requestId, force
Response same shape as update
CLI algol config patch

At least one of enabled, properties, or force is required. The CLI sets properties with -p/--property name=value (type taken from the existing bag) and toggles enabled with --enable/--disable.

commit

Subject local.cfg.cmd.store.main.commit
Request {"filter": "<wildcard ref>", "bundle": "", "force": false}
Response empty on success
CLI algol config commit

delete_bundle

Subject local.cfg.cmd.store.main.delete_bundle
Request {"bundle": "<bundleName>"}
Response {"result": [ { "ref": "...", "status": ..., "error": "..." } ]}
CLI algol config delete --bundle <b>

Bundle: update / activate / delete

Subject Request Response CLI
local.cfg.cmd.bundle.main.update id, optional add[], remove[], bundle, description id, bundle, description, members[], createdAt, digest, installedAt algol bundle update
local.cfg.cmd.bundle.main.activate {"id": "<bundleId>", "adopt": false} id, bundle, members[] with per-member status / error / adopted algol bundle activate
local.cfg.cmd.bundle.main.delete {"id": "<bundleId>", "force": false} id, bundle, stillRunning[], forced algol bundle delete

algol bundle ls / info use list_config_status over *.bundleinfo.*. There is no dedicated bundle-read endpoint.

Manifest: import / check / export

Import subject local.cfg.cmd.manifest.main.import
Request Raw manifest JSON or gzip-compressed bytes (not wrapped)
Headers X-Algol-Manifest-Adopt: true, X-Algol-Manifest-Replace: true (optional)
Response bundles[], activations[], configs[], removedConfigs[], removedBundles[]
CLI algol manifest import
Check subject local.cfg.cmd.manifest.main.check
Request The import's request, unchanged: same payload, same two headers
Response bundles[], activate[], configs[], removedConfigs[], removedBundles[], replace
CLI -

The dry run of that same import: it runs the import's own validation, writes nothing, and reports what an apply would do. It is what lets a caller name the configs a --replace restore would withdraw before an operator confirms it - a set that depends on the device, not on the manifest, so nothing but the store can derive it.

Export subject local.cfg.cmd.manifest.main.export
Request optional bundles[], noActivate (empty body = full backup)
Response Gzip-compressed manifest (Content-Type: application/gzip)
CLI algol manifest export

Secret: set / get

The value of a secret travels these two subjects only (RFC-001 D2), core NATS request-reply, answered into the requester's inbox. The requester is the reply subject's inbox prefix (_INBOX.<moduleId>, _INBOX.userkey.<key>, RFC-034 D8).

Set subject local.cfg.cmd.secret.<id>.set
Request {"value": "<base64>"}
Response {"changed": "<RFC3339>", "size": 28}
Errors 404 not declared, 400 the value does not fit the declared kind, 500 the store failed
CLI algol secret set <id> (--value V \| --from-file F \| -), which declares first through update when the id has none
Get subject local.cfg.cmd.secret.<id>.get
Request {}
Response {"kind": "string", "changed": "<RFC3339>", "value": "<base64>"}
Errors 404 not declared, 403 the requester holds no dispatched config referencing the secret from a type the declaration admits (a person is never refused), 409 no value set, 500 the store failed
CLI algol secret get <id> (prints the value, raw)

update and patch on a cfg.secret.* ref are answered with 403 unless the requester is a person: declaring and scoping a secret is a person's privilege (RFC-001 D10).


Sys (sys)

restart

Subject local.sys.cmd.systemd.ctl.restart
Request Raw UTF-8 systemd unit name (e.g. systemd-resolved.service)
Response empty
CLI none; trigger command

Restarts any systemd unit on the host by name - a keymaster capability in its own right, deliberately kept next to the per-component restarts below. Because the target is named in the body, this command is never grantable through a configured role's write set (RFC-035 D8); the per-component restarts are the grantable form.

systemdscript start

Subject local.sys.cmd.systemdscript.<id>.start
Request ignored
Response "ok"
Purpose Run a listen-mode script on demand

systemdapp start

Subject local.sys.cmd.systemdapp.<id>.start
Request ignored
Response "ok"
Purpose Mount the rootfs and run a listen-mode app workload

systemdscript / systemdapp restart

Subject local.sys.cmd.systemdscript.<id>.restart, local.sys.cmd.systemdapp.<id>.restart
Request ignored
Response "ok"
Errors 404 unknown component, 500 disabled or the unit operation failed
Purpose Restart the component's unit: a running unit is bounced, a loaded exited/failed one re-activated, a never-started one started. Not limited to listen mode.

The grantable form of restart (RFC-035 D8): a command names its component in the subject or is not grantable through a role's write set. The module-level restart by unit name stays alongside, for keymasters.

systemdmount release

Subject local.sys.cmd.systemdmount.<slot>.release
Request ignored
Response "ok" (acceptance only; unmount is asynchronous)
Purpose Unmount the slot without disabling the config, so an operator can swap media

Watch config status for WAIT released... before pulling the disk. See systemdmount.

GSM send_sms

Subject local.sys.cmd.gsm.<connId>.send_sms
Request {"number": "+...", "text": "..."}
Response "ok"
Errors 429 if the SMS queue is full

GSM reset

Subject local.sys.cmd.gsm.<connId>.reset
Request ignored
Response "ok"
Errors 404 unknown gsm component, 500 no modem or the reset failed
Purpose Hardware-reset the connection's modem

The per-component reshape of the retired reset-all-modems command (RFC-035 D8). The connection does not have to be active: a modem that has lost registration cannot bring one up, and that is the state a reset is for. Reset attempts and errors are counted per modem on the owning connection's status (modemResetCounter / modemResetErrCounter), against the modem's physical device rather than its object path, so a reset does not zero the count of itself.

LED set

Subject local.sys.cmd.led.<indicatorId>.set
Request JSON map of element name to pattern: { "green": "on", "red": "blink" }
Response "ok"

Omitted elements turn off. {} clears to dark where allowed. See led.


Recorder (recorder)

Catalog subjects are served on the recstorage component, which owns the medium and the catalog; the CLI addresses it with --recorder (default recorder.recstorage.main). The same component is the recording's owner: its ref is stamped into every Parquet file. It was named recorder.default.main before RFC-035. The rename re-keys nothing already written - archive and Recording refs derive from the recorder's module id, not its full ref - and recordings stamped with the old name stay managed (listed, counted, exported and cleaned up), so a medium upgraded in place holds both spellings and loses nothing.

recording_list

Subject local.recorder.cmd.recstorage.main.recording_list
Request
{
  "filter": { "measurement": "flower.measurement.*", "channel": "*.*.*" },
  "page": { "offset": 0, "limit": 100 },
  "order": "asc"
}

limit defaults to 100, max 500.

| Response | recordings[], offset, total | | CLI | algol recording ls |

Each recording includes recRef, ref, measurementRef, archiveRef, channelRef, channelType, freq, url, start, last, deleted, archiveIdx, archiveLen, size, digest, and context (deviceId, serialNr, measurementSequenceNr).

recording_get

Subject local.recorder.cmd.recstorage.main.recording_get
Request {"ref": "<recording component ref>"}
Response {"recording": { ... }} or 404
CLI algol recording info <recording-ref>

Export start / stop

One pair per export profile:

Profile Subjects Request Response
s3upload local.recorder.cmd.s3upload.<id>.start / .stop ignored "ok"
usbexport local.recorder.cmd.usbexport.<id>.start / .stop ignored "ok"

Idempotent. The reply means the command was accepted, not that the export finished - watch profile telemetry / config status. Errors: 404 unknown profile, 409 stop when not runnable.


Flower (flower)

measurement_event_publish

Subject local.flower.cmd.flow.main.measurement_event_publish
Request
{
  "event": {
    "ref": "<trigger component>",
    "measurementRef": null,
    "timestamp": "...",
    "start": "...",
    "deadline": "...",
    "triggerType": "start|stop|startExtend",
    "reason": "...",
    "dataTimestamp": "..."
  }
}

| Response | |

{
  "result": {
    "result": "event_ignored|new_measurement_triggered|deadline_updated",
    "measurementRef": "<component or null>"
  }
}

| CLI | algol trigger publish | | Also called by | the trigger module |


Board controller (bc)

Register facade

Subject local.bc.cmd.<connector>.<id>.<attr> matching configured register refs
Valid targets component id reg (legacy main is treated as reg)
Read empty body -> {"status":"ok","value": <uint16>}
Write ASCII decimal uint16 -> {"status":"ok"}

Register ref form: bc.<connectorName>.reg.<registerName>.

execute_cmd / execute_cmds

Single local.bc.cmd.main.execute.cmd
Batch local.bc.cmd.main.execute.cmds

A single command is a JSON object discriminated by "type": writeValue, readValue, readValues, directReadValue, directReadValues, directWriteValue, directWriteValues, directWriteBytes, with fields such as registerRef, unitId, registerId, value, options.timeout.

Batch request: {"timeout": 5.0, "continueOnError": false, "commands": [ ... ]}. Response: {"status":"ok","value":{"result":[[...]], "errors":[]}}.

Direct register

Subject local.bc.cmd.<connectorName>.direct.reg_<registerId>
Read / write empty body, or ASCII decimal uint16
Purpose Bypass register-ref indirection; the attribute must start with reg_

Audio (audio)

Subject local.audio.cmd.player.main.play
Request WAV filename only (basename, .wav, no directories), e.g. alarm.wav
Response "ok" after playback completes (blocking; default timeout 5 min)
Errors 404 missing file, 400 invalid name

Files come from the module's configured audio directory.


RETX (retx)

Subject local.retx.cmd.app.main.dump
Request ASCII integer: number of raw bytes to capture from the live stream
Response {"status":"ok\|ignored\|error","message":"..."}

"ok" returns immediately with an output file path; capture runs asynchronously. Ignored when dump is disabled, the body is invalid, the size exceeds the module cap, or a dump is already in progress.


NATS broker (nats)

Subject Request Response
local.nats.cmd.app.main.status empty JSON "OK" / "WAIT" / ...

This is a request-reply health probe, not a telemetry message. See Telemetry.

Auth verbs: ls / enrol / revoke

Served under --auth only (RFC-034 D6): the identity registry's read and write surface. Publishable by role keymaster (the boot's root key, or an enrolled keymaster); every request is signed by the caller's key, and the service acts on the key that signed, not on the connection that carried it.

Subject local.nats.cmd.auth.main.ls / .enrol / .revoke
Request
{
  "key": "<caller's public NKey, U...>",
  "body": {
    "verb": "enrol",
    "issued": "2026-08-30T18:00:00Z",
    "userKey": {"key": "UQRS...", "label": "lukas-laptop", "role": "superuser"}
  },
  "sig": "<base64url ed25519 signature over the body bytes>"
}

| Response | ls: {"modules": [{"name", "expected", "key", "bound", "boundBy", "revoked"}], "userKeys": [{"key", "label", "role", "enrolledBy", "enrolled"}]}; enrol / revoke: {"disconnected": n} | | CLI | algol nats ls / enrol / revoke (--nats-id for a nats app under another --id) |

body is verified as the bytes sent, so a client marshals it once and signs exactly that. enrol and revoke name either userKey (key; label and role on enrol) or module (name; key on enrol); ls takes no arguments. issued must lie within five minutes of the broker's clock and a signature is answered once. Errors: 400 malformed body or an argument the registry rejects, 401 unknown key / bad signature / replay, 403 role may not administer or grant, 404 no such entry, 500 registry write failed. Wire types: lib/flow/messaging/service/auth.


Modules with no request-reply surface

These publish and subscribe only (config, status, data, events). They call endpoints above but do not expose their own.

Module id Role
trigger Client: fires command / jsonCommand / patchConfig actions at any cmd subject; publishes measurement events to flower
gate Cloud connectivity
aggregator Per-batch data_status stats
algold Process supervisor
cli Client wrapping configurer / recorder / flower endpoints
secomea VPN; config / status
gps GNSS source; config / status / data
chrp LoRa (ChirpStack); config / status / events
chrony NTP; config / status

Trigger as a command client

A trigger command or jsonCommand action sends request-reply to any configured cmdRef (full attribute ref including the verb), for example sys.systemdmount.disk01.release or recorder.usbexport.stick01.start. Default timeout is 5 seconds. patchConfig goes to the configurer patch endpoint.