mob
BEAM on device·Native UI·Agent-drivable

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.

Get started See the agent loop
v0.8 · early days · confirmed on simulator, emulator, and real iOS + Android devices
SCROLL

Elixir + BEAM + Native iOS / Android UI

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.

An inside job

Hand it to an agent. Watch it drive.

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.

iex — attached to the phone over dist
# 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.

The harness refuses to lie to your agent

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.

And it tells you where it can't help

Coverage is not uniform, so the framework publishes the gaps rather than burying them. The short version:

HelperiOSAndroid
screen/1, assigns/1
tap/2 by tag
navigation, send_message/2
view_tree/1 native framesshallowapps 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.

Other things worth knowing before you start

  • Mob is v0.8 and pre-1.0. Breaking changes land in minor versions, and the surface above will move.
  • Since 0.8 the app can also say what happened, not only what is on screen: every action gets a receipt naming the stage it reached, the framework runs its own invariants, defects land on a bus as fingerprinted capsules, and post-mortems (BEAM crash dumps, iOS MetricKit, Android exit reasons) can be swept onto the same bus. The reference below lists all of it.
  • 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.
  • The agentic coding guide is in two parts, one agent and a fleet, because they turned out to be different disciplines. If you plan to run more than one at a time, the second part is the one that will save you.

This part is written for the agents

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.

Attach
mix mob.connect · node = :"[email protected]"
Read state
screen/1 · assigns/1 · tree/1 · find/2 · inspect/1
assigns/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).
Measure
element_frames/1 · frame/2 · screen_info/1 · scroll_info/2 · screenshot/1..2 · screenshot_tour/2..3
Native views and painted pixelspixels iOS only
view_tree/1 · view_tree_flat/1 · find_view/2 · sample_color/2 · color_census/1 · paint_debug/1 · capabilities/1..2
view_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.
Drive
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..4
On Android the synthetic input lives in the app's generated bridge: it works in apps from mob_new 0.4.32 or newer, type_text/2 is ASCII only, and clear_text/1 is not implemented there.
Navigate
navigate/2..3 · pop/1 · pop_to/2 · pop_to_root/1 · reset_to/2..3 · switch_tab/2..3 · back/1
Accessibility pathiOS, needs an active AX client
ui_tree/1 · ax_action/3 · ax_action_at_xy/4 · toggle/2 · dismiss_alert/2 · adjust_slider/3..4
"Active AX client" means something is asking iOS for the accessibility tree so SwiftUI materialises it. Today that means the VoiceOver toggle.
Simulate hardware
send_message/2 with {:permission, :camera, :granted} · {:location, %{lat: .., lon: ..}} · {:camera, :photo, %{path: ..}} · {:notification, %{..}}
Delivers the term straight to the screen's handle_info/2, so device-API results can be exercised without the hardware.
Synchronise
settle/1..2 · wait_for/2..3 · wait_for_text/2..3
Navigation calls block until the re-render lands. tap/2, back/1 and send_message/2 return immediately, so pair them with settle/1 before reading state back.
Change the code in the running app
mix mob.push · nl(MyApp.Screen) · mix mob.watch
Loads changed BEAMs over RPC. No restart, no state loss.
What the last action did, not just what is on screen
Mob.Agent.Receipts.recent/1 · fetch/1 · count/0 · dropped/0 · Mob.Agent.Receipt
Every dispatched event gets a receipt recording the stages it reached: dispatched, handled, assigns changed, navigation requested, frame changed, handed to the native side. The first stage missing names the layer to look at. The assigns and frame stages come from before-and-after comparison, so a handler cannot claim a change it did not make; a receipt says what the BEAM did, not that the pixels changed. Errors are redacted and assigns are never carried, so a receipt is safe to hand to a triage tool. The table keeps the last 256; read it over :rpc.
The framework checking itself
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..1
Invariants are checks an app cannot make about itself (a live component under a dead screen, a dead screen still in the navigation stack), recorded only when at least 50 ms old and still true at the next sample. Confirmed violations, cross-platform divergences and post-mortems become fingerprinted, deduplicated capsules on the defect bus. sweep 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.
One design, both platforms
Mob.Differential.compare/2..3 · MobDev.Differential.run/2..3
Two view_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.
Timing, measured
Mob.RenderStats.enable/0 · summary/0 · native_enable/0 · native_summary/0
Per-frame timing of the BEAM half by stage, and of the native apply split by transition so a re-render is not pooled with a push. Native timing needs a debug iOS build, or an Android app generated by mob_new 0.4.32 or newer. A before-and-after tool for one platform, not a cross-platform comparison.
Proving the push landed
mix mob.attest · mix mob.deploy --json · mix mob.mutate · mix mob.flake · mix mob.connect --name · mix mob.routes --strict
attest 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.
Fails honestly instead of guessing
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.
Still needs tooling outside the wire
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.

