kuilt Help

Connections

Through a server or straight to a nearby phone: peers need a path to each other. kuilt calls each way of connecting a fabric.

Pick the fabric that fits your devices. Each gives your app the same way to send and receive, so the code that uses the link stays put.

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. mdnsLoom(…) builds a loom that 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() val serviceType = MDNSServiceType("_myapp._tcp") // Host: registers the mDNS service and starts the WebSocket server. val host = mdnsLoom(serviceType, application, jmdns, port = 8080) { HttpClient { } } val hostSeam = host.host(Pattern("alice's game")) // Joiner: discover then join. val joiner = mdnsLoom(serviceType, application, jmdns, port = 8080) { 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. → Nearby Apple devices

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

02 October 2026