> ## Documentation Index
> Fetch the complete documentation index at: https://www.floe.one/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Data-channel transfer protocol

> The wire format Floe peers speak over the WebRTC data channel: message types, framing, protocol versioning, chunk sizing, backpressure, and every timeout.

Once the WebRTC data channel opens, the signaling server is out of the picture and the two peers
speak this protocol directly. The browser, the CLI, and Floe Desktop all implement it and are
interoperable in every direction. The CLI and the desktop app share one implementation in
`cli/engine/transfer`; the browser has its own in `client/lib/transfer`.

## The sequence

The sender runs this for each file, in order:

1. **`metadata`**, sender to receiver, naming the file and its size.
2. **`ack`**, receiver back to sender, before any bytes move.
3. **Binary chunks**, sender to receiver, until the file is exhausted.
4. **`end`**, sender to receiver, closing the file off.

The next file starts again at step 1. The final `received` comes once, after every file, and only
from a Go receiver.

Four things that order does not show.

* **Step 3 is zero or more frames.** An empty file is `metadata`, `ack`, `end`, with no binary
  frame between them at all.
* **There is no cancel message.** Canceling is a channel close, and a receiver declining at
  the CLI's prompt closes the channel without sending anything. Every wait in the Go engine
  watches for that close as a separate signal and gives up at once. The browser sender does not:
  it sees a close only through its buffer-drain polls, so a decline reaches it as the full
  120-second ack timeout instead.
