[← All NAPs](https://napplet.run/protocol/)

NAP-INC / Merged specification

# Inter-Napplet Communication

Merged

**Shipped implementation: supported**

Topic and channel bindings have compatibility tests and authenticated-endpoint reference-runtime coverage.

Implementation proposal: [merged ↗](https://github.com/napplet/naps/pull/104), checked 2026-10-31. This assessment does not imply that every amendment shown here is implemented. All capabilities remain subject to change.

[Maturity and opt-in policy →](https://napplet.run/docs/naps/maturity.html)

Full specification from [the fetched upstream revision ↗](https://github.com/napplet/naps/blob/43cb3a4e855570ec4b90b9cc05f0c721e5ad0aab/naps/NAP-INC.md). The document below is rendered from its original Markdown. [Follow the latest source on GitHub ↗](https://github.com/napplet/naps/blob/master/naps/NAP-INC.md).

## NAP-INC

### Inter-Napplet Communication

`draft`

**NAP ID:** NAP-INC **Domain:** `inc` **Depends:** none. **Web binding ([NIP-5D](https://github.com/nostr-protocol/nips/pull/2303)):** `window.napplet.inc`; domain presence signals availability.

### Description

NAP-INC provides topic-based publish/subscribe and point-to-point channels for communication between napplets. The contract is inter-napplet rather than inter-frame: [NIP-5D](https://github.com/nostr-protocol/nips/pull/2303) uses sandboxed iframes and `postMessage` today, but the INC API describes shell-mediated communication between napplet endpoints and does not require every implementation target to be modeled as a browser frame.

Under the [NIP-5D](https://github.com/nostr-protocol/nips/pull/2303) iframe transport, sandboxed napplets cannot communicate directly because the `allow-same-origin` sandbox token is absent -- they have opaque origins with no shared context. The shell routes messages between napplets using typed `inc.*` messages over the [NIP-5D](https://github.com/nostr-protocol/nips/pull/2303) wire format. Topic-based INC is loose coupling: the sender does not know who (if anyone) receives the message. Channel-based INC is tight coupling: a napplet opens a named connection to a specific peer and the shell validates the target once on open. The shell MAY also use INC topics for internal coordination (state operations, service commands, configuration).

### API Surface

| Operation | Parameters | Result | Wire |
| --- | --- | --- | --- |
| `emit` | `topic` or convention URI (`tstr`), optional `payload` (`any`) | none | `inc.emit` |
| `on` | `topic` (`tstr`), event handler for `IncEvent` | `Subscription` handle | `inc.subscribe` / `inc.subscribe.result` |
| `channel.open` | `target` (`tstr`, peer endpoint identifier) | `ChannelHandle` | `inc.channel.open` / `inc.channel.open.result` |
| `channel.onOpened` | handler for inbound `ChannelHandle` | `Subscription` handle | `inc.channel.opened` |
| `channel.list` | none | list of `ChannelInfo` | `inc.channel.list` / `inc.channel.list.result` |
| `channel.broadcast` | `payload` (`any`) | none | `inc.channel.broadcast` |
| `ChannelHandle.emit` | `payload` (`any`) | none | `inc.channel.emit` |
| `ChannelHandle.on` | event handler for `ChannelEvent` | `Subscription` handle | `inc.channel.event` |
| `ChannelHandle.onClosed` | handler for `ChannelClosed` | `Subscription` handle | `inc.channel.closed` |
| `ChannelHandle.close` | none | none | `inc.channel.close` |

#### Schemas

`IncEvent` fields:

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `topic` | yes | text | Opaque topic name. Matched by exact string equality. |
| `sender` | yes | text | Runtime-attested emitting napplet endpoint identifier. |
| `payload` | no | any | Per-message data. |

`ChannelHandle` fields:

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `id` | yes | text | Shell-assigned channel id. |
| `peer` | yes | text | Peer endpoint identifier. |

`ChannelEvent` fields:

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `channelId` | yes | text | Shell-assigned channel id. |
| `sender` | yes | text | Sender endpoint identifier. |
| `payload` | no | any | Channel payload. |

`ChannelClosed` fields:

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `channelId` | yes | text | Shell-assigned channel id. |
| `reason` | no | text | Runtime-supplied terminal reason. |

`ChannelInfo` fields:

| Field | Required | Type | Notes |
| --- | --- | --- | --- |
| `id` | yes | text | Shell-assigned channel id. |
| `peer` | yes | text | Peer endpoint identifier. |

`target`, `sender`, and `peer` are runtime-assigned endpoint identifiers. They are opaque text, unique to a live endpoint within the runtime, and MUST NOT be interpreted as a manifest `d` tag. The runtime MUST distinguish different publishers, manifests, and simultaneous instances. It MUST bind each identifier to the authenticated endpoint and its verified manifest and artifact, including root and snapshot manifests without a `d` tag. An identifier MUST NOT be reused for another endpoint during the runtime session. A channel remains bound to the endpoint authorized at open; a restart or replacement requires a new channel.

Callers obtain peer identifiers from runtime-attested INC events or an explicit runtime-authorized peer selection. These identifiers do not imply catalog membership or installation permission, and are not interchangeable with catalog identifiers exposed by other domains. Identifier encoding is runtime policy. `id` and `channelId` are separate shell-assigned opaque channel identifiers.

**`emit(topic, payload?)`** — Broadcasts a message to all napplets subscribed to the given topic. A napplet MAY supply a convention URI as `topic`; the runtime transposes its query parameters into `payload` before routing. Fire-and-forget — there is no delivery confirmation. The runtime derives `sender` from the authenticated emitting endpoint and includes its endpoint identifier in delivered events. The emitter cannot set or override `sender`.

**`on(topic, callback)`** — Subscribes to messages on a topic. The callback receives an `IncEvent` with the topic, sender endpoint identifier, and payload. Returns a `Subscription` handle with a `close()` method to unsubscribe. Multiple subscriptions to the same topic are independent.

#### Convention URI transposition

The developer-facing convention URI is `napplet:<archetype>/<intent>[...?params]`. Its path is the stable topic. Its query is shallow payload sugar.

When `emit` receives a convention URI with a query, the runtime MUST:

1.  Remove the query from the routed topic.
2.  Percent-decode each unique `name=value` pair as text.
3.  Place those pairs in a map of text to text and use that map as `payload`.

The runtime MUST NOT coerce query values to boolean, number, or null. A `+` is a literal plus sign, not a space. A convention URI with a fragment, malformed percent-encoding, a repeated name, or an explicit `payload` argument alongside query parameters MUST be rejected before emission. Structured or non-text data MUST use the explicit `payload` argument with a queryless topic.

This transposition is part of the `emit` operation. Topic routing still uses exact equality over the resulting stable topic.

**`channel.open(target)`** — Opens a point-to-point channel to a napplet identified by its endpoint identifier. The shell validates the target and checks ACL on open. The opener receives a `ChannelHandle`. Before reporting success, the runtime MUST enqueue `inc.channel.opened` for the target. If the target is not found, has no live endpoint, cannot receive the notification, or is ACL-denied, the request fails. The shell validates once on open. Subsequent messages flow without per-message authorization.

**`channel.onOpened(callback)`** — Receives a `ChannelHandle` when a peer opens a channel to this napplet. The handle identifies the same channel as the opener's handle and supports the same `emit`, `on`, `onClosed`, and `close` operations. Its `peer` is the runtime-attested opener endpoint identifier. This notification is not an authorization prompt. The runtime has already applied channel ACL.

**`channel.list()`** — Returns informational snapshots of this napplet's active inbound and outbound channels. `ChannelInfo` is not a handle and does not attach the caller to a channel.

**`channel.broadcast(payload)`** — Sends a message to all open channel peers at once. Fire-and-forget.

**`ChannelHandle.emit(payload)`** — Sends a message to the channel peer. Fire-and-forget.

**`ChannelHandle.on(callback)`** — Receives messages from the channel peer. The callback receives a `ChannelEvent` with `channelId`, sender endpoint identifier, and `payload`.

**`ChannelHandle.onClosed(callback)`** — Receives the terminal `ChannelClosed` record when either endpoint closes the channel or the runtime tears it down. A handler registered after closure MUST be invoked with the retained terminal record.

**`ChannelHandle.close()`** — Tears down the channel. Either endpoint may close. Both handles become terminal and their `onClosed` handlers are notified via `inc.channel.closed`.

### Wire Protocol

INC operations use the [NIP-5D](https://github.com/nostr-protocol/nips/pull/2303) wire format (`{ "type": "domain.action", ...payload }`).

#### Topics

| Type | Direction | Payload fields |
| --- | --- | --- |
| `inc.emit` | napplet -> shell | `topic`, `payload`? |
| `inc.subscribe` | napplet -> shell | `id`, `topic` |
| `inc.subscribe.result` | shell -> napplet | `id` |
| `inc.unsubscribe` | napplet -> shell | `topic` |
| `inc.event` | shell -> napplet | `topic`, `sender` (endpoint identifier), `payload`? |

Key design notes:

-   `inc.emit` has no `id` field — it is fire-and-forget with no acknowledgment.
-   `inc.emit` carries the stable topic and transposed payload, not the developer-facing convention URI.
-   `inc.subscribe` uses `id` for correlation so the shim can confirm the subscription was registered.
-   `inc.subscribe.result` confirms registration by echoing the `id`.
-   `inc.unsubscribe` is fire-and-forget (no `id`, no result message).
-   `inc.event` has no `id` field — it is a shell-initiated delivery identified by `topic` and `sender` (the emitting napplet's endpoint identifier).

#### Channels

| Type | Direction | Payload fields |
| --- | --- | --- |
| `inc.channel.open` | napplet -> shell | `id`, `target` (endpoint identifier string) |
| `inc.channel.open.result` | shell -> napplet | `id`, `channelId`?, `peer`? (endpoint identifier), `error`? |
| `inc.channel.opened` | shell -> target napplet | `channelId`, `peer` (endpoint identifier) |
| `inc.channel.emit` | napplet -> shell | `channelId`, `payload`? |
| `inc.channel.event` | shell -> napplet | `channelId`, `sender` (endpoint identifier), `payload`? |
| `inc.channel.broadcast` | napplet -> shell | `payload`? |
| `inc.channel.list` | napplet -> shell | `id` |
| `inc.channel.list.result` | shell -> napplet | `id`, `channels` (ChannelInfo array) |
| `inc.channel.close` | napplet -> shell | `channelId` |
| `inc.channel.closed` | shell -> napplet | `channelId`, `reason`? (string) |

Key design notes:

-   `inc.channel.open` uses `id` for correlation; the result returns a shell-assigned `channelId`.
-   `inc.channel.opened` is a target-side push with no request `id`. It carries the same `channelId`; `peer` is the runtime-attested opener endpoint identifier.
-   The runtime MUST enqueue `inc.channel.opened` for the target before sending a successful `inc.channel.open.result` to the opener.
-   For each endpoint, handle creation MUST precede any `inc.channel.event` or `inc.channel.closed` for that channel.
-   `inc.channel.emit` has no `id` field — fire-and-forget like `inc.emit`.
-   `inc.channel.event` has no `id` field — shell-initiated delivery, carries `channelId` + `sender`.
-   `inc.channel.broadcast` has no `id` field — fire-and-forget to all open channel peers.
-   `inc.channel.close` has no `id` — the `channelId` identifies the target channel.
-   `inc.channel.closed` is a shell-initiated notification sent on graceful close, peer destruction, or ACL revocation.

#### Examples

**Emit a convention URI:**

```
emit("napplet:profile/open?pubkey=abc123...")
```

The runtime transposes the call before sending the wire message:

```
-> { "type": "inc.emit", "topic": "napplet:profile/open", "payload": { "pubkey": "abc123..." } }
```

No response — fire-and-forget.

**Subscribe:**

```
-> { "type": "inc.subscribe", "id": "a1", "topic": "napplet:profile/open" }
<- { "type": "inc.subscribe.result", "id": "a1" }
```

**Event delivery:**

```
<- { "type": "inc.event", "topic": "napplet:profile/open", "sender": "endpoint-social-feed", "payload": { "pubkey": "abc123..." } }
```

**Unsubscribe:**

```
-> { "type": "inc.unsubscribe", "topic": "napplet:profile/open" }
```

**Open channel:**

Assume the opener endpoint identifier is `endpoint-music-controller` and the target endpoint identifier is `endpoint-media-player`:

```
opener -> { "type": "inc.channel.open", "id": "ch1", "target": "endpoint-media-player" }
target <- { "type": "inc.channel.opened", "channelId": "c-abc", "peer": "endpoint-music-controller" }
opener <- { "type": "inc.channel.open.result", "id": "ch1", "channelId": "c-abc", "peer": "endpoint-media-player" }
```

The opener and target bindings each materialize a `ChannelHandle` for `c-abc`.

**Channel emit:**

```
opener -> { "type": "inc.channel.emit", "channelId": "c-abc", "payload": { "command": "play", "track": 3 } }
target <- { "type": "inc.channel.event", "channelId": "c-abc", "sender": "endpoint-music-controller", "payload": { "command": "play", "track": 3 } }
```

No response — fire-and-forget.

**Target reply:**

```
target -> { "type": "inc.channel.emit", "channelId": "c-abc", "payload": { "status": "playing", "track": 3 } }
opener <- { "type": "inc.channel.event", "channelId": "c-abc", "sender": "endpoint-media-player", "payload": { "status": "playing", "track": 3 } }
```

**Channel broadcast:**

```
-> { "type": "inc.channel.broadcast", "payload": { "announcement": "shutting down" } }
```

**List channels:**

```
-> { "type": "inc.channel.list", "id": "l1" }
<- { "type": "inc.channel.list.result", "id": "l1", "channels": [{ "id": "c-abc", "peer": "endpoint-media-player" }, { "id": "c-def", "peer": "endpoint-chat-widget" }] }
```

**Close channel:**

```
-> { "type": "inc.channel.close", "channelId": "c-abc" }
<- { "type": "inc.channel.closed", "channelId": "c-abc" }
```

**Channel closed by peer or shell:**

```
<- { "type": "inc.channel.closed", "channelId": "c-abc", "reason": "peer destroyed" }
```

**Open error:**

```
<- { "type": "inc.channel.open.result", "id": "ch1", "error": "target not found" }
```

#### Error Handling

`inc.subscribe.result` MAY include an `error` field (string) if the shell rejects the subscription. When `error` is present, the subscription was not registered.

```
<- { "type": "inc.subscribe.result", "id": "a1", "error": "topic rejected by ACL" }
```

`inc.channel.open.result` MAY include an `error` field if the channel cannot be opened. When `error` is present, no channel was created.

A successful `inc.channel.open.result` means the runtime created channel state for both endpoints and enqueued the target's `inc.channel.opened` notification. It does not mean the target registered an application handler or accepted the channel's application semantics.

### Topic Conventions

Topics use a prefix convention to signal direction and scope:

| Prefix | Direction | Meaning |
| --- | --- | --- |
| `shell:*` | napplet -> shell | Commands sent by a napplet to the shell (e.g., `shell:state-get`) |
| `napplet:<archetype>/<intent>` | bidirectional | Archetype-scoped messages between napplets (e.g., `napplet:profile/open`, `napplet:dm/open`) |

The routed topic is the stable message identity. The payload carries per-message data. A convention producer MAY use the full convention URI when calling `emit`; the runtime MUST transpose it before routing. Consumers subscribe to the stable topic without query parameters.

These prefixes are advisory. Except for an explicitly intercepted shell topic, the shell treats the complete topic as opaque text. It MUST NOT parse, decode, normalize, prefix-match, or wildcard-match a topic for routing. Therefore query transposition happens before topic routing, never as part of matching.

### Shell Behavior

#### Topic routing

-   The shell MUST route `inc.emit` messages to all napplets subscribed to the exact same complete topic string.
-   The shell MUST copy the emitted `topic` unchanged into each delivered `inc.event`.
-   The runtime MUST derive `sender` from the authenticated emitting endpoint and include its endpoint identifier in delivered `inc.event` messages. It MUST ignore or reject caller-supplied sender data.
-   The shell MUST NOT deliver `inc.event` back to the emitting napplet (sender exclusion).
-   The shell MUST respond to `inc.subscribe` with `inc.subscribe.result` carrying the same `id`.
-   The shell MUST honor `inc.unsubscribe` by removing the subscription for that topic.
-   The shell MAY intercept specific topic prefixes (e.g., `shell:*`) for internal command handling rather than routing them to other napplets.
-   The shell MAY enforce ACL checks on INC capabilities and reject subscriptions or emits that violate shell policy.

#### Channel management

-   The shell MUST respond to `inc.channel.open` with `inc.channel.open.result` carrying the same `id`.
-   The shell MUST assign a `channelId` (opaque identifier) on successful channel open.
-   The shell MUST validate that the target endpoint identifier exists and has a live napplet endpoint before opening a channel.
-   The runtime MUST derive the opener endpoint identifier from its authenticated endpoint. The opener cannot set or override `peer` in `inc.channel.opened`.
-   The runtime MUST enqueue `inc.channel.opened` for the target before it sends a successful `inc.channel.open.result` to the opener.
-   The runtime-provided binding MUST materialize a `ChannelHandle` from `inc.channel.opened` and retain it until at least one `channel.onOpened` handler receives it.
-   The runtime-provided binding MUST retain incoming `inc.channel.event` messages until at least one `ChannelHandle.on` handler is registered for that channel. It MUST NOT silently discard buffered channel messages.
-   The runtime-provided binding MUST retain the terminal `inc.channel.closed` record. A later `ChannelHandle.onClosed` registration MUST receive it.
-   A runtime MAY bound unopened-handle or message buffers. On overflow it MUST close the channel and notify both endpoints instead of silently dropping the handle or messages.
-   The shell MUST forward `inc.channel.emit` messages to the channel peer as `inc.channel.event`.
-   The shell MUST NOT perform per-message validation on channel messages after open — only the initial open is validated once (auth-on-open model).
-   The shell MUST send `inc.channel.closed` to both sides when a channel is torn down (graceful close, iframe removal, or ACL revocation).
-   The shell MUST respond to `inc.channel.list` with `inc.channel.list.result` listing the napplet's active channels.
-   The shell MUST deliver `inc.channel.broadcast` to all open channel peers of the sender, excluding the sender itself.
-   The shell MUST clean up channel state when a napplet endpoint is destroyed, sending `inc.channel.closed` with `reason: "peer destroyed"` to the surviving endpoint.
-   The shell MAY enforce ACL checks on `inc.channel.open` (e.g., restrict which endpoint identifiers a napplet can open channels to).
-   The shell MAY impose a maximum number of concurrent channels per napplet.

### Channels vs Topics

Topics and channels serve different communication patterns within NAP-INC:

**Topics** are loose-coupled publish/subscribe. Any napplet can subscribe to any topic; the sender does not know who (if anyone) will receive the message. There is no persistent connection. Topics are well-suited for infrequent coordination — UI commands, state sync, configuration events, and notifications.

**Channels** are pre-authorized point-to-point connections. A napplet calls `channel.open(target)` to establish a channel to a specific peer identified by its endpoint identifier. The shell validates the target once on open (auth-on-open model): it checks that the target exists, has a live napplet endpoint, and passes ACL. After open, messages flow between the two endpoints without per-message shell validation. Channels are well-suited for sustained data streams, real-time collaboration, and command channels between specific napplets.

Both are part of the same `inc` namespace and share the [NIP-5D](https://github.com/nostr-protocol/nips/pull/2303) wire format. A napplet may use both simultaneously.

### Security Considerations

Sender identity is runtime-enforced from the authenticated emitting endpoint. There is no per-message signing. Runtime sender identification is the trust boundary. A projection defines how its authenticated endpoint is bound.

#### Topics

-   Topic namespaces are not enforced — any napplet can emit on any topic. The shell MAY restrict topics via ACL.
-   After convention URI transposition, payloads are opaque to topic routing. Receiving napplets are responsible for validating payload content.
-   Sender exclusion prevents echo loops but does not prevent a napplet from emitting messages on any topic. Receivers should check the `sender` endpoint identifier if sender identity matters for their use case.

#### Channels

-   Channel authorization is validated once on `inc.channel.open`. After open, messages flow without per-message shell validation. A compromised browser extension could forge messages on an open channel — this is an accepted trust boundary, the same as topic-based INC.
-   An inbound `ChannelHandle` means runtime ACL permitted the channel. It does not imply application-level consent. The target MAY close it immediately.
-   Unclaimed inbound handles and buffered messages consume runtime resources. The runtime SHOULD rate-limit opens and bound buffers per napplet.
-   Channel IDs are opaque. Napplets cannot enumerate or guess other channels' IDs. This prevents channel hijacking.
-   `inc.channel.broadcast` reaches all open channel peers. Napplets SHOULD NOT send sensitive data via broadcast. For confidential communication, use a named channel with a specific target.

### Changelog

-   `cdeaec3` - Introduced NAP-INC topic and channel communication.
-   `6461e4b` - Adopted unnumbered convention topics for napplet messages.
-   `f24a708` - Separated stable topic identity from per-message payload data.
-   `8782bb1` - Added runtime transposition from convention URI parameters to topic payload data.
-   `13fed4b` - Made sender attestation runtime-derived and carrier-neutral.
-   `6446441` - Made channel handles symmetric with target attachment, delivery ordering, and terminal notification.
-   `24ab958` - Replaced bare dTag peer identity with authenticated endpoint identifiers for all manifest kinds and adopted injected-domain availability.

[Read specification on GitHub ↗](https://github.com/napplet/naps/blob/master/naps/NAP-INC.md)

On this pageSpecification metadata

Capability domain

`inc`

Repository state

Merged

Registry status

Draft

Document marker

draft

Declared dependencies

None declared in the preamble

Source revision

`43cb3a4e8555`

[View source on GitHub ↗](https://github.com/napplet/naps/blob/master/naps/NAP-INC.md)
