MainSDK
Search⌘K
Interfaces

Reliability and operations

Signals

signals

Say what the app noticed as one typed, signed, short-lived signal that the app, its other instances and its playbooks hear, with dedupe, a ring of recent signals and no personal data.

Building for 0.4ships as a beta APIPhase 1 · Core, public betaSpec signalsFree in your app, Apache-2.0

Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.

Stability The promise an interface ships with.

  • Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
  • Beta Usable in production; may change before it becomes stable.
  • Experimental May change or go away; needs an explicit opt-in in main.yaml.
Stability levels
Updated 2026-10-28

signals is how the parts of your app tell each other what they noticed, as one small typed envelope that your handlers and playbooks hear. It is free and needs no service.

Each signal has a name, a severity, a subject, a short life and a signature when it crosses instances. Handlers hear it off the request path. A repeat of a live signal folds into the first (its count goes up) instead of making noise. Built-in names cover resources under pressure, a dependency down, an error budget burning, a deploy and an anomaly.

At a glance

signals:
  keep: 1000
  max-per-minute: 600
  key: secret:MAINSDK_SIGNAL_KEY

Calls

  • signals.emit(name, { severity, subject?, data?, ttl?, scope? })
  • signals.on(pattern, handler, { min?, from? })
  • signals.list({ name?, min?, live?, cursor?, limit? })
  • new Signals(opts)
  • parseSignals(section)
  • signalsIssues(section)
  • signalEnvelopeSchema(const: the JSON Schema of a signal)

Guarantees

Whatever the driver.

  • Signed, versioned envelopes with severity, TTL and dedupe
  • No personal data in a signal; data is redacted like a log record
  • Never on the request path

Drivers

1 drivers. Select one to see its line in main.yaml.

main.yamlBuilt in (diagnostics channels, events.broadcast and the host bus) (default)
signals:
  driver: builtin

Change this one line to swap providers; your calls stay the same.Built-in and local drivers integration

Codes

CodeWhere it appearsMeaning
MAIN-SIGNAL-0006Checkmainsdk check, before deployA signals value is invalid: keep outside 100 to 10,000, a TTL over ttl.max or over 24h, or key written as a literal instead of secret:NAME
MAIN-SIGNAL-0007Checkmainsdk check, before deployProduction uses a shared broadcast driver (redis) and app-scope signals, with no signals.key; another app on the same store could forge them
MAIN-SIGNAL-0001RuntimeError responses and logsSee the spec
MAIN-SIGNAL-0002RuntimeError responses and logsSee the spec
MAIN-SIGNAL-0003RuntimeError responses and logsSee the spec
MAIN-SIGNAL-0004RuntimeError responses and logsSee the spec
MAIN-SIGNAL-0005RuntimeError responses and logsSee the spec
MAIN-SIGNAL-0008RuntimeError responses and logsSee the spec

Features

Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.

FeaturePhaseMilestoneSpec ticket
The mainsdk-signal@1 envelope and the built-in cataloguePhase 10.4signals#envelope
emit, on and list with dedupe and TTLPhase 10.4signals#bus
App-scope signals across instancesPhase 10.4signals#app-scope
Built-in emitters from resources and healthPhase 10.4signals#emitters
Emitters for security eventsPhase 10.4signals#emitters-security
mainsdk signalsPhase 10.4signals#cli

MainSDK Cloud

Everything above is free in your app. Cloud adds these on top, by plan:

PlanWhat it adds
FleetSignals correlated across machines and one response across apps (spec fleet-defend)

Quick start

app/jobs/import.ts

import { signals } from "mainsdk";

// one signal per condition, however often it is emitted while live
await signals.emit("app.import_failed", { severity: "high", subject: "import:nightly", data: { rows: 3 } });

signals.on("pressure.*", (s) => console.log(s.name, s.severity, s.trust), { min: "medium" });

Configuration

main.yaml
signals:
  keep: 1000                      # recent signals held per instance, 100 to 10,000
  max-per-minute: 600             # per source; over it, emits are dropped and counted
  key: secret:MAINSDK_SIGNAL_KEY  # HMAC key for app-scope signals across instances
  ttl: { default: 5m, max: 24h }

