Chapter BE - Transport
BE
Nostr Implementation Possibility - Hex № BE

Nostr BLE Communications Protocol

Run the Nostr client-relay conversation over Bluetooth Low Energy when no internet connection exists between two devices.

Deprecated

1 Overview

Deprecated

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.

Device A UUID C4F0… Device B UUID 21D7… advertisement + device UUID advertisement + device UUID C4F0… > 21D7… — Device A becomes GATT Server (relay), Device B becomes GATT Client (client) and connects.
Fig. BE-1 — Discovery and roles. Both devices advertise. The higher device UUID takes the relay role.

2 Device Advertisement

A device announces itself with a standard BLE advertisement. The advertisement carries two values:

Advertisement contents
FieldValue
Service UUID0000180f-0000-1000-8000-00805f9b34fb
DataThe device UUID, encoded as a byte array.
Tech Note

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.

GATT characteristics
CharacteristicUUIDProperties
Write87654321-0000-1000-8000-00805f9b34fbWrite
Read12345678-0000-1000-8000-00805f9b34fbNotify, 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:

Fixed-role device UUIDs
RoleDevice UUID
GATT ServerFFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF
GATT Client00000000-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 format - one BLE packet
[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.
Message NIP-01 JSON DEFLATE → byte array Split [idx|data|last] Write 1 at a time
Fig. BE-2 — Batch pipeline. Compress, split, send. The receiver runs the same steps in reverse.

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:

  1. Client — writes a NIP-77 NEG-OPEN message.
  2. Server — sends write-success.
  3. Client — sends read-message.
  4. Server — responds with a NIP-77 NEG-MSG message.
  5. Client —
    1. If the client has events that the server misses, it writes one EVENT.
    2. 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.
  6. Server — sends write-success.
  7. Client — sends read-message.
  8. Server —
    1. If the server has events that the client misses, it responds with one EVENT.
    2. If it has none, it responds with EOSE. After that, its responses to the client stay empty.
  9. If the client detects that the devices are not synchronized yet, jump to step 5.
  10. When both ends detect no more missing events, the workflow pauses at this point.
Tech Note

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.

Client Server write: NEG-OPEN write-success read-message NEG-MSG write: EVENT (or EOSE) write-success read-message EVENT (or EOSE) — repeat until synchronized Both ends report EOSE: the workflow pauses.
Fig. BE-3 — Half-duplex synchronization. The client takes turns with the server until neither side misses events.

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:

  1. If the peer is a server:
    1. Client — writes the EVENT.
    2. Server — sends write-success.
  2. If the peer is a client:
    1. Server — sends an empty notification to the client.
    2. Client — sends read-message.
    3. 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.

Kotlin - split and join batches (verbatim from the spec)
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)
}
Tech Note

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.

BLE session - half-duplex sync
-- 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 unrecommended tag 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

AuthorKoalaSat
First commit2025-11-11 — “NIP-BE: Add BLE messaging and device synchronization” (PR #1979)
Statusdraft - unrecommended - optional — deprecated, requires review
SourceBE.md on GitHub - full change history

9 See Also

  • NIP-01 — defines the message structure that every BLE payload carries.
  • NIP-77 — the negentropy sync protocol (NEG-OPEN, NEG-MSG) that the half-duplex workflow adapts.
  • NIP-B7 — the previous chapter; unrelated in topic, adjacent in hex order.