1 Overview
This NIP is marked unrecommended. It was implemented only once, and it is unclear whether that implementation works. The spec requires review. No replacement NIP exists. For normal connectivity, use the WebSocket transport from NIP-01.
Nostr normally runs over WebSocket. A client connects to a relay and they exchange JSON messages. This NIP moves that same conversation onto Bluetooth Low Energy (BLE). Two nearby devices can then exchange and synchronize events with no internet at all.
BLE follows a client-server pattern of its own, called GATT. This NIP maps the two models onto each other. The GATT Server plays the relay. The GATT Client plays the client. The NIP then adapts the message flow to BLE limits: tiny packets, one operation at a time, and connections that come and go.
The NIP defines four things. A device advertisement, so devices can find each other. A GATT service with two characteristics, one for writes and one for reads. A batch format that compresses and splits large messages. A half-duplex workflow that synchronizes events with NIP-77 negentropy messages.
2 Device Advertisement
A device announces itself with a standard BLE advertisement. The advertisement carries two values:
| Field | Value |
|---|---|
| Service UUID | 0000180f-0000-1000-8000-00805f9b34fb |
| Data | The device UUID, encoded as a byte array. |
The advertised service UUID 0000180f-… is the Bluetooth SIG base-UUID slot for the standard Battery Service (0x180F). Reusing an assigned 16-bit identifier can collide with real battery-service devices during discovery. The source spec does not explain this choice.
3 GATT Service
The device exposes a Nordic UART Service. The service has two characteristics. All traffic between the two devices flows through them.
| Characteristic | UUID | Properties |
|---|---|---|
| Write | 87654321-0000-1000-8000-00805f9b34fb | Write |
| Read | 12345678-0000-1000-8000-00805f9b34fb | Notify, Read |
The GATT Client writes messages to the Write Characteristic. The GATT Client reads messages from the Read Characteristic. The GATT Server uses the Notify property of the Read Characteristic to signal that new data waits.
4 Role Assignment
Each device advertises a device UUID. When a device finds another device that advertises the service, it reads the service data. It gets the peer's device UUID. It compares that UUID with its own advertised device UUID.
- The device with the highest device UUID takes the role of GATT Server. It acts as the relay.
- The other device takes the role of GATT Client. It acts as the client, and it establishes the connection.
Some devices only ever serve one purpose. Such a device does not negotiate. It always advertises a fixed device UUID:
| Role | Device UUID |
|---|---|
| GATT Server | FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF |
| GATT Client | 00000000-0000-0000-0000-000000000000 |
5 Messages and Batching
All messages follow the NIP-01 message structure. Before transmission, the sender applies a DEFLATE compression stream to the message. The result is a byte array.
A BLE packet is small. BLE 4.2 carries 20 to 23 bytes per message. Later BLE versions carry up to 256 bytes. When the compressed byte array does not fit in one packet, the sender splits it into batches. Each batch has this structure:
[batch index (first 2 bytes)][batch n (payload)][is last batch (last byte)]
The receiver collects all batches. It joins them in index order. It then decompresses the joined array with DEFLATE and gets the original message. These rules bound the transfer:
- Only one message is read or written at a time. This keeps the link reliable.
- The devices can negotiate the MTU in advance.
- The maximum size for a message is 64KB. The receiver rejects bigger messages.
6 Workflows
Client to relay
- Any message the client sends to the relay is a write message on the Write Characteristic.
- Any message the client receives from the relay is a read message on the Read Characteristic.
Relay to client
A relay cannot push data over BLE the way a WebSocket relay can. Instead, when a new event matches a subscription's filters, the relay should send a Notify signal on the Read Characteristic. The client then reads the waiting messages from the relay.
Device synchronization
A BLE connection between two devices can be extremely intermittent. Gaps of hours or even days are expected. The devices therefore need a defined synchronization process. This NIP uses the negentropy protocol from NIP-77, adapted to BLE limits.
After the two devices connect and settle the client-server roles, they communicate half-duplex. They take turns. Only one side transmits at a time.
Half-duplex synchronization
Right after the two devices connect, the client starts the workflow. It sends the first message. The steps are:
- Client — writes a NIP-77
NEG-OPENmessage. - Server — sends
write-success. - Client — sends
read-message. - Server — responds with a NIP-77
NEG-MSGmessage. - Client —
- If the client has events that the server misses, it writes one
EVENT. - If the client has no events that the server misses, it writes
EOSE. After that, the client's messages to the server stay empty while the server still claims more notes for the client.
- If the client has events that the server misses, it writes one
- Server — sends
write-success. - Client — sends
read-message. - Server —
- If the server has events that the client misses, it responds with one
EVENT. - If it has none, it responds with
EOSE. After that, its responses to the client stay empty.
- If the server has events that the client misses, it responds with one
- If the client detects that the devices are not synchronized yet, jump to step 5.
- When both ends detect no more missing events, the workflow pauses at this point.
The source text of step 8.2 reads "If the Client doesn't have any messages missing on the Server". That condition belongs to step 5.2. In step 8 the server is the sender, so the intended condition is almost certainly "if the server has no events missing on the client". The spec never fixed this wording.
Half-duplex event spread
Two connected and synchronized devices can drift again. One of them may receive a new event from another connected peer. Devices MUST track which notes they sent to each peer while connected. When a device detects that a connected, synchronized peer misses a newly received event, it spreads the event:
- If the peer is a server:
- Client — writes the
EVENT. - Server — sends
write-success.
- Client — writes the
- If the peer is a client:
- Server — sends an empty notification to the client.
- Client — sends
read-message. - Server — responds with the
EVENT.
7 Worked Example
The source NIP ships one code example in Kotlin. One function compresses a byte array and splits it into chunks. The other function joins the chunks and decompresses them to recover the original bytes.
fun splitInChunks(message: ByteArray): Array<ByteArray> { val chunkSize = 500 // define the chunk size var byteArray = compressByteArray(message) val numChunks = (byteArray.size + chunkSize - 1) / chunkSize // calculate the number of chunks var chunkIndex = 0 val chunks = Array(numChunks) { ByteArray(0) } for (i in 0 until numChunks) { val start = i * chunkSize val end = minOf((i + 1) * chunkSize, byteArray.size) val chunk = byteArray.copyOfRange(start, end) // add chunk index to the first 2 bytes and last chunk flag to the last byte val chunkWithIndex = ByteArray(chunk.size + 2) chunkWithIndex[0] = chunkIndex.toByte() // chunk index chunk.copyInto(chunkWithIndex, 1) chunkWithIndex[chunkWithIndex.size - 1] = numChunks.toByte() // store the chunk in the array chunks[i] = chunkWithIndex chunkIndex++ } return chunks } fun joinChunks(chunks: Array<ByteArray>): ByteArray { val sortedChunks = chunks.sortedBy { it[0] } var reassembledByteArray = ByteArray(0) for (chunk in sortedChunks) { val chunkData = chunk.copyOfRange(1, chunk.size - 1) reassembledByteArray = reassembledByteArray.copyOf(reassembledByteArray.size + chunkData.size) chunkData.copyInto(reassembledByteArray, reassembledByteArray.size - chunkData.size) } return decompressByteArray(reassembledByteArray) }
The example does not match the spec, and it clobbers data. The batch format in §5 reserves 2 bytes for the index; the code writes 1 byte and reads 1 byte back. The last byte should be an "is last batch" flag; the code stores the total chunk count instead. The code also copies the payload into offset 1 of a buffer sized chunk.size + 2, then overwrites the buffer's last byte — which is the payload's final byte. Finally, chunkSize = 500 exceeds every packet limit stated in §5 (23 or 256 bytes). This mismatch is part of why the NIP requires review.
The transcript below shows a full half-duplex synchronization pass between a phone (client) and a fixed relay device (server). Orange lines are client writes. Blue lines are server responses.
-- roles settled: phone UUID 21D7… < relay UUID FFFF… → phone = client -- → write ["NEG-OPEN","sub1",{"kinds":[1]},"<initial negentropy msg>"] ← write-success → read-message ← ["NEG-MSG","sub1","<negentropy reconciliation msg>"] -- client has one event the server misses -- → write ["EVENT",{"id":"b1a649ebe8…","kind":1,"content":"sent over BLE",…}] ← write-success → read-message ← ["EVENT",{"id":"9f2c11ab…","kind":1,…}] -- nothing left on either side -- → write ["EOSE","sub1"] ← write-success → read-message ← ["EOSE","sub1"] -- synchronized: workflow pauses until a new event arrives --
Every message above travels as DEFLATE-compressed batches, one BLE packet at a time, per §5. The JSON shown is the payload after reassembly and decompression.
8 Version History
NIP-BE has a short history. It landed late in 2025 and was flagged as unrecommended half a year later. Notable changes, newest first:
- 20262026-05fiatjaf added the
unrecommendedtag and the warning to the NIP body: only implemented once, unclear whether it works, requires review. - Origin2025-11KoalaSat published the spec: BLE messaging and device synchronization, merged as PR #1979.
Original Proposal - Credits
| Author | KoalaSat |
| First commit | 2025-11-11 — “NIP-BE: Add BLE messaging and device synchronization” (PR #1979) |
| Status | draft - unrecommended - optional — deprecated, requires review |
| Source | BE.md on GitHub - full change history |