Mob plugins are regular Hex packages with a priv/mob_plugin.exs data
file. The data file declares what the plugin contributes (NIFs, UI
components, screens, permissions, etc.) and mob_dev's compile step
autolinks those contributions into the host app's build.
This doc covers:
- The five plugin tiers and what each one ships
- The manifest schema, annotated with concrete examples
- Install + activation flow
- Validation + compatibility rules
For the surrounding ecosystem questions (why Hex, why a manifest,
plugin authoring via mix mob.new_plugin), see RELEASE.md and the
relevant guides.
Plugin tiers
Plugins range from "10 lines of helper code" to "embedded chat app." The manifest scales — small plugins use 3 fields, big plugins use a dozen. Every section below the required header is optional; you only write what you need.
| Tier | Example | What it ships | Hot-pushable? |
|---|---|---|---|
| 0 | mob_color_palette | Pure Elixir module, no native, no manifest | Yes (regular Hex pkg) |
| 1 | mob_haptic_extras | NIF + Elixir wrapper | No (native rebuild) |
| 2 | mob_signature_pad | + new <SignaturePad> component | No |
| 3 | mob_in_app_purchase | + Mob.Screen modules, migrations, assets | No |
| 4 | mob_chat_kit | + lifecycle hooks, settings, notification handlers | No |
A tier-0 plugin doesn't need this spec at all — it's just a Hex
package depending on :mob. The manifest matters from tier 1
upward.
Minimum viable manifest (tier 1)
# priv/mob_plugin.exs
%{
name: :mob_haptic_extras,
mob_version: "~> 0.6",
plugin_spec_version: 1
}Three required fields, that's it. A manifest this small means "this
plugin's contributions are entirely in the lib/ folder, no native
code, no permissions." Functionally equivalent to a tier-0 plugin
but allows mob_dev to print it in mix mob.plugins output and
enforce the mob_version constraint at compile time.
Add fields below as you need them. Every section is independently optional.
Tier 1 — functional plugin
A NIF + Elixir wrapper + per-platform helper code that doesn't touch
the render tree. The canonical example is the mob_bluetooth plugin
(extracted from core in Wave 1):
%{
name: :mob_bluetooth,
mob_version: "~> 0.6",
plugin_spec_version: 1,
description: "Bluetooth Classic peripheral (HFP / SPP / HID)",
# Static-linked NIFs. `:module` is the NIF's Erlang module name (a valid
# C token: `[a-z][a-z0-9_]*`), NOT an Elixir module — ERL_NIF_INIT uses
# it as BOTH the registered module name AND the static-init symbol prefix
# (`<module>_nif_init`), so an Elixir module like `MobBluetooth.Nif`
# would yield an invalid C symbol. The plugin ships a small Erlang stub
# (e.g. `src/mob_bluetooth_nif.erl`) that calls `erlang:load_nif/2`; an
# Elixir wrapper can then `defdelegate` into it.
#
# `:native_dir` is the per-NIF native source directory. mob_dev's build
# appends these entries to the existing :static_nifs list (the generated
# driver table references `<module>_nif_init` over C ABI regardless of
# source language).
#
# `:lang` selects the compile path (default `:c`):
# - `:c` → `<native_dir>/<module>.c`, compiled with
# `-DSTATIC_ERLANG_NIF_LIBNAME=<module>` (the ERL_NIF_INIT macro
# emits the init symbol). Fed to build.zig via `-Dplugin_c_nifs`.
# - `:zig` → `<native_dir>/<module>.zig`, compiled via `addZigObject` and
# fed via `-Dplugin_zig_nifs`. The source names its own
# `export fn <module>_nif_init()` (no libname flag) and reaches
# mob-core bindings through the named imports `@import("erts")`
# / `@import("jni")` that build.zig wires for plugin zig objects.
# See mob_dev `decisions/2026-05-28-zig-plugin-nifs.md`.
nifs: [
%{module: :mob_bluetooth_nif, native_dir: "priv/native/jni", lang: :zig}
],
android: %{
# Merged into android/app/build.gradle's dependencies block.
gradle_deps: [],
# Merged into AndroidManifest.xml. REQUIRES explicit user opt-in
# via `config :mob, :plugins` — mob_dev refuses to merge these
# silently for plugins that haven't been activated.
permissions: [
"android.permission.BLUETOOTH_CONNECT",
"android.permission.BLUETOOTH_SCAN"
],
# The plugin's own Kotlin bridge class, in its OWN package (NOT the app's
# MobBridge). mob_dev copies it into the app source tree before
# `gradle assembleDebug` so the app's Kotlin sourceSet compiles it.
bridge_kt: "priv/native/android/MobBluetoothBridge.kt",
# Fully-qualified name of that Kotlin class. mob_dev generates a
# `MobPluginBootstrap.registerAll/0` (called from MainActivity.onCreate)
# that invokes `<bridge_class>.register()` at startup; the plugin's
# `nativeRegister(env, cls)` JNI thunk caches its own jclass + method IDs
# from the `cls` arg (no FindClass / classloader problem). This is how a
# plugin-owned Kotlin class becomes callable from its NIF.
bridge_class: "io.mob.bluetooth.MobBluetoothBridge",
# Plain JNI-thunk C (Java_<pkg>_<Class>_*) compiled alongside beam_jni.c
# via `-Dplugin_jni_sources` (no NIF-init libname — these aren't NIFs).
# Holds nativeRegister + the nativeDeliver* thunks that call the plugin
# NIF's `mob_deliver_*` exports.
jni_source: "priv/native/jni/mob_bluetooth_jni.c"
},
ios: %{
# Swift files compiled with the project's existing swiftc invocation.
swift_files: ["priv/native/ios/MobBluetooth.swift"],
# Info.plist keys to merge. iOS rejects builds without these for
# the matching permission categories — same opt-in gate as Android.
plist_keys: %{
"NSBluetoothAlwaysUsageDescription" =>
"Required by mob_bluetooth — replace this string in your Info.plist"
},
# System frameworks linked at the static-link step.
frameworks: ["CoreBluetooth"]
}
}Notes:
:gradle_depsaccept any string Gradle would understand (group:artifact:version).:plist_keysstrings are placeholders — the user must replace them in theirios/Info.plist. App Store review rejects apps with the default text; this is intentional friction so the user provides a real explanation.- iOS or Android can be omitted. iOS-only and Android-only plugins are valid. The validator warns (does not error) when one is missing so users discover the gap.
Tier 2 — visual plugin
Adds new render-tree node types. Same shape as tier 1 plus a
:ui_components section:
%{
name: :mob_charts,
mob_version: "~> 0.6",
plugin_spec_version: 1,
description: "Line / bar / pie chart components",
android: %{
gradle_deps: ["com.github.PhilJay:MPAndroidChart:v3.1.0"]
},
ui_components: [
%{
# PascalCase tag for the ~MOB sigil: <Chart data={@series} />
tag: "Chart",
# Snake-case atom for the render tree: %{type: :chart, ...}
atom: :chart,
# Props the component accepts. Documentation + (eventually)
# compile-time validation. Optional today; required if you
# want `mix mob.routes` and similar tools to know the shape.
props: [:data, :type, :color, :width, :height],
ios: %{
# SwiftUI View struct in priv/native/ios/. mob's renderer
# dispatches `case .chart:` → `MobChartView(node: node)`.
view_module: "MobChartView"
},
android: %{
# @Composable function in priv/native/android/. mob's
# renderer dispatches `"chart" -> MobChart(node, m)`.
composable: "MobChart"
}
},
%{
tag: "Sparkline",
atom: :sparkline,
props: [:data, :color],
ios: %{view_module: "MobSparklineView"},
android: %{composable: "MobSparkline"}
}
]
}A visual plugin can omit one platform if the component is genuinely
platform-specific (e.g., an iOS-only Live Activity widget). The
validator warns when a ui_components entry has only one platform —
silent UX bugs on the missing side are the #1 React Native plugin
pain point.
Visual plugins are NOT hot-pushable. Adding a new node type
requires recompiling the native shell. The dev loop is "edit Elixir
→ rebuild app → reinstall," not "edit Elixir → mix mob.push."
The manifest validator surfaces this distinction.
Tier 3 — multi-screen plugin
Plugins that ship entire screens (effectively mini-applications
embedded in the host). Adds :screens, :migrations, :assets:
%{
name: :mob_in_app_purchase,
mob_version: "~> 0.6",
plugin_spec_version: 1,
description: "StoreKit / Play Billing IAP flow",
# ── tier-1 capability bits ──
nifs: [%{module: :mob_iap_nif, native_dir: "priv/native/jni"}],
android: %{
gradle_deps: ["com.android.billingclient:billing:6.1.0"],
bridge_kt: "priv/native/android/MobIapBridge.kt",
jni_source: "priv/native/android/jni/iap.c"
},
ios: %{
swift_files: ["priv/native/ios/MobIap.swift"],
frameworks: ["StoreKit"]
},
# ── tier-3 additions ──
# Mob.Screen modules the plugin contributes. Host can push them
# via `Mob.UI.push_screen(MobIap.CatalogScreen)`. The plugin's
# README explains the intended navigation patterns.
screens: [
%{module: MobIap.CatalogScreen, default_route: "/iap/catalog"},
%{module: MobIap.CartScreen, default_route: "/iap/cart"},
%{module: MobIap.ConfirmationScreen, default_route: "/iap/confirm"}
],
# Ecto migrations the plugin ships. The repo_namespace prefixes
# table names so plugins from different vendors don't collide.
# Host app's migrator picks them up at boot.
migrations: %{
repo_namespace: "mob_iap_",
migrations_dir: "priv/repo/migrations"
},
# Asset bundles to merge into the host app's bundle.
# Fonts get registered automatically on iOS (UIAppFonts) and
# Android (assets/fonts/). Images are addressable from Mob.UI
# via "plugin://mob_iap/<filename>" path syntax.
assets: %{
fonts: ["priv/assets/iap-icons.ttf"],
images: ["priv/assets/store-badge.png"]
}
}The screens: section is declarative — it tells the host these
modules exist and provides suggested routes. The host app chooses
whether and where to wire them into its navigation. This avoids the
React-Native problem of plugins silently grabbing routes.
Tier 4 — embedded sub-app
Tier 3 plus lifecycle hooks, settings, background workers, push notifications. The line between "plugin" and "embedded application" gets thin here — but as long as the plugin lives under the host's supervisor (no independent OTP app), it's still a plugin.
%{
name: :mob_chat_kit,
mob_version: "~> 0.6",
plugin_spec_version: 1,
description: "Embeddable chat (channels, messages, attachments)",
# ... tier 1/2/3 fields ...
lifecycle: %{
# Called from Mob.App.on_start/0 after the host's own setup.
# Returns :ok or {:error, reason} — error bubbles to host.
on_start: {MobChatKit, :start, []},
# Children added to the host's supervisor tree. Same shape as
# Supervisor.child_spec. Started after on_start succeeds.
supervised: [
MobChatKit.MessageSync,
{MobChatKit.PresenceTracker, []}
],
# Optional OS-level callbacks. Called when the app foregrounds
# or backgrounds. Plugin can flush pending state, pause workers, etc.
on_resume: {MobChatKit, :on_resume, []},
on_background: {MobChatKit, :on_background, []}
},
settings: %{
# User-facing settings the plugin exposes. Persisted via
# Mob.State, namespaced per plugin. Defaults are
# used until the user opens the editor_screen and saves.
schema: [
%{key: :sound_on_message, type: :boolean, default: true},
%{key: :default_channel, type: :string, default: "#general"},
%{key: :sync_interval_seconds, type: :integer, default: 30}
],
# Mob.Screen module the host can push to let users edit. The
# plugin owns the screen's UX; the host just provides the
# entry point.
editor_screen: MobChatKit.SettingsScreen
},
notifications: %{
# Push notification handler. The host's notification dispatcher
# checks each plugin's handler in registration order; first
# match wins. `match` is either a function or a map prefix.
handlers: [
%{
match: %{type: "chat_message"},
handler: {MobChatKit.Notifications, :handle_message, 1}
}
]
}
}:settings.schema typed entries get free runtime validation via
Mob.State. The plugin reads its own settings with
Mob.Plugins.get_setting(:mob_chat_kit, :default_channel).
Status (2026-06-06): tiers 3 and 4 are built and device-verified (iPhone + Android). The wiring is pure-Elixir off a generated runtime manifest read by
Mob.Pluginsat boot; seedecisions/2026-06-06-plugin-tiers-3-4.md. Device-verified: static + generated screens, migrations (table created on device),plugin://images, notification routing, tier-4 lifecycle/settings/supervised workers, and custom fonts —assets.fontsare build-bundled (iOS.app+UIAppFonts, Androidres/fontuncompressed) and used via thefont:prop, visually confirmed on Android (a plugin-shipped serif font rendered distinct from the system font). This also makes app-levelpriv/fonts/custom fonts (documented above) actually work for the first time. Merged to masters;mix mob.new_pluginscaffolds all five tiers (0–4), cross-plugin conflict detection + a runtime- manifest auto-regen guard the multi-plugin case (see "Cross-plugin conflict detection" below), and a second plugin migration is device-verified composing alongside the first.
Code-generated plugins (spec version 2+)
Some plugins need to derive their contributions from the host app's
configuration at compile time, not declare them statically. The
canonical case is an Ash integration: define N Ash resources in
the host app, and a mob_ash plugin generates N × 3 screens (list,
detail, form) plus any matching UI components — all baked into the
build, not runtime.
For these plugins the static :screens list isn't enough. Spec
version 2 adds the :screens_generator field that returns the same
shape at compile time:
%{
name: :mob_ash,
mob_version: "~> 0.6",
plugin_spec_version: 2, # bumped — requires v2
description: "Generate Mob screens from Ash resources",
# Either static (tier 3) or generated (this section), not both.
# Generator is {Module, :function, args}; mob_dev calls it during
# the compile step and uses the returned list as if it had been
# declared statically.
screens_generator: {MobAsh.ScreenGenerator, :generate, []},
ui_components: [
%{tag: "AshForm", atom: :ash_form, props: [:resource, :action, :record]},
%{tag: "AshList", atom: :ash_list, props: [:resource, :filter, :sort]},
%{tag: "AshField", atom: :ash_field, props: [:attribute, :record]}
],
ios: %{swift_files: ["priv/native/ios/MobAshForm.swift", ...]},
android: %{composable_files: [...]}
}The generator function returns a list with the same shape as
:screens:
defmodule MobAsh.ScreenGenerator do
def generate do
# Read host app's Ash domain registration.
domains = MobDev.Plugin.host_config(:my_app, :ash_domains, [])
for domain <- domains,
resource <- domain.resources(),
screen <- [:list, :detail, :form] do
module = generated_module_name(resource, screen)
route = generated_route(resource, screen)
# Actually create the module at compile time via Module.create/3.
create_screen_module(module, resource, screen)
%{module: module, default_route: route}
end
end
endMobDev.Plugin.host_config/3 is the explicit, audited API for
generators to read the host's config :my_app, ... during compile.
Calls outside this surface (e.g. reading mob.exs directly,
introspecting other plugins) require :host_config_keys declared
in the manifest so the audit can verify what the generator touches.
Other generator fields
Spec version 2 adds matching generator forms for any section that benefits from dynamic computation:
:nifs_generator— useful when the NIF set depends on host config (e.g., conditionally include a feature):ui_components_generator— for plugins that synthesize components from a schema (form-builders, data-bound widgets)
A plugin can mix static and generator forms across different
sections — static :nifs + generated :screens is fine.
Why generators at compile time, not runtime
Mob plugins are statically merged for App Store / Play Store compatibility. Runtime plugin registration would require dynamic module loading which our build posture forbids. Compile-time generators produce real modules that ship in the binary the same as hand-written ones. Hot-push works for any pure-Elixir generated modules (same rule as static screens); native-touching generators require a rebuild.
What a host app looks like
The Ash integration story for an end user:
# mix.exs
{:mob_ash, "~> 0.1"}
# mob.exs
config :mob, :plugins, [:mob_ash]
# my_app.ex (the host's Ash domain)
config :my_app, :ash_domains, [MyApp.Blog, MyApp.Auth]
# That's it. Compile produces:
# MobAsh.Generated.Blog.Post.ListScreen
# MobAsh.Generated.Blog.Post.DetailScreen
# MobAsh.Generated.Blog.Post.FormScreen
# MobAsh.Generated.Auth.User.ListScreen
# ... etc, all baked into the build.Adding a resource to the Ash domain regenerates its screen set on
next compile. Removing one removes the screens. The host's
App.navigation/1 can either wire them up by convention or pick a
subset.
The contract is generic — Ash is one example
The :screens_generator + host_config/3 API doesn't know about
Ash. Any host-side registry of resource-like things can drive
screen generation. A mob_ecto sketch shows the same pattern
without Ash as a dependency:
%{
name: :mob_ecto,
mob_version: "~> 0.6",
plugin_spec_version: 2,
description: "Generate Mob screens from Ecto schemas",
screens_generator: {MobEcto.ScreenGenerator, :generate, []},
ui_components: [
%{tag: "EctoForm", atom: :ecto_form, props: [:schema, :changeset]},
%{tag: "EctoList", atom: :ecto_list, props: [:schema, :query]},
%{tag: "EctoField", atom: :ecto_field, props: [:field, :record]}
]
}The host registers its schemas the same way Ash domains are registered:
# my_app.ex
config :my_app, :ecto_schemas, [MyApp.Blog.Post, MyApp.Auth.User]And the generator iterates schemas instead of resources:
defmodule MobEcto.ScreenGenerator do
def generate do
schemas = MobDev.Plugin.host_config(:my_app, :ecto_schemas, [])
for schema <- schemas,
screen <- [:list, :detail, :form] do
module = generated_module_name(schema, screen)
route = generated_route(schema, screen)
create_screen_module(module, schema, screen)
%{module: module, default_route: route}
end
end
endmob_ash and mob_ecto have identical contracts with mob_dev — they differ only in how they introspect the host's resource definitions. The same pattern fits Phoenix schemas, Memento tables, or any custom host-side registry.
Working with Ash beyond the basics
If a host app wants to share resource code between the Phoenix
server and the mob_ash generator — the same User attributes,
validations, or calculations on both sides — the recommended path
is Spark Fragments, the existing Ash mechanism for composable
DSL fragments:
defmodule Shared.User.Attributes do
use Spark.Dsl.Fragment, of: Ash.Resource
attributes do
attribute :email, :string
attribute :name, :string
end
end
# server-side resource
defmodule MyApp.Auth.User do
use Ash.Resource, fragments: [Shared.User.Attributes]
# + server-only actions, policies, data layer
end
# mobile-side resource (read by mob_ash's generator)
defmodule MyApp.Mobile.User do
use Ash.Resource, fragments: [Shared.User.Attributes]
# + mobile-safe action subset
endPer-action exposure granularity ("expose only :read and :create
to mobile") is a host-app concern, expressed by which actions live
on the mobile-side resource module. mob_dev does not need a DSL
for this — the generator sees whatever resources the host registers
in config :my_app, :ash_domains and generates screens for their
declared actions.
This keeps mob_dev's contract Ash-agnostic while giving Ash users a clean path for the server/mobile code-sharing question without mob_dev needing to know anything about it.
Install + activation flow
Two-step opt-in by design.
Step 1 — install (deps + mix deps.get)
Standard Hex flow. The plugin is now resolvable; mob_dev sees it on the next compile.
# mix.exs
defp deps do
[
{:mob, "~> 0.6"},
{:mob_haptic_extras, "~> 0.1"}
]
endmix deps.get
After this, mix mob.plugins lists the plugin as installed but not
activated. Its native code is NOT merged into the build. Its
permissions are NOT added to your manifest. This is deliberate — a
silent mix deps.get should never modify your app's permission set.
Step 2 — activation (explicit consent in mob.exs)
# mob.exs
config :mob, :plugins, [
:mob_haptic_extras,
:mob_bluetooth
]Now mob_dev's compile step merges contributions. If mob_bluetooth
declares BLUETOOTH_CONNECT + BLUETOOTH_SCAN, those permissions
get added to AndroidManifest.xml only after the plugin is in this
list. mob_dev prints the diff at compile time so you see exactly
what's being added.
If you've added a plugin to deps but not to config :mob, :plugins, the next compile prints:
[mob] :mob_bluetooth is installed but not activated. Add it to
`config :mob, :plugins` in mob.exs to enable its contributions
(NIFs, permissions, native code).Convenience — mix mob.add_plugin <name>
Wraps both steps + runs the plugin's interactive setup (if any):
mix mob.add_plugin mob_chat_kit
Does: add to deps, run mix deps.get, add to config :mob, :plugins, walk the plugin's setup: prompts (e.g., "Register
MobChatKit.MessageListScreen in your App.navigation/1? [Y/n]"). For
tier 1-2 plugins the prompts are usually empty. For tier 3-4 plugins
they're where the plugin author guides integration.
Standard flow always works — mix mob.add_plugin is convenience,
not a required entry point.
Schema reference
Top-level required:
:name— atom matching the package name. Convention:mob_prefix.:mob_version— string, semver requirement ("~> 0.6").:plugin_spec_version— integer. Current:1. Bumped when this schema makes breaking changes; old plugins keep working against old spec versions.
Top-level optional:
:description— short string formix mob.pluginsoutput.:host_requirements— list of strings: manual host-app steps the build can't automate (e.g. an AndroidManifest fragment like mob_screencast's<service android:foregroundServiceType="mediaProjection">or a captureFileProvider). Everymix mob.deploy --nativeof the host prints them as a warning block, so a missing manual step can't fail silently at first feature use. Declare one entry per step, with the exact XML/snippet the host author must add.
Capability sections (any combination):
:nifs— list of NIF declarations. See tier 1 example.:android— map of Android-specific contributions::gradle_deps(list of strings):permissions(list of strings — opt-in via activation):bridge_kt(path to Kotlin file):jni_source(path to C/Zig file):min_sdk(integer, optional override)
:ios— map of iOS-specific contributions::swift_files(list of paths):plist_keys(map — opt-in via activation):frameworks(list of strings):min_version(string, optional override)
Visual sections:
:ui_components— list of component maps. Each entry::tag(PascalCase string for the sigil):atom(snake_case atom for the render tree):props(list of atom keys, optional):ios(map::view_moduleSwiftUI struct name):android(map::composablefunction name)
Multi-screen sections:
:screens— list of%{module, default_route}maps:migrations—%{repo_namespace, migrations_dir}map:assets—%{fonts, images}map
Sub-app sections:
:lifecycle—%{on_start, supervised, on_resume, on_background}map:settings—%{schema, editor_screen}map:notifications—%{handlers}map
Setup section (tier 3+):
:setup— list of interactive prompts thatmix mob.add_pluginwalks through. Optional; mostly for tier-3/4 plugins.
Validation rules
mix mob.validate_plugin (run from a plugin project) checks:
- Required top-level fields present
mob_versionis a valid version requirement- Every path in the manifest exists on disk
- Files declared as
bridge_kt/jni_source/swift_files/view_module/composableexist and parse ui_componentsentries with only one platform (warning, not error)permissionsandplist_keysdeclared (warning + manual review recommended before publishing)mob_versionsatisfied by the version of:mobin deps
Compile-time validation (run by mob_dev when activating plugins):
- Plugin's
mob_versionrequirement satisfied by the installed mob - All plugins in
config :mob, :pluginsare present indeps - Cross-plugin conflict detection (see below)
Both stages fail loud — never silent.
Cross-plugin conflict detection
Anyone can ship a plugin, and a host can activate any combination — so when two
plugins both contribute into the same shared namespace, mob_dev must catch it at
build time rather than let one silently win on device. cross_validate (in
MobDev.Plugin.Validator) runs over the activated set and fails the build
when two plugins clash on any of:
| Shared resource | Manifest field |
|---|---|
| Screen route | screens.default_route |
| Component atom | ui_components.atom |
| iOS native view key | ui_components.ios.view_module |
| Android native view key | ui_components.android.composable |
| Migration namespace | migrations.repo_namespace |
| NIF module | nifs.module |
| iOS Swift source basename | ios.swift_files |
| Android JNI source basename | android.jni_source |
| Android bridge class | android.bridge_class |
| iOS Info.plist key | ios.plist_keys |
| Supervised worker | lifecycle.supervised |
| Notification match | notifications.handlers[].match |
A clash on any of these is a build error naming the resource, the value, and how
many plugins declared it. Resources that are inherently safe — settings (keyed
per-plugin), plugin:// images (namespaced per-plugin), Android permissions /
iOS frameworks (set-unioned) — compose without a check.
A note on what counts as a clash: the check is cross-plugin, so a single
plugin legitimately declaring the same value twice is fine — e.g. a
cross-platform NIF that ships one iOS (lang: :objc) and one Android
(lang: :zig) entry for the same :module is not a collision; two different
plugins claiming that module is. Detection only flags identical values, not
semantic overlap (two notification predicates that could both match the same
payload aren't comparable in general — keep matches disjoint).
Completeness guarantee. Every field that lands in a shared namespace is
classified in Validator.conflict_surface/0, and a test (conflict_surface_test)
asserts that classification covers every merge gatherer. Adding a new
shared-resource field to the schema without classifying its conflict behavior
fails CI — so the guarantee that multiples compose can't silently rot as the
schema grows. A property-based fuzzer (merge_fuzz_test) additionally checks the
detection is sound and complete across random N-plugin combinations.
Runtime plugin manifest
Tiers 3 and 4 are pure-Elixir and runtime-wired: the host needs to know,
while running, which screens / lifecycle hooks / settings / notification handlers
the activated plugins declared. mob_dev bakes that into a generated terms file,
priv/generated/mob_plugins.exs, which the core Mob.Plugins module reads once
at boot. It is derived state, not hand-maintained — mix mob.deploy --native
regenerates it from the activated plugins' current manifests on every build (you
can also run mix mob.regen_plugin_manifest directly, or --check it in CI).
Because it regenerates unconditionally, changing a plugin's tier-3/4 sections
can't ship a stale manifest. Tier-0/1/2 plugins contribute nothing to it.
Versioning and forward compatibility
:plugin_spec_version is the escape hatch for evolving the schema
without breaking existing plugins.
- Today: spec version 1. All examples above target spec 1.
- If the schema needs a breaking change (e.g., renaming
:ui_componentsto:components), bump to spec 2 and have mob_dev support both. - Plugins declare which spec they target; mob_dev validates against that spec; old plugins keep compiling unchanged.
Bumping spec version means giving plugin authors a migration window before deprecating the old spec.
Hot-push compatibility
| Plugin tier | Hot-pushable? | Why |
|---|---|---|
| 0 (regular Hex pkg) | Yes | Pure Elixir; .beam ships via mix mob.push |
| 1 (NIFs) | No | Native code requires APK/IPA rebuild |
| 2 (visual component) | No | Same |
| 3 (multi-screen) | Partial — Elixir code in screens IS hot-pushable; native code IS NOT |
| 4 (sub-app) | Partial — same |
The manifest validator computes hot_pushable automatically from
which sections are populated. Plugin docs should make this explicit
so users understand why some changes need a rebuild.
Why this design
A few choices to flag:
- Manifest is data, not code. The plugin doesn't
register_pluginat runtime; mob_dev reads the data at compile time. Static, inspectable, validatable. Closer tomix.exs'sproject/0than to Phoenix's runtime route registration. - Two-step activation (deps + config). Borrowed from how iOS entitlements work — a framework supporting capability X doesn't mean your app uses X; that requires explicit declaration. Mitigates the supply-chain risk of silent permission merges.
- Schema scales with tier, not exhaustive everywhere. A tier-1
plugin doesn't fill out
:lifecycleor:settings. The schema doesn't make small plugins look big. - Hex is the substrate. Versioning, dep resolution, security
posture, hexdocs publication — all free. Local
path:deps work the same way for development. - Static-link required, no dlopen. Mob's App-Store-compatible
build pins this. Plugins follow the same rule; the build embeds
plugin NIFs into the host's
libpigeon.so. Restrictive vs. React Native; necessary for App Store shipping.
Requirements raised by third-party UI-kit evaluation
Evaluating whether an established web component library (Mishka
Chelekom — shadcn-style Phoenix/Tailwind generator) could be brought
to Mob surfaced two gaps in the current spec. Both are now resolved
(2026-05-27) — see decisions/2026-05-27-pure-elixir-composite-tier.md
and decisions/2026-05-27-ui-kit-distribution-model.md. The resolution
for each is noted inline below.
1. Pure-Elixir composite components have no tier
:ui_components (tier 2) assumes native backing — every entry
maps tag/atom → a SwiftUI view_module and an Android
composable. There is currently no slot for a pure-Elixir
composite: a tag that expands to a built-in widget tree with no
native code (e.g. <MishkaCombobox/> → Column + TextField +
List). This is the headline ask from any UI-kit author who doesn't
write Swift/Kotlin.
What exists today:
- Tier 0 already gives function-call composites —
def combobox(opts), do: ~MOB"..."invoked via the sigil's{combobox(...)}child slot. Pure Elixir, hot-pushable, ships as a plain Hex package with no manifest. A UI kit can ship its presentational + simple-interactive components this way now. - What's missing is tag syntax (
<MishkaCombobox/>) and a manifest declaration for it.
Reserved shape — a third form alongside the native one:
ui_components: [
%{tag: "MishkaCombobox", atom: :mishka_combobox,
expand: {Mishka.Combobox, :expand}} # pure-Elixir, no :ios/:android — HONORED since 2026-06-11
]This implies a third expansion pass in core, run before
Mob.List.expand / Mob.Component.expand in Mob.Screen.do_render/3
(so a composite can itself emit <List> / native_view for the later
passes), recursing to a fixpoint with a depth guard. Because the pass
runs in the screen process and is handed the screen pid (like
Mob.List.expand is), it can auto-inject event targets — the
author writes on_select="combo_select" and the pass wires
{screen_pid, :combo_select}, removing the need to thread self()
through every component. Hot-pushable (pure Elixir; same rule as
tier 0).
Resolution (updated 2026-06-11): the expand: field is honored.
Mob.Composite runs as the FIRST expansion pass in Mob.Screen's
render pipeline (before Mob.List.expand / Mob.Component.expand, so a
composite may itself emit <List> or Mob.UI.native_view), recursing
to a fixpoint with a depth guard. The expander contract is
expand(props, children, ctx); on_* props written as bare
strings/atoms are AUTO-INJECTED as {screen_pid, tag} — no self()
threading. Registration is the manifest expand: form (validated:
native backing XOR expand; expand-only plugins are hot-pushable) or
Mob.Composite.register/2 at runtime for plain Hex kits with no
manifest. Tier-0 function composites ({combobox(...)}) remain fully
supported — the tag form is ergonomics on top. Worked example:
mob_plugin_demo/plugins/mob_demo_kit (<DemoCard>/<DemoCombobox>,
device-verified). The original deferral ("no core churn in Phase 1,
design against a concrete consumer") resolved when both conditions
flipped — see decisions/2026-06-11-composite-expansion-pass.md.
Known wart: the ~MOB sigil warns once per call site for tags outside
its compile-time whitelist; composite tags compile fine through the
PascalCase→snake_case fallback. Follow-up: app-extendable tag list.
2. Generator vs. dependency — distribution model
Mishka-class kits are shadcn-style generators: a dev-only tool
(mix mishka.ui.gen.component, built on Igniter) that emits component
source the user owns and edits into their project; components are
free, the paid tier is templates + support, not components. The plugin
system here is dependency-shaped (Hex dep + two-step activation).
These are different products. A faithful UI kit for Mob may be a
generator (mix mob.gen.component) rather than a plugin at all —
or the two coexist (a generator scaffolds owned source from a plugin
package). Decide which model a UI kit targets before committing a
vendor to it; it determines whether a kit is a plugin in the first
place, and it's the vendor's entire identity as a tool author.
(Igniter is shared ground — Mishka is built on it and Mob's build
migration is heading there — so the generator path is not foreign
territory.)
Resolution: two lanes, kept separate. The plugin (dependency)
lane — Hex dep + two-step activation — is what this spec covers and
is in scope for the plugin epic; it's for native-backed,
capability-bearing, or centrally-maintained components. The
generator lane — mix mob.gen.component, Igniter-based, emitting
owned-source presentational components — is a separate tool tracked
with the Igniter build-migration work, not part of the plugin
epic. The two can coexist. For Mishka specifically, the faithful port
is the generator lane.
Future: full-language plugins
This section parks an idea that's coherent but explicitly out of
scope for the current spec. Mob's lane is Elixir-first / BEAM-native
(see plugin_extraction_plan.md "Scope"). A determined plugin author
who wants to write entire screens in Python, Lua, JS, or any other
language-with-an-embedded-interpreter could in principle build that
on top of the plugin system — but the framework doesn't ship the
glue.
What would be needed
A new manifest concept — a screen dispatcher:
%{
name: :mob_python_app,
mob_version: "~> 0.6",
plugin_spec_version: 3, # speculative — not part of v2
requires: [:mob_pythonx],
screen_dispatcher: %{
kind: :python,
module: MobPythonApp.Dispatcher,
callbacks: [
mount: 3,
render: 1,
handle_event: 3
]
}
}A screen registered with kind: :python would route its lifecycle
callbacks through the dispatcher instead of expecting an Elixir
module. The dispatcher resolves them however it wants — calling
into the embedded Python interpreter, in this case.
The user's authoring story would then look like:
# app/screens/home.py
import mob
@mob.screen("home")
class HomeScreen:
def mount(self, params, session):
return {"count": 0}
def render(self, assigns):
return mob.ui.column([
mob.ui.text(f"Count: {assigns['count']}"),
mob.ui.button("Tap", on_tap=("incr", None))
])
def handle_event(self, name, _, assigns):
if name == "incr":
return {"count": assigns["count"] + 1}Why it's parked
- Lane discipline. Mob's value rests on Elixir + BEAM ergonomics. Diverting design effort into "Python frontends are equally first-class" weakens the core lane without obviously reaching parity with React Native / Flutter / native SDKs in their own lanes.
- The hooks are conceptually clear; the implementation is bottomless. Sketching the screen-dispatcher takes a paragraph. Making it actually pleasant (debugging, hot-reload across the language seam, error attribution, asset bundling, IDE support) is multi-month framework work. Worth doing only if the demand is clear.
- The hybrid model captures the win without the cost. Apps that
use Mob screens in Elixir but call into Rust (via
mob_rustler) or Python (viamob_pythonx) for specific concerns — ML, perf- sensitive paths, scripting layers — get most of the benefit without forcing the entire screen surface through an interpreter. Seeplugin_extraction_plan.md"Scope" for the recommended hybrid pattern.
What stays open
- The plugin spec versioning leaves room. If a plugin author builds a full Python (or Lua, JS, etc.) frontend on top of the current spec, the framework can codify the screen-dispatcher concept in a later spec bump without breaking anyone.
- The BEAM-native path is unaffected. Gleam, LFE, Hamler, or any
BEAM language can already author Mob screens today — Mob's API is
just BEAM modules, the sigil is the only Elixir-flavoured part. A
mob_gleamergonomic-wrappers plugin is a perfectly reasonable community project that needs no framework changes.
The door stays open. Walking through it is on the ambitious plugin author, not the framework.