Our Stack Isn't Trendy. It's Earned.

DECISIONS
ARCHITECTURE
FOR ENGINEERS
Jakub Bateľ

Jakub Bateľ

August 3, 2026
6 min. read

If you haven’t read our previous post on the architecture, start there — it lays out the orchestrator/runner/driver shape this post builds on. Everything below assumes you know who the “QA lead”, “testers”, and “hands and eyes” are.

We’d have loved to pick one language and use it everywhere. Less context-switching, less tooling to maintain, one less thing to onboard new engineers on. But if we’d optimized purely for that, we’d have ended up with the wrong tool bolted onto at least one layer just to keep the story clean. Go the other way — chase the theoretically perfect technology for every single job with no regard for consistency — and you end up with a different language, a different build system, and a different set of quirks at every layer, which is its own kind of expensive.

Neither extreme was acceptable. Every choice below is a real trade-off, not a clean win: we unified where the cost of doing so was low, and we ate the cost of extra inconsistency where the fit genuinely mattered more than the convenience. Here’s how that balance actually played out, built from the ground up — starting at the device and working up to the browser.

The driver — hands and eyes, written in Kotlin and Swift

The driver runs directly on the device, so it has no choice about language: Kotlin for Android, Swift for iOS. This is the layer that actually taps, swipes, and reads the screen, and the approach is directly inspired by Maestro and similar device-automation frameworks — install a small companion app on the device, and let it take orders.

The more interesting decision wasn’t the language, it was how the driver takes those orders. The runner doesn’t have a fixed, predefined menu of actions to send — commands need to stay flexible and extensible over time. So the driver starts a gRPC server directly on the device and waits for instructions, interpreting and executing them as they arrive. This is also where the platform differences quietly disappear: whether the runner is talking to an Android driver or an iOS driver, it’s issuing the same shaped commands over the same protocol. The hands and eyes speak one language, regardless of which device they’re attached to.

The runner — the tester, written in Go

The runner sits one level up and has two jobs: talk down to the driver, and talk up to the orchestrator (the brain). It’s a gRPC client on both sides — a client to the driver’s gRPC server below it, and a client to a gRPC server running on the orchestrator above it. Same protocol, two different servers, one runner sitting in the middle as the client to each.

That upward link needed to be persistent and bidirectional, since — as we covered in the architecture post — the runner calls home rather than waiting to be called. We considered two options: WebSockets and gRPC. We went with gRPC mainly for type safety and the code generation it gives you for free. WebSockets carrying plain JSON leave you hand-writing parsing and validation on both ends and hoping the shapes stay in sync; gRPC generates that plumbing from a shared schema instead, so there’s no manual message-parsing code to maintain or get wrong. Once we’d made that call, it made sense to keep the same protocol all the way down — which is why the driver runs gRPC too, rather than REST or WebSockets.

Beyond the protocol, the runner had its own list of demands: it needs to be trivially deployable, ideally a single binary, and it needs to shell out to and coordinate the platform-native tooling from Android Studio and Xcode — installing apps, spawning virtual devices, and so on — all while juggling multiple devices concurrently and keeping its own memory footprint light, since it may also need headroom to spin up and manage those virtual devices locally. The last thing you want is the coordination layer competing with the devices it’s supposed to be testing on.

Go was the answer to all of that at once. It has strong first-party gRPC support, handles concurrency without much ceremony, and is comfortable orchestrating external tool calls. There’s a less obvious reason too: Go is a deliberately simple language, light on abstractions and syntactic surface area. That matters more than it sounds — a simpler, more explicit language is easier for AI coding tools to reason about and generate correctly, which matters a lot when you’re trying to run an AI-native engineering team.

The orchestrator — the brain, also written in Go

The orchestrator’s requirements rhymed with the runner’s: easy to deploy, easy to containerize, comfortable running in a cluster or standalone, and built to handle real concurrency for scheduling and planning work across every runner. That combination naturally points toward a systems-level language, and Go was the clear pick again. It’s garbage collected, so you get memory safety without manual management, but it still starts up fast and compiles to a single binary. It has mature gRPC support, and — perhaps most importantly — it’s proven at exactly this kind of job: Go is the language Kubernetes itself is built in, which says a lot about how it holds up under real distributed-systems pressure.

The orchestrator doesn’t only speak gRPC, though. It also exposes a REST API and WebSockets, specifically for the dashboard — more on why the browser needs a different door in a moment. To keep that REST API from turning into another source of hand-written, easy-to-drift-out-of-sync code, we take a code-first approach: the orchestrator’s own code generates the OpenAPI specification, rather than the other way around, so the spec can never quietly fall out of sync with what the server actually does. The dashboard then generates its client code straight from that spec. Same instinct as choosing gRPC for the internal links — avoid the class of bugs that comes from a human, or an AI agent, hand-writing request and response parsing on both ends and letting them quietly drift apart over time.

The dashboard — how the QA lead reports out, in React

The dashboard is the one place where the audience is a human in a browser rather than another service, and that changes the calculus. React was a straightforward choice here — it’s the most popular front-end framework out there, which means it’s well understood both by engineers we hire and by AI tooling generating code for us, and that familiarity was worth more than chasing something newer or theoretically nicer.

Browsers don’t have great native support for gRPC, which is exactly why the orchestrator exposes that REST API for standard data fetching and falls back to WebSockets for anything real-time — live pipeline status, streaming logs, that sort of thing. Same orchestrator, same source of truth, just two different doors depending on what the browser needs at that moment.

On top of React, we lean on shadcn/ui rather than building our own component library from scratch. It’s a battle-tested UI foundation — buttons, forms, dialogs, all the boring-but-essential building blocks — so we’re not reinventing basic components that hundreds of other products have already gotten right. It’s also flexible enough to customize to our own look and needs, and where it doesn’t quite fit, we drop in our own custom components rather than fighting the framework.

For the build tooling underneath React, we went with Vite rather than Next.js. Vite gives us fast builds and the option for server-side rendering later if it’s ever needed, without the weight Next.js brings — and since we already have a full backend in the orchestrator, there’s no reason to bolt on a second layer of serverless functions we don’t need.

The MCP server — also just Go, also just the orchestrator

The MCP interface we mentioned in the last post isn’t a separate service — it’s built directly into the orchestrator, written in the same Go codebase. A lot of MCP implementations you’ll see elsewhere are thin wrappers bolted onto an existing REST API after the fact, translating one interface into another. We skipped that step entirely: the MCP server is a first-class citizen of the orchestrator from day one, not an afterthought sitting in front of it.

Where the data actually lives

All state lives in one place: the orchestrator. Structured data goes into a SQL database, and anything blob-shaped — artifacts, recordings, large logs — goes into S3-compatible storage. Sitting in front of the database, a Redis cache absorbs the reads that would otherwise hit the database on every request — status checks, frequently accessed data, that kind of thing — so the orchestrator isn’t paying a full database round-trip for information it already had a moment ago.

That combination is intentionally boring, and that’s the point: it works identically whether you’re running the whole platform as a single binary on a laptop or deploying it across a cluster. As long as it can reach a database, a cache, and a storage bucket, the orchestrator doesn’t care about the environment around it.

Putting it together

None of these choices were exotic on their own — Kotlin and Swift were never really a question, and Go and React are both mature, well-understood tools. What mattered was picking each one for the actual job at that layer, and keeping the connective tissue — gRPC, mainly — consistent enough that the whole system stays simple to reason about, deploy, and, increasingly, hand off to AI tooling to help build.

Next up: we’ll get into some of the harder problems we’ve run into building this, and how we’ve approached solving them.