You're in the mob repo, the runtime library for the Mob mobile framework. Read this in full before making changes — it's the 5-minute orientation that will keep you from re-deriving things the rest of the team has already learned (or learned the hard way).
Mob lets you write iOS and Android apps in Elixir, with the BEAM running
on-device. The phone hosts an Erlang node — a real one, distribution-capable,
introspectable, hot-code-loadable. Two modes: a SwiftUI/Compose UI driven by
Elixir GenServers (Mob UI apps), or a sidecar BEAM embedded in a normal native
app to give agents and tests live access (Mob as test harness). The sidecar
mode is the long-term bet. Both modes produce a real Erlang node you can Node.connect/1 to.
For the why (the BEAM-on-mobile pitch), see guides/why_beam.md.
Mob is three coordinated repos. Know which one to edit before you change anything.
| Repo | Path | What lives here | Edit when |
|---|---|---|---|
| mob | ~/code/mob |
Runtime library: Mob.Screen, Mob.App, Mob.Renderer, Mob.Dist, Mob.Test, the iOS Swift / Android Kotlin native bridges, the NIF |
UI behavior, on-device runtime, native bridge changes |
| mob_dev | ~/code/mob_dev |
Mix tasks: mob.deploy, mob.connect, mob.devices, mob.emulators, mob.provision, mob.doctor, mob.battery_bench_*. Igniter installers (mob.add_nif, mob.enable, mob.adopt). Device discovery (MobDev.Discovery.{Android,IOS}). Native build orchestration (MobDev.NativeBuild). OTP tarball download/cache (MobDev.OtpDownloader). |
Build/deploy mechanics, device handling, dev tooling, Igniter tasks that mutate an existing project |
| mob_new | ~/code/mob_new |
Project generator. Hex archive (mix archive.install hex mob_new). Templates in priv/templates/mob.new/. Generates both native Mob UI projects and Phoenix LiveView wrappers. |
Greenfield generator output. Must stay self-contained (ArchiveSelfContainedTest) — no hex-dep modules reachable from archive code, so Igniter-based tasks live in mob_dev, not here |
Cross-repo changes are common — fixing one user-visible behavior often needs
the runtime patched in mob, the build retooled in mob_dev, and the
generator template updated in mob_new so newly-generated projects pick up
the fix without manual edits.
The OTP runtime tarballs (Android arm64/arm32, iOS sim, iOS device) are built
separately and uploaded to GitHub Releases — see mob_dev/build_release.md
and mob_dev/scripts/release/. Patches we apply to OTP source live at
mob_dev/scripts/release/patches/.
The default instinct — screenshots — is wrong. Mob apps run a real Erlang node you can talk to directly. Read the BEAM, drive it, then verify visually only when state isn't enough.
mix mob.devices # list everything connected (sims, emulators, physical)
mix mob.emulators --list # list virtual devices (running and stopped)
mix mob.connect # set up tunnels, start IEx attached to all running nodes
mix mob.connect --no-iex # just print node names + tunnels (for scripting)Node names are platform-specific:
mob_demo_ios@127.0.0.1 # iOS simulator
mob_demo_android_<serial-suffix>@127.0.0.1 # Android (suffix from ro.serialno)
For iOS simulator, the sim shares the Mac's network stack — distribution Just
Works. For Android (and iOS device), mix mob.connect sets up adb reverse /
similar tunnels.
node = :"mob_demo_ios@127.0.0.1"
Mob.Test.screen(node) # which screen is showing? → ModuleName
Mob.Test.assigns(node) # live socket assigns → %{...}
Mob.Test.find(node, "Submit") # locate widget by visible text
Mob.Test.inspect(node) # full snapshot: screen, assigns, nav stack, widget treeThis is faster, exact (not pixel-inferred), and works without taking a screenshot. Use it as the default.
Mob.Test.tap(node, :open_text) # tap by tag atom (the on_tap: {self(), :tag})
Mob.Test.send_message(node, {:custom, :msg}) # arbitrary handle_infoAfter a tap, call Mob.Test.screen(node) again to confirm navigation
happened. Call Mob.Test.assigns(node) to confirm state changed.
When layout/animation/rendering matters, fall back to MCP platform tools:
| iOS simulator | Android |
|---|---|
mcp__ios-simulator__screenshot |
mcp__adb__dump_image |
mcp__ios-simulator__ui_view |
mcp__adb__inspect_ui |
mcp__ios-simulator__ui_tap {x, y} |
adb shell input tap |
mcp__ios-simulator__ui_swipe |
adb shell input swipe |
mcp__ios-simulator__record_video |
adb shell screenrecord |
Use these to confirm a layout looks right, spot animation glitches, or
debug rendering. Don't use them for state queries — Mob.Test.assigns/1
is always better.
1. Edit Elixir/Swift/Kotlin code
2. mix mob.push # fast: BEAM-only push, no native rebuild
mix mob.deploy --native # slower: native rebuild needed (NIF / Swift / Kotlin change)
3. Mob.Test.screen(node) # confirm navigation / state
4. mcp__*__screenshot # spot-check visual (only if layout matters)
5. Mob.Test.tap(node, :button) # drive next interaction
6. Mob.Test.assigns(node) # confirm state updated
7. repeat
Full workflow detail: guides/agentic_coding.md.
These are the things we've burned ourselves on. Following them isn't optional.
-
Default arguments evaluate eagerly.
System.get_env("ROOTDIR", Path.expand("~/..."))evaluatesPath.expandevery call, regardless of whetherROOTDIRis set.Path.expand("~/...")callsSystem.user_home!()which raises on Android (noHOMEenv var). Usecase System.get_env(...)or||instead. Burned us once — see commitd77932e. -
Don't silently swallow
Mob.Screen.start_rooterrors. It returns{:ok, pid}or{:error, reason}and crashes from insideinitare reported via{:error, ...}. If you don't pattern-match, the screen never renders and the app sits on the "Starting BEAM…" splash forever. The on_start callback should{:ok, _} = Mob.Screen.start_root(...)so failures crash loudly. -
TDD discipline in mob_dev. Every new public function gets a test.
mob_dev/CLAUDE.mdmakes this explicit. Don't bypass — the tests are how we catch the multi-step regressions like the iOS-device deploy chain. -
Format + credo before commit.
mix format && mix credo --strictfrom the relevant repo, every time. Both are clean across the codebase today; don't regress them. -
Multi-repo changes batch together. A user-visible fix in mob often needs matching changes in mob_dev (build) and mob_new (template). Bumping versions without coordination produces ghost regressions. Check all three before declaring done.
-
iOS device sandbox blocks
fork(). The BEAM'sforker_startand EPMD'srun_daemonboth call fork; both are patched in our OTP cross-compile. Patches atmob_dev/scripts/release/patches/. Don't undo them. -
iOS sim and iOS device are different build paths. Sim →
ios/build.sh(build_ios/1in NativeBuild). Device →ios/build_device.sh(build_ios_physical/2). When--device <udid>is passed, mob_dev resolves it viaIOS.list_devices/0to know which path to take. Don't shortcut. -
LV port 4200 is global per device. Two installed Mob LV apps + one running = the second can't bind. Workaround for now: force-stop the squatter. Real fix tracked in
issues.md#4 (hash bundle id into port). -
Compile-time
~r//literals are unsafe on OTP 28. They bake a:re_exported_patternand call:re.import/1at runtime; OTP 28.0 removed that function. UseRegex.compile!("...", "flags")to compile at runtime. 71 literals across mob_dev were swept in 0.3.17. -
:mob_nif.log/1for early startup logging,Loggerafter Mob.App.start.Mob.NativeLogger.install()runs as part ofMob.App.startand reroutesLoggerto NSLog/logcat. Before that point (steps 1–4 in the Erlang bootstrap),Loggeroutput goes to stderr and is invisible. Use:mob_nif.log("message")for diagnostics during early init. -
NIFs on Android must be statically linked, not
dlopen'd. Android'sSystem.loadLibraryloads native libsRTLD_LOCALby default — the parent'senif_*symbols are invisible to subsequently-dlopen'd children. The OTP-internal NIFs (crypto,asn1rt_nif) are built as.aarchives and linked into the app's main native lib via--whole-archive; the BEAM resolves theirnif_initviadlsym(RTLD_DEFAULT)(registered through--enable-static-nifsat OTP build time, listed inerts_static_nif_tab[]). Any custom NIFs a mob app adds must follow the same pattern. Seemob/common_fixes.mdfor symptoms and the dead-end attempts (we tried-Wl,--export-dynamicand runtimeRTLD_GLOBALself-dlopen; neither works on Android). -
:cryptoon-device is real OpenSSL (3.x, statically linked). No more shim — old code that special-cased "no crypto on mobile" can be deleted. The deployer'sgenerate_crypto_shim/0only fires when a cached OTP runtime lackslib/crypto-*/ebin/crypto.beam; current tarballs have it. Seemob/crypto_plan.mdfor the rebuild process when bumping OpenSSL. -
Igniter-based tasks live in mob_dev, never in the mob_new archive. mob_new ships as a self-contained Mix archive;
ArchiveSelfContainedTestpins that no hex-dep modules are reachable from archive code (an archive bundles only its own beams, so a call into a hex dep crashes every installed user withUndefinedFunctionError). Igniter is a hex dep, so anyIgniter.Mix.Task(mob.add_nif,mob.enable,mob.adopt) belongs in mob_dev — a normal project dependency where Igniter is on the path. A task that needs mob_new's templates (e.g.mob.adopt --android/--ios) reads them from the installed mob_new archive via:code.priv_dir(:mob_new)rather than duplicating them. Seemob_dev/decisions/2026-06-19-mob-adopt-lives-in-mob_dev.md.
| Question | File |
|---|---|
| Round-trip workflow + MCP setup | guides/agentic_coding.md |
| System architecture / native cocoon model | CLAUDE.md (top half), ARCHITECTURE.md |
| "I hit error X — has this happened before?" | common_fixes.md |
| "Does this user-facing setup issue ring a bell?" | user_issues.md |
| Open known issues with diagnoses + fixes | issues.md |
| Speculative ideas, longer-term plans | future_developments.md, wire_tap.md, PLAN.md |
| Per-feature deep dives (events, navigation, theming, ...) | guides/*.md |
| Architecture decisions (one ADR per cross-cutting decision) | docs/decisions/ |
| iOS device deployment (provisioning, build chain, gotchas) | guides/ios_physical_device.md |
| Generator templates (mob_new) | mob_new/priv/templates/mob.new/ |
| Build / release tooling | mob_dev/scripts/release/, mob_dev/build_release.md |
- Terse responses. Default to short, dense communication. The user reads code changes via diff; don't recap them in chat.
- No premature abstractions. Three similar lines beats a half-baked helper.
- No comments explaining the code. Comments explain why — invariants, hidden constraints, surprising behavior. Never the what.
- Trust internal callers. Don't add validation/error handling for cases that can't happen. Validate at system boundaries (user input, external APIs).
- Don't add features beyond what was requested. A bug fix doesn't need surrounding cleanup; a one-shot doesn't need a helper.
- Write UI the LiveView way. The
~MOBsigil supports@assignsshorthand and:if/:forcontrol attributes (<Row :for={u <- @users}>), andMob.Sockethasassign/2,3,update/3,assign_new/3. Seeguides/components.md→ Control flow.
LLMs reach for the same anti-patterns over and over. The list below is the
shape of code our mix credo --strict (via ex_slop) refuses to merge — but
catching it post-hoc costs a round-trip. Don't write it in the first place.
Error handling
- No blanket
rescue _ -> nilorrescue _e -> {:error, "..."}. Rescue the specific exception or let it crash. - No
rescue e -> Logger.error(...); :error— that logs the bug into oblivion. Either reraise or return a typed error tuple the caller can match on. - No
try/rescuearound functions that don't raise (Map.get,Enum.find,String.split). Look up whether the function actually raises before wrapping it.
Database access
- Filter in SQL, not in Elixir:
from(u in User, where: u.active)— notRepo.all(User) |> Enum.filter(& &1.active). - No N+1 in
Enum.map: don'tEnum.map(ids, &Repo.get(...)). UseRepo.all(from … where: id in ^ids). - Don't write a GenServer whose entire job is
Map.get/Map.puton state — use ETS, Agent, or a struct passed by value.
Maps
- Pick one key type per map. Don't
Map.get(m, :key) || Map.get(m, "key")— normalize once at the boundary. - Iterate the map directly. Not
Map.keys(m) |> Enum.map(fn k -> m[k] end).
Enum / list idioms — use the function that exists:
Enum.reject(&is_nil/1)notEnum.filter(&(&1 != nil))Enum.empty?(x)notlength(x) == 0List.last(x)/Enum.at(x, -1)notEnum.at(x, length(x) - 1)Map.new/2notEnum.reduce(%{}, fn ..., &Map.put/3)Enum.into(list, %{})only if you actually have a Collectable target; for a plain literal target it's justMap.new.Enum.filternotEnum.flat_map(fn x -> if cond, do: [x], else: [] end)Enum.sumnot a hand-rolled reduce with+Enum.max/Kernel.maxnotif a > b, do: a, else: bEnum.sort(list, :desc)notEnum.sort(list) |> Enum.reverse()Enum.min(list)notEnum.sort(list) |> Enum.at(0)Enum.map_join(list, sep, &f/1)notEnum.map(list, &f/1) |> Enum.join(sep)
with blocks
- No identity
elseclause.with :ok <- foo() do :ok end— drop theelse err -> errpart.
Strings
String.length(s)notlength(String.graphemes(s)).- For counting specific ASCII chars, prefer
:binary.matches/2over graphemes. - No manual string reverse via graphemes + reverse + join — use
String.reverse/1.
Paths
Application.app_dir(:my_app, "priv/...")overPath.expand("...priv...", __DIR__). The Mix-task code inmob_devis an exception — it needs cwd-relative paths for the user's project.
Docs and comments
- No "This module provides functionality for..." moduledoc. State why it exists or what's surprising; if there's nothing to say, omit it.
- No obvious comments (
# Fetch the useraboveRepo.get(User, id)). - No narrator comments (
# We need to...,# Here we...). - No step comments (
# Step 1: Do X,# Step 2: Do Y) — function names cover that. - No
@doc falseon adefp— private already means undocumented. - Boilerplate
## Parameters / ## Returnssections are noise unless the parameters are non-obvious.
Code shape
- Don't shadow
Kernelfunctions with local variables namedlength,min,max,node, etc. - Don't rebind a parameter inside the function body. Pick a new name.
- Don't write
x = foo(); xat the end of a function — justfoo(). - Don't extract
[a, b] = listonly to immediately rebuild[a, b]. - Use the same name for the same parameter across all clauses of a function.
Periodic check:
ex_slopand the related (but heavier)credencelinter add new AI-pattern checks regularly. Both ecosystems are young — when something here feels stale or you spot a new ExSlop release, skim the changelogs and update this section. Credence has ~70 rules ExSlop doesn't port yet; if any get backported (or ifcredencebecomes worth wiring in alongside Credo), revisitmob/CLAUDE.mdand the deps lists.
The next agent's first decision will be informed by this file. Stale guidance here causes wrong decisions everywhere downstream.
When you change something this doc describes — repo topology, conventions, gotchas, a new piece of CLI surface area, a deprecated workflow — update this file in the same commit. Not in a follow-up. The history of "I'll fix the docs later" is that it doesn't happen.
If you discover a gotcha that bit you — something that should have been on the pre-empt list but wasn't — add it to rule #N+1 with a one-line summary and a link to the commit/test that demonstrates it. Future you will thank present you.