---
title: "Neighbour apps"
description: "Let MainSDK apps on one machine hear each other's host-scope signals over user-only sockets, signed with a per-host key, so one app's attack warning raises the others' defences."
section: "interfaces"
status: "Building for 0.4 · ships as a beta API"
spec: "neighbours"
canonical: "https://mainsdk.dev/docs/interfaces/neighbours"
updated: "2026-10-28"
---

# Neighbour apps

> Let MainSDK apps on one machine hear each other's host-scope signals over user-only sockets, signed with a per-host key, so one app's attack warning raises the others' defences.

Status: Building for 0.4 · ships as a beta API · Canonical: https://mainsdk.dev/docs/interfaces/neighbours · Updated: 2026-10-28

`neighbours` lets MainSDK apps on one machine, run as the same OS user, share host signals so one app's attack warning can raise the others' defences.

## 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/neighbours.md
- Category: Platform, OS and network

Calls:

- `neighbours.ask(peer, verb, { to?, for?, reason? })`
- `new Neighbours(opts)`
- `parseNeighbours(section)`
- `neighboursIssues(section, ctx)`
- `rotateHostKey(appsDir)`

In main.yaml:

```yaml
neighbours:
  host: on
  listen: [attack.*, pressure.*]
  share: [attack.suspected, pressure.high]
  asks: { accept: [status, posture.raise, load.shed] }
  priority: 5                          # 1 to 9
  ports: 47400-47499                   # loopback range for the host bus
```

Guarantees, whatever the driver:

- Nothing leaves the machine
- Only three asks between declared peers; never code, data or a lowered guard
- Apps never list the machine's apps


## Drivers

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

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| Built in (user-only sockets and the app link) | `builtin` | yes |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-NEIGHBOUR-0001` | check | `neighbours` is invalid: `host: on` with `apps.registry: off`, a `share` or `listen` name that isn't a signal name or glob, `priority` outside 1 to 9, `max-posture: lockdown`, `max-for` over 1h, or `asks.accept` with a verb outside the three |
| `MAIN-NEIGHBOUR-0005` | check | `asks.accept` lists `posture.raise` or `load.shed` but `apps.link` is off or `apps.peers` is empty |
| `MAIN-NEIGHBOUR-0002` | runtime |  |
| `MAIN-NEIGHBOUR-0003` | runtime |  |
| `MAIN-NEIGHBOUR-0004` | runtime |  |
| `MAIN-NEIGHBOUR-0006` | runtime |  |
| `MAIN-NEIGHBOUR-0007` | runtime |  |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| Per-host key | 1 | 0.4 | `neighbours#host-key` |
| Host signal bus | 1 | 0.4 | `neighbours#host-bus` |
| status, posture.raise and load.shed asks | 1 | 0.4 | `neighbours#asks` |
| Priority between neighbours | 1 | 0.4 | `neighbours#priority` |

## MainSDK Cloud

Everything on this page is free in your app.


## Quick start

```yaml title="main.yaml"
apps:
  registry: on
neighbours:
  host: on
  listen: [attack.*, pressure.*]            # host signals this app accepts
  share: [attack.suspected, pressure.high]  # what this app may send
  priority: 5                               # 1 yields first, 9 yields last
```

```ts title="app/signals.ts"
import { signals } from "mainsdk";

// Hear what the neighbours share. Host signals reach handlers only when you ask for them with `from`.
signals.on("attack.*", (s) => console.log(s.name, s.source.project, s.trust), { from: ["host"] });
```

## How it works

Each app with `host: on` reads or creates a 32-byte key at `<apps dir>/../host.key` (mode 0600, created once with `O_EXCL`). It listens on a Unix socket at `<apps dir>/../run/signals/<h>.sock` (mode 0600 in a 0700 folder) and writes a `<h>.peer` marker beside it, where `h` is the first 16 hex digits of the HMAC of the project id under the key, so the file names do not reveal project ids. A host-scope signal in `share` goes to every other peer marker in that folder (at most 64, 250 ms each, a dead one skipped for 5 minutes after three failures), signed with HMAC-SHA256 under the host key. The receiver drops anything whose signature does not verify, whose name is not in `listen`, or that comes faster than `max-per-minute` from one neighbour.

The listener and the sends go through the network chokepoint: the app declares its own socket and the run folder under `network.listen` and `network.allow` while `host: on`. Where the runtime refuses a Unix socket (Windows, serverless), the bus falls back to loopback TCP inside `neighbours.ports` (default `47400-47499`), with the marker naming the port; `mainsdk neighbours` shows which transport is in use.

> **Warning:** The key proves only that the sender can read this OS user's files on this machine. It does not defend against malware running as the same user. On Windows the bus stays off with `MAIN-NEIGHBOUR-0002`.

## Configuration

