---
title: "App registry and link"
description: "See every MainSDK app on a machine from one local folder, and let two apps that declare each other prove who they are and call each other's exposed actions."
section: "interfaces"
status: "Building for 0.4 · ships as a beta API"
spec: "app-registry"
canonical: "https://mainsdk.dev/docs/interfaces/apps"
updated: "2026-10-28"
---

# App registry and link

> See every MainSDK app on a machine from one local folder, and let two apps that declare each other prove who they are and call each other's exposed actions.

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

Each MainSDK app on a machine writes one record to a private folder, and `mainsdk apps` lists them. Two apps that list each other can prove who they are and call exposed actions.

The record is readable only by its user. `mainsdk apps` shows who publishes each app, which version, whether its seal holds and where it sends data. Two apps that list each other can also find each other and call the actions they expose, after proving who they are. It is a local file and a local socket: nothing leaves the machine, it needs no account, and an app never sees the machine's other apps.

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

Calls:

- `apps.peer(id)`
- `apps.call(id, action, { input?, signal? })`
- `apps.provide(actions)`
- `new AppRegistry(opts)`
- `parseApps(section)`
- `readRegistry(dir)`
- `readSeal(doc)`

In main.yaml:

```yaml
apps:
  link: on
  identity: "ed25519:MCowBQYDK2VwAyEA"
  peers: [prj_01JD8X]
  expose: [orders.status]
```

Guarantees, whatever the driver:

- Nothing leaves the machine
- A record holds apps and their seals, never a person
- Apps see only the peers they declare, and a call needs both sides to agree


## Drivers

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

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| Built in (a local folder and a user-only socket) | `builtin` | yes |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-APPS-0004` | check | Code reads the registry folder directly, or `permissions.disk` grants it, instead of `apps.peer()` |
| `MAIN-APPS-0005` | check | A file that reads `apps.peer()` or `apps.call()` results also makes an outbound call; registry data may leave the machine |
| `MAIN-APPS-0001` | runtime | The registry folder is unsafe (world-writable, or owned by another user and not root) or not writable; nothing is written and the app keeps serving |
| `MAIN-APPS-0002` | runtime | Two records claim one project id from different repositories |
| `MAIN-APPS-0003` | runtime | A record says running but was last seen more than 5 minutes ago |
| `MAIN-APPS-0006` | runtime | A peer call between apps that don't both list each other under `apps.peers` |
| `MAIN-APPS-0007` | runtime | A peer call to an action the callee doesn't list under `apps.expose` |
| `MAIN-APPS-0008` | runtime | The link handshake failed: the peer's proof doesn't verify with the identity key in its seal, or its seal is invalid |
| `MAIN-APPS-0009` | runtime | `apps.link: on` without `apps.identity` and `MAINSDK_APP_KEY`; the link stays off |
| `MAIN-APPS-0010` | runtime |  |
| `MAIN-APPS-0011` | runtime |  |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| Per-machine registry | 1 | 0.4 | `app-registry#write` |
| mainsdk apps | 1 | 0.4 | `app-registry#apps-cmd` |
| Peer calls on a mutual challenge | 1 | 0.4 | `app-registry#link` |

## MainSDK Cloud

Everything on this page is free in your app.


## Quick start

```yaml title="main.yaml (app A)"
apps:
  link: on
  identity: "ed25519:MCowBQYDK2VwAyEA…"   # public half of the app identity key
  peers: [prj_01JD8X]                      # B must list A back
```

```yaml title="main.yaml (app B)"
apps:
  link: on
  identity: "ed25519:MCowBQYDK2VwAyEA…"
  peers: [prj_01JC8Q]
  expose: [orders.status]
access:
  roles:
    app: [orders.status]                   # a peer runs as the role app
```

```ts title="app A"
import { apps } from "mainsdk";

const peer = await apps.peer("prj_01JD8X");            // null unless it is a declared peer
const order = await apps.call("prj_01JD8X", "orders.status", { input: { id: 1 } });
```

```ts title="app B"
import { action, apps } from "mainsdk";
import { s } from "mainsdk/schema";

const ordersStatus = action({
  name: "orders.status",
  intent: "Read an order's status",
  input: s.object({ id: s.number() }),
  risk: "read",
  run: async ({ input }) => ({ id: input.id, status: "shipped" }),
});

apps.provide([ordersStatus]);   // only names under apps.expose are reachable
```

The private half of the identity key is the secret `MAINSDK_APP_KEY`. It is never in `main.yaml` or in the record.

