Skip to main content
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.
  • 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.
metadata
string
required
A per-file identifier. The ack echoes it.
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.
integer
required
The file’s length in bytes. The receiver checks the bytes it wrote against this value.
integer
required
Position in the batch, 1-based.
integer
required
How many files are in the batch.
integer
required
The batch’s total size. Older senders may send 0 for a single file, in which case a receiver substitutes fileSize.
integer
Highest protocol version this peer speaks. Absent on releases before v1.6.0, which are treated as version 1.
integer
Lowest protocol version this peer still supports.
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.

ack

Receiver to sender, once per file, before any bytes move.
ack
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.

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.
end

received

Receiver to sender, once, after every file in the batch is written.
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.
incompatible

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.

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. 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.

HTTP API

The signaling server’s endpoints, up to the point where this protocol takes over.

Architecture

Components, transports, and the shape of a whole session.