smon is a system monitor rewritten from a collection of bash scripts into a typed
TypeScript project. Its domain model — probes, verdicts, alert payloads, email
templates, and notification adapters — is declared as typed metadata using the
MetaObjects toolchain, and the runtime code
(schemas, types, generated glue) is generated from that model rather than hand-written.
Featured as a MetaObjects reference project — read the honest before/after case study at metaobjects.com/case-studies/smon.
Instead of scattering the shape of a "probe result" or "alert" across bash variables,
ad-hoc JSON, and string templates, smon declares each concept once as metadata under
metaobjects/. Code generation (meta gen) turns that declaration into typed TypeScript
— entities, validators, and supporting glue — so the domain model and the code that
implements it can never silently drift apart. meta verify checks that generated output
still matches the metadata, catching drift before it ships.
smon consumes the public verdict contract published in the
small-model-skills project:
Probes in smon emit verdicts that conform to this shared contract, so other tools that
speak the same contract can consume smon's output without bespoke glue.
A goal of this rewrite is to make smon a small showcase of adapter-style
extensibility: a probe emits a verdict; a notify adapter turns a verdict into an
outbound action (email, chat message, webhook, etc.). New probes and new notify
adapters should be addable independently, without touching each other, by adding new
metadata and regenerating.
Every notify adapter — email, a push service, a chat webhook, a heartbeat sink — starts
as a single adapter.notify node in metaobjects/meta.notify.json. For example, this
is the entire declaration behind the Matrix chat adapter:
{
"adapter.notify": {
"name": "Matrix",
"@kind": "chat",
"@payloadRef": "AlertPayload",
"@digestPayloadRef": "DigestPayload",
"@requiredConfig": ["SMON_MATRIX_HOMESERVER", "SMON_MATRIX_TOKEN", "SMON_MATRIX_ROOM"]
}
}Adding a new one is the same shape — declare the adapter's @kind, which payload(s) it
sends, and which env vars it needs, then run bun run gen:
- Validators check it.
meta verify/meta genenforce that@payloadRef(and@digestPayloadRef, for adapters that also send the daily digest) resolves to a realobject.value, and — for@kind: "email"— that@alertTemplateRef/@digestTemplateRefpoint at atemplate.output @kind=emailwhose mustache variables actually exist on the payload (ERR_SMON_TEMPLATE_PAYLOAD_MISMATCH/ERR_VAR_NOT_ON_PAYLOAD). A typo or a mismatched payload fails codegen, not production. - Codegen emits the typed registry.
src/generated/notify/registry.data.tsgains a newADAPTERS[name]entry (kind, payload/template refs, required/optional config keys) andmissingAdapterConfig()picks it up automatically — that's what powerssmon --list-adapters' STATUS column. - Codegen emits the docs.
docs/generated/monitoring.md's adapter table gets a new row, generated from the same metadata — the reference docs can't drift from the model.
The one hand-written step is the transport itself: add src/notify/impl/<name>.ts
satisfying NotifyAdapter<Payload[, Digest]> and wire it into src/notify/registry.ts.
The satisfies pin against the generated payload type means that if the payload's
shape ever changes, the new adapter's implementation fails to typecheck instead of
silently shipping a stale shape.
metaobjects/— the typed metadata that describessmon's domain (probes, verdicts, alert payloads, email templates, notify adapters). This is the durable source of truth.codegen/generators/— the codegen generators that turn metadata into TypeScript. Scaffolded bymeta initand owned by this repo (see the header comment in each generator file).src/— hand-written application code (probes, adapters, CLI entrypoint) plus generated output.metaobjects.config.ts— codegen configuration (output directory, generators, docs settings).
Install dependencies:
bun installGenerate code from the metadata model (regenerates src/generated/*, docs/generated/*,
and the model/API reference pages under docs/; documentation is a generator):
bun run genCheck that template bodies and prompts still resolve against their payloads, and that generated code has no drift or hand edits:
bun run verifyRun tests:
bun testBuild a standalone CLI bundle (dist/smon.js, targeting node runtime semantics):
bun run buildbun run typecheck (tsc --noEmit) also runs in CI, and bun run build runs it plus
verify before bundling. The four commands above are the sequence
CI runs on every push and pull request — see that
workflow's header comment for why gen (with a clean-tree assertion) and verify
are both required rather than redundant.
smon is invoked with at most one verb. With no arguments it runs a full sweep
(configured probes → policy decision → notify dispatch → state persistence →
heartbeat):
node dist/smon.js # run a sweep
node dist/smon.js --dry-run # sweep, but skip notify dispatch/state writes
node dist/smon.js --once <probe> # run a single probe by name, outside the roster
node dist/smon.js --test-alert # fire a synthetic alert through every configured adapter
node dist/smon.js --list-adapters # introspect the notify adapter registry
node dist/smon.js --list-probes # introspect the probe rosterConfiguration is entirely environment-driven (SMON_PROBES, SMON_NOTIFY, per-adapter
secrets, quiet hours, etc.) — see src/config.ts for the full, typed list of knobs and
their defaults; a missing/misspelled adapter or probe name in SMON_* fails config
loading immediately rather than silently no-opping.
--list-adapters and --list-probes are introspection surfaces generated straight from
the metadata model (no bash equivalent) — useful for checking what a given environment
actually has configured before it runs:
$ node dist/smon.js --list-adapters
ADAPTER KIND STATUS
email email missing: SMON_EMAIL_TO
ha-push push missing: SMON_HA_URL, SMON_HA_TOKEN, SMON_HA_DEVICE
kuma heartbeat missing: SMON_KUMA_PUSH_URL
matrix chat missing: SMON_MATRIX_HOMESERVER, SMON_MATRIX_TOKEN, SMON_MATRIX_ROOM
stdout stdout configured
$ node dist/smon.js --list-probes
PROBE TAGS
disk-report DISK_CRITICAL, DISK_HIGH, NOMINAL, PROBE_FAILED
docker-hygiene CLEAN, DAEMON_DOWN, NO_DOCKER, RECLAIMABLE
log-triage FAILED_SERVICES, LOG_ERRORS, NOMINAL
...
The same two tables, generated from the same metadata, are published as reference docs:
- Generated monitoring reference:
docs/generated/monitoring.md— emitted bybun run gen(smon's owncodegen/generators/monitor-docs.ts). - Generated model + API reference:
docs/README.mdanddocs/api/AGENT-API.md— emitted bybun run gen(docsFile()/apiDocsFile()), one page perobject.valueand per template, plus a call-it-exactly-as-written API page. Both directories are gated by CI'sbun run gen- no-diff step: a model change without a regen fails there.
The domain model (probes, verdicts, alert/digest payloads, email templates, notify
adapters), the parser/runner, the alert-policy state machine, notify adapter
implementations, LLM enrichment, config/state handling, and the CLI are implemented and
covered by the parity-oracle test suite (bash-behavior-equivalence cases plus regression
cases for bugs found along the way). Deployment tooling (installing dist/smon.js on a
host, per-host configuration, and retiring the legacy bash implementation it's ported
from) is out of scope for this repository and tracked separately.
MIT, see LICENSE.