## Configuration

| Key | What it does |
| --- | --- |
| `registry` | `on` (default) or `off`. |
| `dir` | The registry folder; or the `MAINSDK_APPS_DIR` variable. |
| `link` | `on` opens the link. Off by default; without `identity` and `MAINSDK_APP_KEY` it stays off (`MAIN-APPS-0009`). |
| `identity` | The `ed25519:` public key of this app. |
| `peers` | Project ids of the apps this app may call or be called by. |
| `expose` | Action names a declared peer may call. |
| `ports` | The loopback range used only as the fallback. |

## What you get

The record `<dir>/<project-id>.json` (default `~/.mainsdk/apps`, mode 0600 in a 0700 folder) holds the served seal, the boot check state, status, environment, start time and a `lastSeen` rewritten every minute. It never holds a user, tenant, session or device id; a record with any other field is refused before it is written (`MAIN-APPS-0010`). The registry starts for an app whose `main.yaml` has an `apps`, `ops` or `neighbours` section, and stays off on serverless runtimes or with `apps.registry: off`. An unsafe folder (world-writable or owned by another user) logs `MAIN-APPS-0001`, nothing is written and the app keeps serving.

The link listens on a Unix socket at `<dir>/../run/<project-id>.sock` (mode 0600). Where the runtime refuses a Unix socket it falls back to loopback TCP inside `apps.ports` (default `47500-47599`) with a user-only port file at the same path. Before any request, both sides sign a fresh challenge with their identity keys and check the other's signature against the key in the other's seal; a proof that does not verify fails with `MAIN-APPS-0008`. A call then needs both apps to list each other (`MAIN-APPS-0006`) and the action under the callee's `apps.expose` (`MAIN-APPS-0007`). The callee runs it as subject `app:<caller id>` with roles `app` and `app:<caller id>`, no user and no tenant, and audits both project ids. The caller's network decision log shows the call as `app:<callee id>`.

> **Warning:** The link proves which sealed app is speaking on this machine, not more: malware running as the same user can read the files and secrets of every app that user runs.

### Command line

```bash
npx mainsdk apps                         # every app on the machine, flagged
npx mainsdk apps --strict                # exit 1 when anything is flagged
npx mainsdk apps --actions --risk destructive --json
npx mainsdk apps --prune                 # delete records stopped or stale for 30 days
```

`mainsdk check` reports `MAIN-APPS-0004` for code that reads the registry folder by path or a `permissions.disk` entry that covers it, and `MAIN-APPS-0005` for a file that uses `apps.peer()` or `apps.call()` results and also calls `http` or `share`.

## Errors and checks

- `MAIN-APPS-0001`: the registry folder is unsafe or not writable.
- `MAIN-APPS-0002`: two records claim one project id.
- `MAIN-APPS-0003`: a running record was last seen over 5 minutes ago.
- `MAIN-APPS-0004`, `MAIN-APPS-0005`: the checks above.
- `MAIN-APPS-0006`: the apps do not list each other.
- `MAIN-APPS-0007`: the action is not exposed.
- `MAIN-APPS-0008`: the handshake failed.
- `MAIN-APPS-0009`: the link stays off.
- `MAIN-APPS-0010`: a record would hold a field outside the schema.
- `MAIN-APPS-0011`: a declared peer is not reachable.

## Swapping drivers

`apps` has no driver to swap: the registry is a local folder and the link is a local socket. `apps.registry: off` stops the record and `apps.link` stays off unless you turn it on.

## Testing

`createSim` from `@mainsdk/testing` runs several apps on one fake clock. Pass `host: "sockets"` to it and `link` on an app to seal that app and open its link to the other linked apps, in a temporary apps folder that `stop()` removes. For one app, `new AppRegistry(opts)` and `readRegistry(dir)` cover the registry on its own:

| Call | What it does |
| --- | --- |
| `new AppRegistry(opts)` | The registry of one app instance, for tests and custom wiring. |
| `parseApps(section)` | Read the `apps` section of `main.yaml` into settings. |
| `readRegistry(dir)` | Read every app record in an apps folder. |
| `readSeal(doc)` | Read a seal document and say what it proves. |

## Next

- [Link two apps](https://mainsdk.dev/docs/guides/link-two-apps)
- [Neighbours](https://mainsdk.dev/docs/interfaces/neighbours)
- [Ops](https://mainsdk.dev/docs/interfaces/ops)
- [Network](https://mainsdk.dev/docs/interfaces/network)
