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 = :"my_app_ios_1a2b3c4d@127.0.0.1" 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 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. Since mob_new 0.6 the components come from the mob_mishka plugin instead of being copied into your project.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.9.7, mob_dev 0.7.5 and mob_new 0.6.2. Where something works on one platform only, it says so.
mix mob.connect · mix mob.connect --no-restart · node = :"my_app_ios_1a2b3c4d@127.0.0.1"--no-restart attaches to the app as it is instead of restarting it, so a live session can be inspected. Node names carry a per-device suffix and each app's dist port is derived from the device and the app, so two Mob apps on one phone no longer collide; read the names mix mob.connect prints rather than hard-coding one.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.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:undeliverable. 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.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 · Mob.Diag.health/0sweep collects BEAM crash dumps, iOS MetricKit diagnostics and Android ApplicationExitInfo (crashes, ANRs, OOM kills); nothing runs until you call it. An Android exit record or MetricKit payload, which the OS hands over only once, is journaled on the device and emitted again, once per boot, until a bus subscriber or recent/1 has seen 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.Diag.health/0 says whether these stores are working themselves (owners, lost writes, resets), so an empty recent/1 or classes/1 means something only once it reports the store healthy.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 and the screen process gets a plain message, {:tap, :increment}, in handle_info/2, like any GenServer.
defmodule MyApp.CounterScreen do use Mob.Screen def mount(_params, _session, socket) do {:ok, Mob.Socket.assign(socket, :count, 0)} end def render(assigns) do tap = {self(), :increment} ~MOB""" <Column padding={:space_md} gap={:space_md}> <Text text={"#{@count}"} text_size={:xl} /> <Button id="add_one" text="Add one" on_tap={tap} /> </Column> """ end # fires on tap, pure Elixir, on the device def handle_info({:tap, :increment}, socket) do {:noreply, Mob.Socket.update(socket, :count, &(&1 + 1))} end end
Rendered by SwiftUI · driven by a GenServer · my_app_ios@127.0.0.1
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:
The framework you add to your app: screens, the renderer, theme tokens, the plugin host, and the native bridge.
hexdocs ↗ · GitHub ↗Two more to ship new code without a store release:
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 and more from mob_themes, 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. Since 0.9 it can bring its own supervision tree, started on the device at boot, and hook navigation through Mob.Router.Hooks. 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.
First-party plugins on Hex cover the camera, photos, location, biometrics, Bluetooth, NFC, notifications, background work and more. The newest, mob_deliver, ships signed over-the-air updates and fetches screens the first time they are opened, built on those same hooks.
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. On Mob they come as the mob_mishka plugin. 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. Start from a mob_themes look (apps from mix mob.new include it), override a couple of tokens, or build one from scratch. Tap a card to preview it on this page.
Generate a project, fetch the on-device runtime, build onto a booted 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. After the first --native build, a plain mix mob.deploy pushes only the changed BEAMs.
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.
Want to ship new screens to installed apps without a store update? mix mob.new my_app --deliver wires in mob_deliver and a signing key, and mix mob_deliver.publish signs what is in mobile/ for your server to hand out.
Confirmed on the iOS simulator, the Android emulator, and real iOS and Android devices. The framework is moving fast: 0.9 lets plugins start their own supervision trees and hook navigation, gets your config/*.exs onto the device, and brings signed over-the-air updates with mob_deliver. There is plenty of room for early hands. Feedback welcome.