* **A peer that stops on purpose says why.** Canceling is still a bare close, but a side that
  refuses a transfer for a reason it can name sends that reason first, in an `incompatible`
  frame, and waits for it to flush before tearing the connection down. That covers the 2 GB relay
  cap on the sending side and a file the receiver would not keep on the other. See
  [incompatible](#incompatible).
* **A person can sit between steps 1 and 2.** `floe receive` prints `Accept? [Y/n]` and waits for
  an answer before it sends the ack. That is the whole reason the ack deadline is 120 seconds.
* **The end of a batch is a race, not a message.** A Go sender finishes on whichever comes
  first: the `received` frame, its send buffer reaching zero, or a full 60 seconds in which the
  buffer did not shrink at all.

## How a frame is classified

There is no length prefix and no envelope. What a frame is comes from **how it was sent**, not
from what is inside it.

In the direction file data travels, sender to receiver, a frame is a control message if and only
if it is a **text** frame. A **binary** frame is file data, whatever its bytes spell. That is the
whole rule, and it is what lets a small `.json` file whose entire content is `{"type":"end"}`
arrive intact instead of being swallowed as an end marker.

A text frame is then read as a control message when both of these hold:

1. It is at most **1000 bytes**. A longer one is a protocol error and stops the transfer, rather
   than being written to a file.
2. It parses as JSON with a `type` field naming a message below.

A text frame that parses but names a type the receiver does not know is **ignored**. That is what
makes a new message type safe to add: an older peer drops it instead of writing it into whatever
file is open.

The other direction, receiver to sender, carries no file data, so nothing there can be confused
with a chunk and a sender accepts a control message in either framing. It has to: a Go receiver
sends `ack`, `received` and `incompatible` as binary, and a browser receiver sends `ack` as a
string and `incompatible` as binary.

If you are building another client, one rule falls out of all of this: **every frame you send to
a receiver that is not file data has to be a text frame.** A binary one lands in somebody's file.

One implementation detail worth knowing, because it is the one place the two disagree. The Go
receiver skips leading whitespace before looking for the opening `{`; the browser requires the
frame to start with it. So a control message padded with leading spaces is read by a Go receiver
and treated as file data by a browser receiver. Nothing in Floe sends one.

## Message types

### metadata

Sender to receiver, before each file.

```json metadata theme={"system"}
{
  "type": "metadata",
  "id": "3f2b9c1e-7a4d-4c2e-9b1f-8e6d5c4b3a21",
  "fileName": "photo.jpg",
  "fileSize": 6291456,
  "index": 1,
  "total": 3,
  "totalBytes": 11800000,
  "pv": 1,
  "pvMin": 1,
  "ver": "1.10.11"
}
```

<ResponseField name="id" type="string" required>
  A per-file identifier. The ack echoes it.
</ResponseField>

<ResponseField name="fileName" type="string" required>
  The name as the sender sees it, including any relative path when a folder is being sent. The
  receiver sanitizes it before writing.
</ResponseField>

<ResponseField name="fileSize" type="integer" required>
  The file's length in bytes. The receiver checks the bytes it wrote against this value.
</ResponseField>

<ResponseField name="index" type="integer" required>
  Position in the batch, **1-based**.
</ResponseField>

<ResponseField name="total" type="integer" required>
  How many files are in the batch.
</ResponseField>

<ResponseField name="totalBytes" type="integer" required>
  The batch's total size. Older senders may send `0` for a single file, in which case a receiver
  substitutes `fileSize`.
</ResponseField>

<ResponseField name="pv" type="integer">
  Highest protocol version this peer speaks. Absent on releases before v1.6.0, which are treated
  as version 1.
</ResponseField>

<ResponseField name="pvMin" type="integer">
  Lowest protocol version this peer still supports.
</ResponseField>

<ResponseField name="ver" type="string">
  The human release string, and its shape depends on the build. The CLI sends `1.10.11` with no
  leading `v`, because GoReleaser strips it. The desktop app sends the tag verbatim, as
  `desktop-v0.2.12`. A build from source sends `dev`. Browser peers omit the field entirely. It is
  informational and never gates a transfer.
</ResponseField>

### ack

Receiver to sender, once per file, before any bytes move.

```json ack theme={"system"}
{ "type": "ack", "id": "3f2b9c1e-...", "offset": 0, "pv": 1, "pvMin": 1, "ver": "1.10.11" }
```

<ResponseField name="offset" type="integer" required>
  Where the sender should start reading. Reserved for mid-file resume: both senders honor a
  non-zero value and seek to it. The Go receiver hard-codes `0`. The browser receiver would reply
  with what it already holds if a second `metadata` arrived for a file id it had buffered bytes
  for, but no current sender ever reuses an id that way, and no resume state is kept on disk or
  across connections. **Against any current sender it is always `0`.**
</ResponseField>

### Binary chunks

Raw bytes, sent until the file is exhausted. Chunk size is a local sender choice and not part of
the protocol; every receiver handles any size.

Both implementations size chunks to the connection's negotiated maximum message size, capped at
256 KB. When that size is unavailable they fall back to different defaults: **16 KB** in the Go
engine, **64 KB** in the browser.

The browser reads the source file in 4 MB slabs and sub-slices those into chunks. The Go sender
reads straight into a chunk-sized buffer.

### end

Sender to receiver, after the last chunk of a file.

```json end theme={"system"}
{ "type": "end" }
```

### received

Receiver to sender, once, after every file in the batch is written.

```json received theme={"system"}
{ "type": "received" }
```

Only Go receivers send it, and only Go senders act on it: it lets them exit immediately instead
of polling the SCTP buffer. The browser sender ignores it even when a Go receiver sends it, and
always waits for its own send buffer to drain to zero instead.

### incompatible

Two jobs, told apart by the `pv` range it carries.

**A version mismatch.** Sent by the receiver **in place of the ack** when the two protocol version
ranges do not overlap. The receiver aborts before creating any file, so the sender fails fast
rather than waiting for an ack that never comes. Senders that predate this message type ignore it
safely.

**A deliberate abort with a reason**, when the `pv` range **does** overlap. A peer reads the
overlap as "this is not about versions" and prints the `reason` verbatim instead of rebuilding a
version remedy from `pv` and `pvMin`. That is what lets a side that is stopping on purpose name
the cause without a new message type and without a `ProtocolVersion` bump. It carries the relay
cap and a source file that changed under the send from a sender, and a discarded file, a
sender that overran its announced size, or a refused file description from a receiver.

Framing depends on direction, and this is the one rule another client has to get right.
Receiver to sender is binary, which is what every Floe receiver has always sent. **Sender to
receiver has to be text**, because on that path a binary frame is file data by definition, and
Go receivers from v1.1.0 to v1.5.5 will write a binary one into the file being received.

The whole encoded frame stays within 1000 bytes, reason included, or a browser receiver stops
reading it as a control message. Whichever side displays it caps the reason again, at 300
characters.

Both Go peers wait for the send buffer to drain, up to 2 seconds, before closing, and so does a
browser sender. Without that wait the frame is routinely lost: closing a pion connection aborts
the SCTP association and discards whatever was still queued, so the peer would see the close and
report a lost connection rather than the reason. A browser receiver sends without waiting,
because it does not close the connection afterwards.

```json incompatible theme={"system"}
{
  "type": "incompatible",
  "reason": "Cannot transfer: your floe is too old for this peer. ...",
  "pv": 1,
  "pvMin": 1,
  "ver": "desktop-v0.2.12"
}
```

## Protocol versioning

The wire protocol carries a version of its own, independent of Floe's release numbers. That is
what lets peers on different releases interoperate while still catching a genuine breaking
change cleanly.

* Each peer advertises `pv`, the highest version it speaks, and `pvMin`, the lowest it still
  supports. **Both are 1 today.**
* Two peers are compatible when their ranges overlap:
  `max(localMin, remoteMin) <= min(localMax, remoteMax)`. They operate at the highest common
  version.
* A peer that omits the fields is treated as version 1, so every release ever shipped stays
  compatible.
* When the ranges miss, the receiver sends `incompatible` and each side prints a remedy for its
  own surface: `floe update` on the CLI, the Microsoft Store or the download page on the desktop,
  a page refresh in the browser.

Which side is named depends on who is reading. No current peer trusts the wire `reason` for a
version mismatch: both implementations rebuild the message locally from the frame's `pv` and
`pvMin`, so the side they name is always correct. Both receivers also word the `reason` they put
on the wire from the recipient's point of view, in words that name no surface, for peers that
display it verbatim. When a message names a remedy that makes no sense for your surface, the peer
that sent it is older than this convention, so compare the two version strings it prints and
update the older one.

Constants live in `cli/engine/transfer/protocol.go` as `ProtocolVersion` and `MinProtocolVersion`,
and in `client/lib/transfer/protocol.ts` as `PROTOCOL_VERSION` and `MIN_PROTOCOL_VERSION`. Bump
`ProtocolVersion` only on a breaking wire change, and raise `MinProtocolVersion` only when
deliberately dropping support for an old format. Keep the two implementations in step. A test on
each side pins both numbers and names its twin (`TestProtocolVersionPinnedToClient` in
`cli/engine/transfer/protocol_test.go`, the `constants` block in
`client/lib/transfer/protocol.test.ts`), so a bump cannot land on one side without a failing test
pointing at the other.

## Flow control

The sender pauses when the SCTP send buffer reaches **8 MB** and resumes once it drains below
**4 MB**. Both implementations use the same two numbers.

If the buffer has not shrunk at all after 60 seconds, the Go sender gives up on the transfer.
A wait that is merely slow does not trigger it; only a buffer that never moves does.

The browser adds two drains the Go engine does not have: it waits for the buffer to fall below
64 KB before sending each file's metadata, and it drains to zero before declaring the batch
complete.

## Integrity

A file is committed only when the bytes written match `fileSize` exactly. A mismatch in either
direction discards the file, because a short count means truncation and an over-count means a
frame boundary was wrong, which corrupts the result just as thoroughly.

The sender holds itself to the same number. It reads at most `fileSize` bytes, so a
file that grows after its size is announced cannot overrun the announcement, and if
the file changed on disk in that window the sender fails locally and sends no end
marker, so the receiver is told the file changed rather than left to report a byte count that
reads like a network fault.

**Floe computes no hash of file contents at any point.** The check is a byte count. The transport
already authenticates every packet, so corruption in flight is not the gap being covered here;
truncation is.

The two implementations disagree on one input. If a peer announces no usable `fileSize`, the
browser skips the check and keeps whatever arrived, while the Go receiver reads the missing size
as `0` and therefore discards the file. Only a peer predating the field can produce that, and none
has shipped in a long time.

Receivers that write to disk stage each file under a name ending in `.part` and rename it only
after that check passes, so a killed transfer never leaves a file sitting under its final name.
See [Known limitations](/docs/how-it-works/known-limitations).

## Timeouts

Every value the Go engine enforces. The browser shares two of them: the 120-second ack deadline
and the 2-second wait for an abort reason to reach the wire.

| Phase                                          | Limit              | What happens                                                                 |
| ---------------------------------------------- | ------------------ | ---------------------------------------------------------------------------- |
| Waiting for the peer's offer or answer         | 30 s               | Connection attempt fails                                                     |
| ICE, DTLS, and the data channel opening        | 30 s               | `timed out establishing a connection`                                        |
| Data channel grace after the peers connect     | 10 s               | Connection attempt fails                                                     |
| Sender waiting for a file's ack                | 120 s              | Transfer fails. Sized for a human answering the CLI's `Accept? [Y/n]` prompt |
| Sender's send buffer not draining at all       | 60 s               | Transfer fails                                                               |
| Sender waiting for delivery confirmation       | 60 s with no drain | `timed out waiting for delivery confirmation from peer`                      |
| Receiver connected, no data yet                | 30 s               | `connected, but no data arrived from the sender within 30s`                  |
| Receiver mid-transfer with no progress         | 60 s               | Transfer fails                                                               |
| Receiver grace before teardown                 | 5 s                | Lets the final acknowledgement reach the sender                              |
| The byte-count report                          | 5 s                | Abandoned; never affects the files                                           |
| Flushing an abort reason before closing        | 2 s                | Best effort; the close goes out either way                                   |
| Fetching TURN credentials over HTTP            | 30 s               | Warns and falls back to STUN only                                            |
| Registering or resolving a room code over HTTP | 30 s               | `could not register code` or `could not reach signaling server`              |

One deadline sits outside the engine. `floe send` and `floe receive` give the server 20 seconds
to answer `join-room` before failing with `timed out waiting for the server to assign a role`.
Floe Desktop has always used the same 20 seconds.

A browser sender waits about 2 seconds after the peers connect before it checks the route and
sends the first byte. A `floe receive` can linger for up to about 10 seconds after printing its
summary, which is the teardown grace plus the report.

## One SDP detail worth knowing

Pion, the WebRTC stack behind the CLI and the desktop app, omits `a=max-message-size` from the
answer it generates. RFC 8841 makes the absent default 65536, and Chrome then rejects every chunk
larger than that. The Go engine patches the attribute into the answer with a value of 1 GB before
sending it, which is what makes browser-to-CLI transfers work at all.

If you are building another client against Floe, this is the non-obvious part.

## Related

<Columns cols={2}>
  <Card title="HTTP API" icon="webhook" href="/docs/reference/http-api">
    The signaling server's endpoints, up to the point where this protocol takes over.
  </Card>

  <Card title="Architecture" icon="layers" href="/docs/reference/architecture">
    Components, transports, and the shape of a whole session.
  </Card>
</Columns>


## Related topics

- [Introduction](/docs/introduction.md)
- [How the pieces fit together](/docs/reference/architecture.md)
- [Signaling server HTTP API](/docs/reference/http-api.md)
- [File size limits and the 2 GB relay cap](/docs/how-it-works/2gb-limit.md)
- [Which app should I use?](/docs/choosing-floe.md)
