kuilt Help

Connections

Connections are how peers reach each other. In kuilt, each connection implementation is called a fabric.

Every fabric is wrapped in the same Loom/Seam contract, so from your app's point of view the workflow stays the same. Your app logic never needs to know whether peers are connected by WebSocket, LAN discovery, or direct radio.

Pick a connection path by deployment shape

  • Use WebSocket when you want the fastest path to cross-platform connectivity.

  • Use mDNS when peers need local-network discovery before connecting.

  • Use direct device links when you want peer-to-peer connectivity without a relay server.

WebSocket fabric (kuilt-websocket)

WebSocket is usually the quickest path to a cross-platform session. Setup is role-split (server accepts, client connects), but once connected both peers use the same Seam API. The split is:

  • KtorServerLoom — JVM/Android. Supports only host(); join() throws.

  • KtorClientLoom — all targets. Supports only join(); host() throws.

Server:

val server = KtorServerLoom(application, path = "/live", selfPeerId = PeerId("server-1")) scope.launch { while (isActive) { val seam = server.nextLink() // suspends until a client connects handleConnection(seam) } }

Client:

val client = KtorClientLoom(httpClient) val seam = client.join( WebSocketAdvertisement( url = "ws://192.168.1.10:8080/live", serverPeerId = PeerId("server-1"), sessionName = "alice", ), )

The advertisement includes the server's PeerId, so both ends get the same membership view without an extra handshake message.

mDNS discovery (kuilt-mdns, JVM/Android)

mDNS helps peers find each other on a local network. It is discovery, not transport: the session still runs over WebSocket. MDNSPeerLinkFactory registers an mDNS service on host() and resolves an MDNSAdvertisement to a WebSocket join on join(). Discover peers separately with MDNSServiceDiscoverer:

val jmdns = JmDNS.create() // Host: registers the mDNS service and starts the WebSocket server. val host = MDNSPeerLinkFactory(application, jmdns, port = 8080, httpClientFactory = { HttpClient { } }) val hostSeam = host.host(Pattern("alice's game")) // Joiner: discover then join. val joiner = MDNSPeerLinkFactory(application, jmdns, port = 8080, httpClientFactory = { HttpClient { } }) val discoverer = MDNSServiceDiscoverer(jmdns) val ad = discoverer.discoveries().first() val joinerSeam = joiner.join(ad)

Limit your collection with a timeout or take(n), because discoveries() keeps emitting.

If you want direct device-to-device links — nearby phones talking to each other with no server in the middle — use one of the peer-to-peer fabrics. They implement Loom like any other fabric, so replacing InMemoryLoom with one of these leaves your app code unchanged. Each uses the same instance for host and join, because every peer both advertises and looks for others (one in-process mesh).

  • kuilt-nw (iOS/macOS) — Apple's Network.framework. This is the Apple fabric to reach for. Every peer advertises, browses, and dials, and the redundant double-dial is folded into one link. It needs a Pattern.roomKey: the key becomes the shared secret that encrypts the link, and a session opened without one is refused.

  • kuilt-nearby (Android) — Google Nearby Connections.

  • kuilt-multipeer (iOS/macOS) — Apple Multipeer Connectivity. Superseded by kuilt-nw; prefer that for new code.

kuilt-webrtc (wasmJs) provides a WebRTC data-channel fabric. WebRTC sessions need signaling, but that stays inside the fabric implementation — callers only see Loom/Seam.

Writing your own fabric

When your transport is not packaged yet, implement Loom (and a private Seam) and prove it behaves like every other kuilt fabric by subclassing SeamConformanceSuite.

Why this matters: conformance tests keep your custom fabric from surprising the layers above it.

class MyFabricLoom : Loom { override suspend fun weave(rendezvous: Rendezvous): Seam = when (rendezvous) { is Rendezvous.New -> TODO("host") is Rendezvous.Existing -> TODO("join") } // Report what your fabric is and whether it can run right now. This one // method is the source of truth — `availability()` is derived from it, so // you never override `availability()` directly. override fun capability(): TransportCapability = TransportCapability( roles = setOf(TransportRole.Data), // what your transport does availability = if (myCapabilityPresent()) FabricAvailability.Available else FabricAvailability.Unavailable("my radio is off"), ) } // In commonTest — this is your contract test. Green means you conform. class MyFabricConformanceTest : SeamConformanceSuite() { override fun newLoomPair(): Pair<Loom, Loom> { val loom = MyFabricLoom() return loom to loom // same instance for in-process radio fabrics // role-split fabrics: return hostLoom to joinerLoom (distinct instances wired together) } }

newLoomPair() returns (hostLoom, joinerLoom). In-process radio fabrics return the same instance twice (shared mesh). Role-split fabrics (WebSocket, mDNS, WebRTC) return distinct host and joiner instances wired together. The suite runs host() and join() concurrently, which matters for WebSocket-style fabrics where host() suspends until a client connects.

The suite tests:

  • weave(Rendezvous.New(...)) returns a Seam with a non-empty selfId.

  • broadcast and sendTo deliver frames and stamp sender.

  • peers tracks membership.

  • incoming is single-collection and ordered.

  • close() is idempotent.

  • capability() (and the availability() it derives) returns sensibly.

Keep real-network smoke tests in a separate test that is opt-in (e.g. -Pmy.fabric.integration.tests=true) so the conformance suite stays fast and deterministic.

Tag and custom discovery

Tag is an open interface. Each fabric defines its own (WebSocketAdvertisement, MDNSAdvertisement, …). A custom fabric provides a Tag with whatever its join() call needs.

The membership layer (kuilt-session)

Seam is pure transport — peers reflects whoever the wire says is connected. When your product needs room semantics (identified members, host role, reconnect behavior), add kuilt-session.

SeamRoomFactory wraps any Loom and produces Rooms with an admit/identify handshake, a roster of admitted members, reconnect tokens, and partition detection:

val factory: RoomFactory = SeamRoomFactory(loom, scope) val room: Room = factory.host(Pattern(sessionName = "alice", maxPeers = 4)) scope.launch { room.roster.collect { members -> render(members) } } scope.launch { room.events.collect { event -> handle(event) } } scope.launch { room.incoming.collect { frame -> consume(frame.sender, frame.payload) } } room.broadcast("hello room".encodeToByteArray()) room.leave()

Because SeamRoomFactory accepts any Loom, the same code runs over InMemoryLoom in tests and over WebSocket or mDNS in production.

16 August 2026