kuilt Help

How connections work

kuilt gives your app one way to send and receive. Learn it once; swap how devices connect without rewriting the code that uses the link.

At a high level, it is three things:

  • open or join a session (Loom),

  • send and receive frames (Seam + Swatch),

  • react when peers join or leave (peers).

Different transports fail in different ways. The contract keeps those differences out of your app code.

Type

Role

Loom

Factory — weave(Rendezvous): Seam; convenience wrappers host(Pattern) and join(Tag)

Seam

One peer's symmetric view of a live session

Swatch

Immutable binary frame — payloadSize: Int, sender: PeerId?, sequence: Long; read bytes zero-copy via byteAt/decodeToString/decode; copy explicitly with toByteArray()

Rendezvous

Sum type: New(pattern) to host, Existing(tag) to join

Pattern

Config for opening a session: display name, max peers

Tag

Discovery handle for joining a session (WebSocketAdvertisement, MDNSAdvertisement, …)

PeerId

Stable identifier for a peer within a session

FabricAvailability

Available, Unavailable(reason), or Unknown(reason) (no ground truth yet)

TransportCapability

A fabric's self-report: the roles it plays plus its FabricAvailability

TransportRole

What a transport does — e.g. Data, Discovery, WifiLan, WifiDirect, Bluetooth, WebRtc, ServerRelay

Loom

Loom is where sessions come from: host a new one or join an existing one. Formally, its single abstract method is:

suspend fun weave(rendezvous: Rendezvous): Seam

Two convenience wrappers delegate to it:

suspend fun host(pattern: Pattern): Seam = weave(Rendezvous.New(pattern)) suspend fun join(tag: Tag): Seam = weave(Rendezvous.Existing(tag))

Before you connect, a fabric can tell you two things: what it is and whether it can run right now. Both come from one method, capability():

fun capability(): TransportCapability // roles + availability
  • Roles describe what the transport does — carrying data, discovering peers, Wi-Fi on a shared network vs. peer-to-peer Wi-Fi, Bluetooth, WebRTC, or relaying through a server. A single fabric can hold several roles at once.

  • Availability says whether you can attempt it now. It is three-valued:

    • Available — good to go.

    • Unavailable(reason) — a capability that exists in principle but is missing right now (for example, Play Services absent on an AOSP build).

    • Unknown(reason) — nobody has established the answer up front, so the only way to find out is to try. This is what a fabric says by default, and the reason tells you what was never checked (a permission that was not asked for, a radio nobody read). A fabric only claims Available once it can back the claim up.

    A fabric that is genuinely absent on a platform — no class to construct at all — simply is not on the classpath. That is different from a fabric that ships a placeholder you can construct: Apple's Multipeer does exactly that on Android and in the browser, so you really do hold one there, and it answers Unavailable rather than pretending.

availability() is a convenience shortcut for the availability half of capability(); fabric authors override capability(), never availability().

A host composing fabrics should skip the ones that have ruled themselves out, rather than keep only the ones that have proved themselves — a fabric saying "I don't know" is usually still worth trying, and is often the honest answer:

val activeLoom = looms.first { it.availability() !is FabricAvailability.Unavailable }

At runtime the same report is available live per session as Seam.capability, so a host can react as a transport's real-world reachability changes.

A Loom can also combine other Looms rather than pick one: CompositeLoom runs several transports as one bonded session for the same peer. See Multipath.

Seam

Seam is the API your app actually uses at runtime. It is one peer's symmetric view of a multi-peer session. There is no client Seam and no server Seam — every peer holds the same interface.

interface Seam { val selfId: PeerId val peers: StateFlow<Set<PeerId>> // includes selfId val incoming: Flow<Swatch> // single-collection suspend fun broadcast(payload: ByteArray) suspend fun sendTo(peer: PeerId, payload: ByteArray) suspend fun close(reason: CloseReason = CloseReason.Normal) }

The rules

These are the load-bearing invariants. Violating them breaks consumers in ways the type system won't catch:

incoming is single-collection

One Flow<Swatch> carries all peers' frames, in send order, delivered to one collector. Collect it once per Seam. A second concurrent collector races and is unsupported.

If several parts of your application need the frames, wrap with shareIn:

val shared = seam.incoming.shareIn(scope, SharingStarted.Eagerly) // now multiple collectors on `shared` are safe

Swatch is binary-only

No text-frame variant. The wire layer never interprets the bytes — that is the consumer's job.

sender and sequence are stamped on receipt

Sending peers leave sender null and sequence zero. The receiving Seam stamps them:

/** * The receiving [Seam] stamps `sender` from the sending peer's [PeerId]. * * Alias for the `Swatch sender field on received broadcast equals sender PeerId` * test — the backtick name can't be an `include-symbol` target. */ @Suppress("unused") internal fun sampleSwatchSenderField() = runTest { val factory = InMemoryLoom() val a = factory.host(Pattern("Alice")) val b = factory.join(InMemoryTag("Bob")) val deferred = async { b.incoming.first() } a.broadcast(byteArrayOf(0)) val frame = deferred.await() assertEquals(a.selfId, frame.sender) }

No client/server split

A 2-peer WebSocket connection is the degenerate peers.size == 2 case of the symmetric model. This is why the WebSocket fabric and an N-peer Multipeer mesh share one contract.

close() is idempotent

Calling close() twice must not throw:

/** * [Seam.close] is idempotent — a second call must not throw. * * Alias for the `close is idempotent — calling twice does not throw` test. */ @Suppress("unused") internal fun sampleCloseIsIdempotent() = runTest { val factory = InMemoryLoom() val link = factory.host(Pattern("Alice")) link.close() link.close() // must not throw }

When a peer closes, it is removed from every other peer's peers set atomically and sending to it becomes an error.

peers tracks membership

peers: StateFlow<Set<PeerId>> always includes selfId. When peers join and leave, the flow emits the updated set on every Seam in the session:

/** * Closing a peer removes it from every other peer's [Seam.peers] set. * * Alias for the `close removes the closing peer from every other peer's peers set` test. */ @Suppress("unused") internal fun sampleCloseRemovesPeer() = runTest { val factory = InMemoryLoom() val a = factory.host(Pattern("Alice")) val b = factory.join(InMemoryTag("Bob")) val c = factory.join(InMemoryTag("Charlie")) b.close() val expected = setOf(a.selfId, c.selfId) assertEquals(expected, a.peers.value) assertEquals(expected, c.peers.value) }

Sequence numbers

The receiving Seam assigns a monotonically increasing sequence number per receiver. Sequence numbers are receiver-local — A and B have independent counters:

/** * The receiving [Seam] assigns sequence numbers starting at 1, increasing by 1. * * Alias for the `sequence on received frames is monotonically increasing starting from 1` test. */ @Suppress("unused") internal fun sampleSequenceMonotonicallyIncreasing() = runTest { val factory = InMemoryLoom() val a = factory.host(Pattern("Alice")) val b = factory.join(InMemoryTag("Bob")) val frames = async { b.incoming.take(3).toList() } a.broadcast(byteArrayOf(1)) a.broadcast(byteArrayOf(2)) a.broadcast(byteArrayOf(3)) val received = frames.await() assertEquals(1L, received[0].sequence) assertEquals(2L, received[1].sequence) assertEquals(3L, received[2].sequence) }
02 October 2026