Without key, app-scope signals go unsigned. With a shared broadcast driver (redis) in production, mainsdk check --env prod reports MAIN-SIGNAL-0007 until you set it, because another app on the same store could forge them.

Calls

CallWhat it does
signals.emit(name, { severity, subject?, data?, ttl?, scope? })Emit an app.* signal. scope is instance (default) or app (every instance of this app). Throws MAIN-SIGNAL-0001 on a bad name, severity or data over 4KB.
signals.on(pattern, handler, { min?, from? })Hear signals by name, a prefix.* glob or *. min filters by severity and from by trust (self, app, peer, host). Returns an unsubscribe.
signals.list({ name?, min?, live?, cursor?, limit? })The recent-signal ring, newest first, as { items, next }.

Only MainSDK emits the built-in names (pressure.high, dependency.degraded, slo.burning, budget.low, anomaly.detected, posture.changed and others). Your names start with app..

What you get

  • Signs with HMAC-SHA256 over canonical JSON (RFC 8785) when key is set, and drops an app-scope signal with no signature or a bad one (MAIN-SIGNAL-0004).
  • Redacts data like a log record: labelled personal fields, resolved secrets and key formats are removed, and the attribute is named in MAIN-SIGNAL-0002.
  • Folds a repeat of a live signal into the first, and delivers again only when the severity rises.
  • Runs handlers off the request path, one at a time per handler, at most 1s each (MAIN-SIGNAL-0005), with a queue of 1,000 (MAIN-SIGNAL-0008).
  • Costs under 5 microseconds at p99 per emit with no handler.
  • Writes each signal to the log, so mainsdk signals reads it from the local event store.

When the app has a security-event feed (securityEvents, the shape of security.on), detections at high or critical become attack.suspected (subject net:<trimmed network> or actor:<keyed hash>, never a raw id or address), secret.rotated becomes key.rotated (subject secret:<NAME>, never the value) and incident.control becomes incident.control (subject incident:<target>). With no feed these three stay unused.

A single serverless app gets instance-scope signals within one invocation only.

Command line

bash
npx mainsdk signals --min medium            # newest first, with count and age
npx mainsdk signals --name "pressure.*" --json
npx mainsdk signals --follow                # keep printing new ones

Helpers for wiring and tests

CallWhat it does
new Signals(opts)The signal bus of one app instance, for tests and custom wiring; apps use signals.emit.
parseSignals(section)Read the signals section of main.yaml into settings.
signalsIssues(section)List what is wrong in the signals section, without throwing.
signalEnvelopeSchema(const)The JSON Schema of a signal envelope.

Errors and checks

  • MAIN-SIGNAL-0001: emit refused (name, severity or data).
  • MAIN-SIGNAL-0002: a data attribute was dropped as personal or secret.
  • MAIN-SIGNAL-0003: a source passed max-per-minute; the rest were dropped.
  • MAIN-SIGNAL-0004: an envelope failed verification (unsigned, bad signature, unknown key, stale or future at).
  • MAIN-SIGNAL-0005: a handler threw or ran over 1s.
  • MAIN-SIGNAL-0006: a signals value is invalid (mainsdk check).
  • MAIN-SIGNAL-0007: production shares a broadcast with app-scope signals and has no signals.key (mainsdk check).
  • MAIN-SIGNAL-0008: a handler's queue was full; the oldest were dropped.

Each code's cause and fix is in check codes.

Swapping drivers

signals has no driver of its own. Instance-scope signals stay in the process. App-scope signals ride the app's events.broadcast driver, so changing that driver in main.yaml changes how instances hear each other. With a shared driver (redis) in production, set signals.key (MAIN-SIGNAL-0007). A broadcast outage leaves instance-scope signals working, and a dropped broadcast is not retried.

Testing

createTestApp() from the Testing kit runs signals offline with the fake clock and the local broadcast driver, so a test can emit, advance time past a ttl and read the ring with signals.list. Build a bus directly with new Signals(opts) when you need custom wiring.

Next

  • Playbooks act on signals.
  • Norms record what the app has learned from them.
  • Neighbours carry signals between apps on one machine.
  • Log holds each signal as a record.

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet