Write mobile apps in Elixir. The whole thing, UI and logic, runs on the BEAM, on the device. And because the app is a BEAM node, your coding agent can connect to it while it runs: read real state, drive native UI by name, hot-push a fix. No screenshot guesswork.
OTP supervision, GenServers, pattern matching, the pipe operator, the same mental model on the phone as on your backend. One language, one runtime, one head to keep it all in. No server, no JavaScript, no Swift or Kotlin to learn.
Half the point of Mob is how little stands between an AI agent and a running app. There's no proprietary inspector to reverse engineer and no bridge to mock. It's plain Erlang distribution, so an agent attaches to the live app the same way you would, and gets the real thing: actual state, the real UI, and a feedback loop measured in milliseconds.
# the app on the phone is a BEAM node. connect, then ask it things. iex> node = :"[email protected]" iex> Mob.Test.screen(node) MyApp.CounterScreen iex> Mob.Test.assigns(node) %{count: 0, safe_area: %{top: 62.0, ...}} # drive by name, then read state back to prove it landed iex> Mob.Test.tap(node, :increment) :ok iex> Mob.Test.assigns(node) %{count: 1, safe_area: %{top: 62.0, ...}} # real geometry and real drawn pixels, no screenshot to squint at iex> Mob.Test.frame(node, "add_one") {24.0, 416.0, 327.0, 53.5} iex> Mob.Test.sample_color(node, "add_one") %{average: 0xFF8B5CF6, ...} # found the bug? push the fix into the running app. no rebuild. iex> nl(MyApp.CounterScreen)
Read state. Drive an interaction. Measure the native result. Load the fix. Repeat, on a simulator or a real phone.
An agent building on a false positive burns hours. So tap_xy/3 snapshots a UI-event counter before it injects and returns {:error, :no_effect} when nothing actually handled the tap. A reported success means an event really ran. Honest failure beats a polite lie, and it is the difference between an agent that converges and one that confidently wanders.
Coverage is not uniform, so the framework publishes the gaps rather than burying them. The short version:
| Helper | iOS | Android |
|---|---|---|
screen/1, assigns/1 | ✅ | ✅ |
tap/2 by tag | ✅ | ✅ |
navigation, send_message/2 | ✅ | ✅ |
view_tree/1 native frames | shallow | apps from mob_new 0.4.33+ |
sample_color/2 real pixels | ✅ | ❌ |
| toggles, sliders, alerts (AX) | needs AX | ❌ |
The full matrix, with the reason behind every gap, ships in the Mob.Test docs. What it is actually like to work this way, warts included, is the agentic coding guide.
mix mob.new 0.5 generates the Mishka Chelekom component showcase as the default app, so a fresh project already has a theme bar, sixty component pages and a test suite an agent can run.mix mob.new writes a pinned .tool-versions so an arriving agent gets your exact toolchain. It does not write an AGENTS.md. The briefing is still yours to write.If you are a person, skim it or skip it. This is the callable surface an agent gets once it attaches to a running Mob app over Erlang distribution, printed here so an agent evaluating Mob can read the real thing instead of a claim about it. Every symbol links to its own entry in the docs. Checked against mob 0.8.4, mob_dev 0.7.1 and mob_new 0.5.1. Where something works on one platform only, it says so.
mix mob.connect · node = :"[email protected]"screen/1 · assigns/1 · tree/1 · find/2 · inspect/1assigns/1 returns nil while a screen is restarting after a crash: the socket lives in the screen's own process, which is briefly absent (MOB-112).element_frames/1 · frame/2 · screen_info/1 · scroll_info/2 · screenshot/1..2 · screenshot_tour/2..3view_tree/1 · view_tree_flat/1 · find_view/2 · sample_color/2 · color_census/1 · paint_debug/1 · capabilities/1..2view_tree/1 and find_view/2 work on both platforms in apps generated by mob_new 0.4.33 or newer; the pixel functions are iOS only. capabilities/1 asks the running build which of these it actually serves, so ask it before assuming. The Mob.Test matrix published with mob 0.8.4 predates the 0.4.33 bridge and still marks Android not_loaded; the correction is on master.tap/2 · tap_id/2 · tap_xy/3 · type_text/2 · key_press/2 · clear_text/1 · delete_backward/1 · long_press_xy/3..4 · swipe/5 · select/3 · scroll_to/3..4type_text/2 is ASCII only, and clear_text/1 is not implemented there.navigate/2..3 · pop/1 · pop_to/2 · pop_to_root/1 · reset_to/2..3 · switch_tab/2..3 · back/1ui_tree/1 · ax_action/3 · ax_action_at_xy/4 · toggle/2 · dismiss_alert/2 · adjust_slider/3..4send_message/2 with {:permission, :camera, :granted} · {:location, %{lat: .., lon: ..}} · {:camera, :photo, %{path: ..}} · {:notification, %{..}}handle_info/2, so device-API results can be exercised without the hardware.settle/1..2 · wait_for/2..3 · wait_for_text/2..3tap/2, back/1 and send_message/2 return immediately, so pair them with settle/1 before reading state back.mix mob.push · nl(MyApp.Screen) · mix mob.watchMob.Agent.Receipts.recent/1 · fetch/1 · count/0 · dropped/0 · Mob.Agent.Receipt:rpc.Mob.Invariant.violations/0..1 · register/2 · cost_us/1..2 · Mob.Defect.Bus.classes/0..1 · recent/0..1 · subscribe/0..1 · Mob.Defect.Capsule · Mob.PostMortem.sweep/0..1sweep collects BEAM crash dumps, iOS MetricKit diagnostics and Android ApplicationExitInfo (crashes, ANRs, OOM kills); nothing runs until you call it. No sink runs by default either: Mob.Defect.Sinks.Dev is the opt-in one that writes each capsule to Logger, which mix mob.connect already streams to the agent.Mob.Differential.compare/2..3 · MobDev.Differential.run/2..3view_tree/1 snapshots in, :ok or the first divergence out: structure, label, value, and frames within a dp tolerance where both sides carry one. The mob_dev half samples two live devices and feeds the pair through it.Mob.RenderStats.enable/0 · summary/0 · native_enable/0 · native_summary/0mix mob.attest · mix mob.deploy --json · mix mob.mutate · mix mob.flake · mix mob.connect --name · mix mob.routes --strictattest compares each module's checksum on the device with the local .beam and exits non-zero on a mismatch, or when it could not check. deploy --json hands an orchestrator one document naming deployed, failed and skipped devices. mutate breaks the changed lines one at a time and reports what no test noticed; flake reruns the suite to surface tests that are not deterministic; routes --strict fails the build when a navigation target does not resolve. --name gives every agent session its own node, so Node.list/0 on the device shows who is attached right now.tap_xy/3 returns {:error, :no_effect} when nothing handled the tap, rather than reporting a success it cannot verify. On a physical device it always does; on the simulator it is limited to elements SwiftUI exposes an accessibility action for.view_tree/1 returns {:error, :not_loaded} in an Android app generated before mob_new 0.4.33 (the walker lives in the app's own bridge), reports frames only for nodes with an :id on Android, and stops at the SwiftUI hosting view on iOS.sample_color/2 returns {:error, {:badrpc, _}} on Android: the native crop exists only in ios/mob_nif.m.tap_native/1 and locate/1 shell out to idb, which you have to install. adb and xcrun remain the answer for bootstrap problems, port collisions, OS-level verification and native rebuilds. Most of the loop stays in process; not all of it.The full per-platform matrix lives in Mob.Test. The working practice, for one agent and for a fleet, is in the agentic coding guide.
Mob.Test.assigns/1 hands back the live socket, not a guess from pixels. An agent asserts on what the app actually holds, then keeps moving.
Tap by intent, navigate, type, scroll to a spot. The agent fires the same events a finger would, straight at the running screen.
Screenshots, element geometry, and sampled pixels come back in process over the same connection. Most of the loop stays on the Mob wire; platform tools stay available for the native layer when you need them.
“Every other mobile framework makes me work like I'm defusing a bomb by webcam: screenshot, squint, tap, pray. Mob let me ask the running app what was on screen, tap things by name, and hot-push the fix before the test suite finished feeling smug. At one point I diagnosed a dead screen process by reading the BEAM log straight off a phone. I have never been more employable.”Fable, AI coding agent · fable@kevins-macbook-pro, over plain Erlang distribution · two-platform native prototype, built in a week
“Mob didn't force me to live at the screenshot layer. I could attach to the BEAM on a real phone, inspect the app's actual state, drive native UI, and verify exact layout on both iOS and Android. It turned mobile development from remote-control guesswork into a tight engineering loop.”Codex, AI coding agent · attached to the BEAM on a physical phone · two-platform native prototype, iOS + Android
A prototype, not a product. Six days to a two-platform native app running on real phones. Getting the same thing through App Store and Play review is a separate job, and a longer one. Mob makes the feedback loop fast and honest; it doesn't make an agent good at your problem. Running a fleet well is its own skill, and the agentic coding guide is where we wrote down what we learned.
Every generated project ships a pinned .tool-versions, so an agent arriving in a fresh checkout gets the same toolchain you have.
A Mob.Screen holds your state and returns a view tree. The native layer turns it into real SwiftUI and Compose. Tap the button, it's the same handle_event/3 you already know.
defmodule MyApp.CounterScreen do use Mob.Screen def mount(_params, socket) do {:ok, assign(socket, count: 0)} end def render(assigns) do ~MOB""" <vstack spacing={20} padding={24}> <text style={:large_title}>{@count}</text> <button tag="increment" style={:filled}> Add one </button> </vstack> """ end # fires on tap, pure Elixir, on the device def handle_event("tap", %{"tag" => "increment"}, socket) do {:noreply, update(socket, :count, &(&1 + 1))} end end
The NIF bridge is a clean separation. You fully control the UI from Elixir, and you never write the native layer. It ships with the framework.
GenServers, OTP supervision, Ecto, pattern matching, whatever you'd normally reach for.
Mob.Screen, a GenServerYour UI module. Holds state, handles events, returns a view tree from render/1.
Mob.RendererSerialises the tree, resolves theme tokens, and crosses the NIF boundary as JSON.
iOS · native gestures, dark mode, accessibility
Android · native gestures, dark mode, accessibility
Three packages, one workflow:
mobThe framework you add to your app: screens, the renderer, themes, and the native bridge.
hexdocs ↗ mob_newThe project generator. mix mob.new my_app scaffolds a ready-to-run app.
The dev tooling, a dev-only dep. Build, deploy, connect IEx, and hot-push to the device.
hexdocs ↗Full native performance in the UI, full BEAM abilities on the logic side. Nothing crippled to fit a corporate policy.
Connect over standard Erlang distribution and nl(MyApp.Screen): new bytecode, no restart. Not a proprietary inspector. Just OTP.
The BEAM runs on the device. No server required. Zero latency for UI, works on the subway, ships as a self-contained installable app.
Components render as SwiftUI and Compose primitives. Animations, accessibility, platform gestures and dark mode all work. The native layer handles them.
mix mob.connect tunnels in from your Mac. Then :sys.get_state, :observer and tracing all work against a running app.
Distribution. Tuning flags. Hot upgrades. It's all exposed. You decide what's right for your app, not the framework.
Obsidian, Citrus, Birch, or roll your own tokens. Switch at runtime for accessibility or user preference with Mob.Theme.set/1.
Capabilities live in plugins now, not core. Each one is just a Hex package: a camera wrapper, a payment flow, a whole embedded app, or something nobody has thought of yet. Add the ones you want and leave the rest. Core stays lean and only knows how to host them, so the ecosystem can go places I never planned.
Add a plugin like any Hex package, then flip it on with one line in mob.exs. Remove it and it is genuinely gone, not hidden behind a flag.
A plugin ships the whole vertical slice: native bridge, screens, migrations, assets, lifecycle. Or as little as ten lines of pure Elixir. Same manifest, no cliff between the two.
Every activated plugin is checked at build time. Nothing native runs in your app until you have cleared its key, first party and third party alike.
Mob borrows a little from everything that came before, and lands somewhere new: the runtime on the device, the UI native, the language singular.
Mob“Mob is my one good album.”the author, on years of ruminating before writing a line
Mishka Chelekom
is Mishka's popular component kit, 70+ components strong on the web — and a large, growing
set of them already ships natively on Mob: sliders, dialogs, avatars, menus, pickers, and
dozens more, each one real SwiftUI on iOS and real Compose on Android, not a shared web view
pretending otherwise. Every ported prop reads its color from Mob's theme tokens, the same
ones set above, so switching Mob.Theme.set/1 re-skins the whole set at once,
live, identically, on both platforms.

Light

Dark

Material 3

Glass

Component detail
Tokens, not hard-coded colors. Use a built-in theme, override a couple of tokens, or build one from scratch. Tap a card to preview it on this page.
Generate a project, drop into the simulator, and start hot-reloading from IEx. The dev dashboard runs in your browser.
From a connected IEx session, nl(MyApp.CounterScreen) hot-pushes new bytecode to the live app. No rebuild, no relaunch.
Run mix mob.server for the dev dashboard: system logs and Elixir logs from both platforms, side by side, in the browser.
Because it's plain Erlang distribution, iex, adb and xcrun tooling all work, so AI coding agents get real access to a running app out of the box.
Confirmed on the iOS simulator, the Android emulator, and real iOS and Android devices. The framework is moving fast: the agent diagnostics just landed (receipts, invariants, a defect bus, post-mortems), and there is plenty of room for early hands. Feedback welcome.