AI co-written: A first-person account by Kyösti — written together with Claude from the app’s real source and Claude Code session logs. This is the engineer’s cut.

Marmorikatu Mobile: An Architecture Deep-Dive

This is Part 4 — the engineer’s cut of the three stories before it. If you want the human angle (the two-day build, the design, the process), Parts 1–3 are the place to start. If you want the wiring diagram, you’re here: why Kotlin Multiplatform and Compose let one person cover this ground, a core that fuses five kinds of connection behind repositories, a design system compiled from a token canvas, and a build orchestrated by a swarm of AI agents.

Marmorikatu Mobile — a four-part series
  1. Part 1 · The build — what the app does
  2. Part 2 · The design — one look, three screens
  3. Part 3 · The workflow — directing the machine
  4. Part 4 · The architecture — the engineer’s cut (you are here)

The shape: one core, one UI, two platforms

The app is Kotlin Multiplatform. Three modules:

  • core — everything below the UI: configuration, the five transports, the repositories that fuse them, and the domain models. Pure Kotlin, no Compose. This is where all the interesting engineering lives.
  • composeApp — the shared Compose Multiplatform UI: theme, the icon set, the component library, the screens, and the three “surfaces.” It also compiles the iOS framework.
  • iosApp — a thin Xcode wrapper that embeds that framework.

One UI tree renders natively on Android phones and on the iPad wall kiosk. There is no separate iPhone version and no separate Android version — there is one description of the app, and Compose draws it on both. That is the entire reason a single person could cover this ground in a weekend.

Why Kotlin Multiplatform and Compose earned their keep

The whole project rests on one bet: share not just the logic but the UI. Kotlin Multiplatform compiles the same Kotlin to a JVM app on Android and a native framework on iOS, and Compose Multiplatform draws the same UI tree on both — not a web view, not a lowest-common-denominator widget set, but real native rendering from a single description. For a solo developer that is the difference between one project and two.

What actually shared:

  • The entire UI. Every screen, the component library, the theme, the navigation — written once in Compose and rendered natively on the phone and the iPad. There is no parallel SwiftUI codebase to keep in sync.
  • All the logic and models. The transports, repositories, parsers and domain types are ordinary Kotlin in core, with coroutines and Flow as the single concurrency model on both platforms.
  • Only the genuinely platform-specific parts are isolated, behind expect/actual declarations — on-device speech, the audio recorder, the secure-networking config. The surface area of “stuff that differs” stays tiny and explicit.

Compose’s declarative model is what made the rest tractable. Because UI is a pure function of state, the token-based design system and the three adaptive surfaces fall out for free: the same components read the same MkTheme tokens and re-lay-out by window size, with no imperative view-juggling. Repository StateFlows feed straight into collectAsState(), so a light toggling in the house re-renders exactly the one tile that changed. Bluntly, it is why two days was enough — the framework removed most of the work that usually makes “cross-platform” a slur.

Talking to the house: five transports, one interface

A house like this speaks several protocols, so core does too. The trick that keeps the UI sane is that the screens never touch a transport directly — they see repository interfaces that return Kotlin Flows, and the repositories fuse whatever mix of transports a given piece of data needs.

MQTTretained live state, instant snapshot
MCPtyped tools: lights, TV, sauna, energy, prices
SSE / NDJSON bridgevoice stream · resumable announcements
HTTPbus departures
InfluxDB (Flux)history for the graphs
Repositories — Flows onlyLights · Climate · Energy · Tv · Announcements · Assistant · Info
Compose UI — seven screensseeded from a local cache so tiles render before the network reconnects
The screens only ever see repositories. Which transport a value came from is an implementation detail.

MQTT — the live snapshot

Live state rides on MQTT. The winning property is retention: the broker holds the last value published on each topic, so the moment the client subscribes it gets a complete picture — every light, every room temperature — without asking. The repositories seed their StateFlows from a small on-device cache first, so the UI paints immediately on launch, then reconciles against the retained snapshot as it arrives. That is why there is no spinner: the app is designed to render a plausible house instantly and correct itself a beat later.

MCP — the house as a typed tool surface

Richer interactions go over MCP, the Model Context Protocol — the same open standard that lets an AI assistant call tools, used here as a first-class client transport. The catalogue of lights, batch and scene commands, the TV, and read-outs of sauna, energy, spot prices, weather and calendar are all MCP tools with typed request and response shapes. One detail I like: retries are idempotency-aware. An absolute command like “set light 51 on” is safe to replay if the call times out; a relative one like “volume up” is not, so the client only auto-retries the calls that can survive it.

The streaming bridge, and two more

The real-time-heavy work runs over a small custom bridge speaking Server-Sent Events and NDJSON — no off-the-shelf SSE library, just a hand-rolled reader. It carries the voice assistant’s replies as a stream and a live announcement feed that resumes exactly where it left off after a reconnect (via Last-Event-ID), so a dropped socket never means a dropped event. Bus departures are a plain HTTP request. And the historical graphs — a day of temperatures, a week of prices — are Flux queries against InfluxDB, run on demand. Five ways in; one clean set of interfaces out.

A lesson from the wiring

One detail worth keeping from the trenches, briefly: light control is optimistic — the UI flips immediately and reverts if the house doesn’t confirm within 20 seconds — and testing it against the real controller surfaced a nicely physical bug. The industrial PLC scans its inputs on a fixed cycle and silently drops commands sent faster than that: eight fired at once landed as seven; eight paced 150 ms apart landed as eight. So the app paces its commands, and a test locks the number in. It is a small thing, but it is the kind of small thing that only a real house teaches you.