| Key | What it does |
| --- | --- |
| `host` | `on` joins the host bus. Off by default. |
| `listen` | Host signal names this app accepts, such as `attack.*`. Default none. |
| `share` | Signal names this app may send to the host. Default none; anything else stays in the app (`MAIN-NEIGHBOUR-0007`). |
| `priority` | 1 to 9, sent with each signal so a rule can compare itself with the sender. Default 5. |
| `max-per-minute` | Host signals accepted per neighbour. Default 60. |
| `ports` | The loopback range used only as the fallback, 1024 to 65535. Default `47400-47499`. |
| `asks.accept` | What declared peers may ask: `status`, `posture.raise`, `load.shed`. Default `status`. |
| `asks.max-posture`, `asks.max-for`, `asks.max-per-hour` | Ceilings for asks: `high` at most, 1h at most, 6 an hour. |

`mainsdk check` reports `MAIN-NEIGHBOUR-0001` for a bad value (including `host: on` with `apps.registry: off`) and `MAIN-NEIGHBOUR-0005` when `asks.accept` lists `posture.raise` or `load.shed` but `apps.link` is off or `apps.peers` is empty.

## Command line

```bash
npx mainsdk neighbours            # host bus settings, this app's socket name, the last host signals
npx mainsdk neighbours rotate-key # a new host key; running apps re-read it within 60 s and accept the old one for 120 s more
```

`mainsdk info` shows the same settings under `neighbours`.

## Asks

Between apps that list each other under `apps.peers` (with `apps.link: on`), `neighbours.ask(peer, verb, options)` sends one of three asks over the app link: `status` returns the peer's state, resources, posture and priority; `posture.raise { to, for, reason }` raises its posture; `load.shed { for, reason }` runs its `pressure.slim` steps. The peer must accept the verb under `asks.accept`. A `for` longer than `asks.max-for` is shortened to it, `to: lockdown` is refused, a peer with a lower priority cannot ask a higher one to shed, and a seventh ask in an hour is refused. Each accepted ask is a playbook run with source `neighbour` and an audit entry naming both apps, the verb and the run. An app that reacted to a peer cannot ask it back for 10 minutes (`MAIN-GUARD-0004`).

```ts
import { neighbours } from "mainsdk";

const status = await neighbours.ask("prj_01JD8X", "status");
const r = await neighbours.ask("prj_01JD8X", "posture.raise", { to: "high", for: "30m", reason: "attack on us" });
console.log(r.accepted, r.run);
```

Priority matching in rules and Windows named pipes come after go-live.

## Helpers for wiring and tests

| Call | What it does |
| --- | --- |
| `new Neighbours(opts)` | The host bus of one app instance, for tests and custom wiring. |
| `parseNeighbours(section)` | Read the `neighbours` section of `main.yaml` into settings. |
| `neighboursIssues(section, ctx)` | List what is wrong in the `neighbours` section, without throwing. |
| `rotateHostKey(appsDir)` | Replace the host bus key in the apps folder; peers re-read it on their next call. |

## What you get

- Apps that run as the same OS user look out for each other: when one is attacked or the machine is short of CPU, it shares a signal and the others can react through their [playbooks](https://mainsdk.dev/docs/interfaces/playbooks).
- It is opt in, uses no daemon and sends nothing off the machine.
- A single serverless app has nothing to talk to, so it gets nothing from this until a second app runs beside it.

## Errors and checks

- `MAIN-NEIGHBOUR-0001`, `MAIN-NEIGHBOUR-0005`: the checks above.
- `MAIN-NEIGHBOUR-0002`: the host key or folder is unsafe or unreadable; the bus stays off and the app keeps serving.
- `MAIN-NEIGHBOUR-0003`: a host envelope failed the key check; dropped and counted.
- `MAIN-NEIGHBOUR-0004`: an ask was refused.
- `MAIN-NEIGHBOUR-0006`: a neighbour passed `max-per-minute`.
- `MAIN-NEIGHBOUR-0007`: a signal outside `share` was asked to go to the host.

Related: [signals](https://mainsdk.dev/docs/interfaces/signals), [playbooks](https://mainsdk.dev/docs/interfaces/playbooks), [network](https://mainsdk.dev/docs/interfaces/network).

## Swapping drivers

`neighbours` has no drivers: the host bus is built in, over a Unix socket or the loopback fallback. `mainsdk neighbours` shows which transport is in use.

## Testing

`createSim` in `@mainsdk/testing` can run the real host bus over fake unix sockets with `host: "sockets"`, and `link: true` lets `neighbours.ask` work between apps. See the [Testing kit](https://mainsdk.dev/docs/interfaces/testing). For custom wiring, `new Neighbours(opts)` builds the bus of one app instance.

## Next

- [Signals](https://mainsdk.dev/docs/interfaces/signals)
- [Playbooks](https://mainsdk.dev/docs/interfaces/playbooks)
- [Network permissions](https://mainsdk.dev/docs/interfaces/network)
- [Check codes](https://mainsdk.dev/docs/reference/check-codes): each code's cause and fix.
