---
title: "Signals"
description: "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."
section: "interfaces"
status: "Building for 0.4 · ships as a beta API"
spec: "signals"
canonical: "https://mainsdk.dev/docs/interfaces/signals"
updated: "2026-10-28"
---

# 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.

Status: Building for 0.4 · ships as a beta API · Canonical: https://mainsdk.dev/docs/interfaces/signals · 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](https://mainsdk.dev/docs/interfaces/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

- Status: Building for 0.4 · ships as a beta API
- Phase: 1 (Core, public beta)
- Milestone: 0.4 Hardening
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/signals.md
- Category: Reliability and operations

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)`

In main.yaml:

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

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

Pick one in main.yaml with `signals.driver`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| Built in (diagnostics channels, events.broadcast and the host bus) | `builtin` | yes |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-SIGNAL-0006` | check | A `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-0007` | check | Production 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-0001` | runtime |  |
| `MAIN-SIGNAL-0002` | runtime |  |
| `MAIN-SIGNAL-0003` | runtime |  |
| `MAIN-SIGNAL-0004` | runtime |  |
| `MAIN-SIGNAL-0005` | runtime |  |
| `MAIN-SIGNAL-0008` | runtime |  |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| The mainsdk-signal@1 envelope and the built-in catalogue | 1 | 0.4 | `signals#envelope` |
| emit, on and list with dedupe and TTL | 1 | 0.4 | `signals#bus` |
| App-scope signals across instances | 1 | 0.4 | `signals#app-scope` |
| Built-in emitters from resources and health | 1 | 0.4 | `signals#emitters` |
| Emitters for security events | 1 | 0.4 | `signals#emitters-security` |
| mainsdk signals | 1 | 0.4 | `signals#cli` |

## MainSDK Cloud

Everything on this page is free in your app.
Cloud adds:

| Plan | What it adds |
| --- | --- |
| fleet | Signals correlated across machines and one response across apps (spec fleet-defend) |


## Quick start

TypeScript:

```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" });
```

Rust:

```rust
use mainsdk::prelude::*;
let app = App::test();
Context::new().tenant("tnt_a").user("u1").scope(async {
app.signals().emit("app.order.paid", "info", serde_json::json!({ "n": 1 })).await?;
assert_eq!(app.signals().list().await?.len(), 1);
Ok::<_, MainError>(())
}).await?;
```


## Configuration

```yaml title="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

| Call | What 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

| Call | What 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](https://mainsdk.dev/docs/reference/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](https://mainsdk.dev/docs/interfaces/testing) 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](https://mainsdk.dev/docs/interfaces/playbooks) act on signals.
- [Norms](https://mainsdk.dev/docs/interfaces/norms) record what the app has learned from them.
- [Neighbours](https://mainsdk.dev/docs/interfaces/neighbours) carry signals between apps on one machine.
- [Log](https://mainsdk.dev/docs/interfaces/log) holds each signal as a record.