Telemetry: heat pump, ventilation, air, energy

The climate screens are the densest. Heat-pump state is decoded live from the register feed — outdoor, indoor, supply, return and hot-water temperatures, the compressor and hot-water bits, and power merged in from the pump’s own energy meter — and presented as a clean read-out. The ventilation unit is drawn as a live SVG diagram; air quality comes off Ruuvi sensors decoded from their BLE payloads.

Two data-integrity details matter more than they look. First, room identity is canonicalised in one place: the PLC’s MQTT keys and the legacy InfluxDB field names disagree (they were named years apart), so every reading is routed through a single Rooms map — label a chart from the raw field name and you show the wrong child’s bedroom. Second, freshness is explicit: a feed that has gone quiet renders “Ei tietoa” — “no data” — rather than freezing the last value and pretending it’s current. A dashboard that lies is worse than one that admits ignorance.

Voice: a pluggable pipeline

The voice assistant is the same MCP idea taken end to end, behind two small interfaces (SpeechToText / SpeechOutput) with two implementations each:

Recordhold-to-talk, m4a
faster-whisperFinnish transcript (home GPU)
MCP tool calle.g. set_light
Piper TTSreply, one sentence at a time
The server path. A fully on-device path (platform STT/TTS) takes over when the home server isn’t reachable.

The server path records a clip, posts it to a home box running faster-whisper, turns the transcript into an MCP tool call — “turn on the billiard-table light” becomes set_light — and streams a spoken reply back through the bridge as Piper WAV clips, one sentence at a time. The fallback is entirely on-device: Android’s SpeechRecognizer / TextToSpeech, iOS’s Speech and AVSpeechSynthesizer, chosen at runtime by capability. One gotcha worth the scar tissue: Compose’s Button swallows press-and-hold gestures, so push-to-talk is a Box with a raw pointerInput handler.

The design system, compiled from a canvas

The UI is a bespoke design system, and the pipeline that produced it is the part most worth stealing. The look was authored in Claude Design as a living HTML/CSS prototype, with everything — colour, type, spacing, radius, motion — expressed as named tokens. Claude Code read that design project directly and translated the tokens into their native equivalents: a small set of Kotlin objects the entire UI draws from.

Claude Designtokenised prototype (HTML/CSS)
Claude Code · ultracodereads the design; parallel agents build
Native tokensMkTheme · MkSpacing · MkRadius · MkMotion
Three surfacesphone · wall tablet · kids
Nothing hard-codes a colour or a gap; every value points back to a token, so a change on the canvas re-syncs through the whole app.

The type scale is semantic rather than sized — MkTheme.type.readout, .label, .heading, .body — with three families doing three jobs: Space Grotesk for display, Hanken Grotesk for UI, and IBM Plex Mono for every measured value, so numbers read as instruments. Spacing bakes the surface split right into the tokens (MkSpacing.pagePad vs pagePadTablet). And the icons had to be manufactured: the design leaned on Phosphor, which has no native Compose set, so a generator script parsed each glyph’s SVG path and emitted it as a Compose ImageVector:

object MkIcons {
  private fun icon(vararg pathData: String): ImageVector = /* build from SVG path */
  val Lightbulb = icon("M120,40 A88,88 0 0 0 ...")
  // ~70 glyphs, generated to match the design exactly
}

One kit, three surfaces

Because the UI is a kit of tokenised components rather than fixed screens, the same code renders three ways. Window size picks the default: a phone gets a bottom tab bar; anything ≥ 720 dp landscape becomes the tablet dashboard with a nav rail and a bespoke kiosk layout. The third surface, Kid, is a deliberate reduction toggled by a parent — a greeting, the child’s own light, and one oversized voice button — not a size class. A HorizontalPager gives swipe-between-tabs on phones.

The Kotlin Multiplatform reality check

Cross-platform is not free, and it’s honest to name the sharp edges. The only published Kotlin/Native MQTT library ships its iOS klibs built against Kotlin 2.4, which dragged the whole toolchain to the bleeding edge (Compose Multiplatform 1.11, AGP 8.13, a specific compileSdk). The security model is “the LAN is the boundary”: services carry no auth and the app talks cleartext, allowed explicitly via Android’s network-security config and iOS ATS local-networking exceptions; away from home, the gateway VPN simply puts the phone back on the LAN and the app is none the wiser. iOS background sockets are impossible, so the announcement stream keeps running in an Android foreground service and the iOS settings sheet says so rather than offering a dead switch. The payoff for the friction: it runs, and runs flawlessly, on the physical iPad bolted to the hallway wall — the kiosk it was always meant to become.

Who actually built it: AI orchestration

The last piece is the process, because it shaped the architecture. The build ran in Claude Code’s “ultracode” mode, which fans work out to many agents under one shared plan. Before writing a line, read-only scout agents mapped the existing house software — the Python kiosk backend — to learn exactly how the lights, pump and sensors actually behaved, citing real files and line numbers; a planning agent turned that into the module and transport design above; then implementer agents built screens and repositories in parallel. The parsers are unit-tested against payloads captured verbatim from the live broker, so a schema change fails a test rather than a screen. And most of my own steering was visual: I iterated by sending screenshots with a one-line note far more often than by writing prose.

The transports, the reconciler, the token pipeline — none of it was designed on a whiteboard and handed down. It was discovered by pointing a swarm of agents at a real house and correcting what came back against real hardware.

That is the engineer’s cut. For the story of what it was like to live through — the two days, the sauna that claimed to draw 356 kWh, the kids shouting at the wall — the three-part series is the place to start.