Reads the real state

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.

Drives the real UI

Tap by intent, navigate, type, scroll to a spot. The agent fires the same events a finger would, straight at the running screen.

Sees without leaving the wire

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.

Read the agentic coding guide
A screen is a GenServer

You write Elixir. The phone renders it natively.

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.

counter_screen.ex
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
9:41
Counter
0
taps so far
Home
Profile

Rendered by SwiftUI · driven by a GenServer · [email protected]

How it works

From your supervision tree to native pixels.

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.

01

Your Elixir app

GenServers, OTP supervision, Ecto, pattern matching, whatever you'd normally reach for.

OTP supervision tree
02

Mob.Screen, a GenServer

Your UI module. Holds state, handles events, returns a view tree from render/1.

component tree
03

Mob.Renderer

Serialises the tree, resolves theme tokens, and crosses the NIF boundary as JSON.

set_root NIF call
04

SwiftUI

iOS · native gestures, dark mode, accessibility

04

Compose

Android · native gestures, dark mode, accessibility

Three packages, one workflow:

mob

The framework you add to your app: screens, the renderer, themes, and the native bridge.

hexdocs ↗
mob_new

The project generator. mix mob.new my_app scaffolds a ready-to-run app.

hexdocs ↗
mob_dev

The dev tooling, a dev-only dep. Build, deploy, connect IEx, and hot-push to the device.

hexdocs ↗
The rackets

Everything the BEAM does. In your pocket.

Full native performance in the UI, full BEAM abilities on the logic side. Nothing crippled to fit a corporate policy.

Hot code reload

Connect over standard Erlang distribution and nl(MyApp.Screen): new bytecode, no restart. Not a proprietary inspector. Just OTP.

Offline by default

The BEAM runs on the device. No server required. Zero latency for UI, works on the subway, ships as a self-contained installable app.

Real native UI

Components render as SwiftUI and Compose primitives. Animations, accessibility, platform gestures and dark mode all work. The native layer handles them.

IEx into your phone

mix mob.connect tunnels in from your Mac. Then :sys.get_state, :observer and tracing all work against a running app.

The full power of the BEAM

Distribution. Tuning flags. Hot upgrades. It's all exposed. You decide what's right for your app, not the framework.

Themes that swap live

Obsidian, Citrus, Birch, or roll your own tokens. Switch at runtime for accessibility or user preference with Mob.Theme.set/1.

Hired guns

If the phone can do it, you can build it.

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.

Just a dependency

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 feature, not a function

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.

Signed, deny by default

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.

Where it sits

An unusual position in the landscape.

Mob borrows a little from everything that came before, and lands somewhere new: the runtime on the device, the UI native, the language singular.

LiveView Native

  • Server-driven; the BEAM lives in the cloud
  • Needs a connection for UI logic
  • Pioneered the three-letter sigil ❤

React Native / Flutter

  • JS or Dart over a bridge
  • A second language and toolchain
  • No OTP, no supervision trees

Native (Swift + Kotlin)

  • Two codebases, two languages
  • Best tooling, biggest investment
  • Backend devs start from zero

Image Mob

  • BEAM on the device, no server
  • Native SwiftUI & Compose views
  • One language: Elixir, all the way down
Mob is my one good album.
the author, on years of ruminating before writing a line
Dressed the same, both sides of town

70+ components on the web. A growing set native on Mob.

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.

Mishka Chelekom component grid, Light theme, on Android

Light

Mishka Chelekom component grid, Dark theme, on Android

Dark

Mishka Chelekom component grid, Material 3 theme, on Android

Material 3

Mishka Chelekom component grid, Glass theme, on Android

Glass

Mishka Chelekom Avatar component detail page, on Android

Component detail

Explore Mishka Chelekom Browse the Mob port source
Named themes

Pick a look. Or invent one.

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.

Obsidian

Mob.Theme.Obsidian, dark violet

use Mob.App, theme: Obsidian

Citrus

Mob.Theme.Citrus, warm charcoal + lime

use Mob.App, theme: Citrus

Birch

Mob.Theme.Birch, warm parchment

use Mob.App, theme: Birch
Quickstart

Four commands to a running app.

Generate a project, drop into the simulator, and start hot-reloading from IEx. The dev dashboard runs in your browser.

~/code
$ mix archive.install hex mob_new
$ mix mob.new my_app
creating my_app/lib/my_app/counter_screen.ex
ready: BEAM bundled, native shells generated
$ cd my_app
$ mix mob.deploy --ios
booting simulator · starting node [email protected]
live · run mix mob.connect to attach IEx

Then never restart again.

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.

Read the getting started guide
Early development · v0.8

Come build it with us.

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.