A mob plugin is a Hex package (or local path dep) that extends a host app — from a few lines of pure-Elixir helpers to an embedded sub-app with its own screens, database tables, background workers, and settings. This guide walks the full authoring loop: scaffold → implement → sign → activate → deploy. For the exhaustive manifest schema, see MOB_PLUGINS.md; for the trust model, MOB_PLUGIN_SECURITY.md.

The five tiers

A plugin declares only what it needs. The tier is just how much it ships:

TierShipsNative rebuild?
0Pure-Elixir helpers (no manifest)No — plain Hex pkg, hot-pushable
1A NIF + Elixir wrapperYes
2A native UI component (<MyView>)Yes
3Whole Mob.Screens + Ecto migrations + assets (fonts/images)Yes
4Lifecycle hooks + supervised workers + settings + notificationsYes

Tiers are cumulative in spirit but independent in the manifest — a tier-4 plugin can also ship NIFs and screens. The tier reported by mix mob.plugins is just the highest section present.

1. Scaffold

mix mob.new_plugin generates a working skeleton for any tier into plugins/<name>/:

mix mob.new_plugin my_widget --tier 3

What you get per tier:

  • 0 — mix.exs + lib/my_widget.ex (a hello/0 to replace).
  • 1 — adds an Erlang NIF stub (src/my_widget_nif.erl), the C source (priv/native/jni/my_widget_nif.c), and a :nifs manifest.
  • 2 — adds a Mob.Component module + its Kotlin Composable + Swift View, and a :ui_components manifest.
  • 3 — adds two Mob.Screen modules (list + detail), a :screens + :migrations manifest, and a namespaced Ecto migration.
  • 4 — adds a lifecycle module, a supervised Worker, a Notifications handler, a settings editor screen, and a :lifecycle + :settings + :notifications manifest.

The generated manifest validates and the generated modules compile as-is — they are stubs to fill in, not pseudocode. Every tier also ships a starter test suite (test/<name>_test.exs): stdlib-only structural checks of the manifest and stubs that run with plain mix test — grow it alongside your plugin's pure logic.

2. Implement

Replace the stub bodies with your plugin's real logic. A few rules the manifest comments also remind you of:

  • NIF :module is a C/Erlang token (not an Elixir module) — ERL_NIF_INIT uses it as the registered name + the static-init symbol prefix.
  • Screen routes (screens.default_route) and migration repo_namespace must be unique across every activated plugin (see Conflicts, below). The scaffold defaults the namespace to "<name>_", which is unique by construction.
  • Migrations are the plugin author's raw files; mob_dev namespaces the copied filename at build so two plugins' migrations never collide. Rename the scaffolded migration with a real timestamp before publishing.
  • Settings are typed and per-plugin-namespaced; read/write them with Mob.Plugins.get_setting/2 and Mob.Plugins.put_setting/3 (values are validated against the declared :type).
  • Pure-Elixir composite components (UI kits, no Swift/Kotlin): a ui_components entry may declare expand: {Module, :function} instead of native backing — your expander turns <MyTag …/> into a built-in widget tree at render time, with on_* event props auto-wired to the screen process. See the Components guide and Mob.Composite; the worked example is mob_demo_kit.
  • host_requirements: if your plugin needs something the build can't automate — typically an AndroidManifest.xml fragment like a <service>, <activity>, or <provider> — declare each step as a string in the manifest's host_requirements list. Every mix mob.deploy --native of the host prints them, so a missing manual step can't fail silently at first feature use. (Examples: mob_screencast's mediaProjection service, mob_scanner's scanner activity, mob_notify's FCM wiring.)

Validate as you go, from the plugin directory:

mix mob.validate_plugin

3. Sign

Plugins are cryptographically signed so a host can pin trust to a public key (see MOB_PLUGIN_SECURITY.md). One-time, generate a keypair (the private key stays on your machine, under ~/.mob/keys/):

mix mob.plugin.keygen --plugin plugins/my_widget

Then sign (re-run after any manifest or source change):

mix mob.plugin.sign --plugin plugins/my_widget

This writes priv/mob_plugin.pub + priv/mob_plugin.sig and prints a fingerprint. The fingerprint is the public key — it does not change when you re-sign new content, so a host's trust record stays valid across releases.

4. Activate (in the host)

Activation is two deliberate steps in the host app — a plugin in deps does nothing until it's listed in mob.exs:

# mix.exs
defp deps, do: [{:my_widget, path: "plugins/my_widget"} | _]

# mob.exs
config :mob, :plugins, [:my_widget]
config :mob, :trusted_plugins, %{my_widget: "ed25519:<fingerprint>"}

mix mob.plugin.trust my_widget records the fingerprint for you. An unsigned prototype can instead be acknowledged explicitly via config :mob, :acknowledge_unsafe_plugins, [:my_widget] (a banner prints).

Verify the host sees it:

mix mob.plugins   # lists tier, hot-push status, vetting, activation; flags conflicts

5. Deploy + verify

mix mob.deploy --native    # tiers 1-4 need a native rebuild
mix mob.connect            # drive the running app over dist

--native runs the build-time plugin wiring: NIF/component compilation, asset bundling, migration copying, and a regeneration of the runtime plugin manifest (priv/generated/mob_plugins.exs) so the device's tier-3/4 wiring always matches what the plugins declare.

Boot order and configuration on device

A Mob app boots from its own BEAM entry, not a release, so Mob.App.start/0 does the work a release would. In order:

  1. App config. config/config.exs (and config/runtime.exs, evaluated on the build machine at build time, not on the phone) is put into the application environment from the :mob_app_config module mob_dev ships with every build and BEAM push. Application.get_env(:my_plugin, :key) therefore works on device, and the values survive the plugin's own application being loaded later (they're put with persistent: true). The :mob_dev key is dropped. Logged as [mob] app config: loaded :mob_app_config (my_plugin: 3 keys, …).
  2. Framework services (navigation registry, state, sender, device, …).
  3. Plugin OTP applications. Application.ensure_all_started/1 for each activated plugin (its manifest name is its OTP app), so the plugin's supervision tree and its deps (Req, Finch, …) are up. A failure, a crash, or a start that takes longer than 5 s is logged ([mob] plugin :my_plugin: OTP application failed to start, continuing boot: …) and boot continues. A start that timed out is abandoned without undoing anything: dependencies it already started stay running for the plugins after it, even if its own start later fails.
  4. Plugin lifecycle.on_start, then the plugins' supervised children.
  5. The app's own on_start/0, which starts the root screen.

Because step 1 can be absent (an app built with an older mob_dev, or a host test), a plugin should still treat a missing setting as "not configured" and keep running, not crash.

Router hooks

A plugin that needs to act around navigation registers a hook at runtime — from its lifecycle.on_start, which runs before the app starts its root screen — with Mob.Router.Hooks.register/2. No app configuration.

  • :before_navigate — called with the destination before a push or reset mounts a screen. Return :ok, {:redirect, module} to mount another screen instead (same kind of navigation: a push still pushes, so BACK returns to the screen underneath), {:reset, module} to replace all navigation with module (every stack discarded, persisted screen state cleared, empty params; BACK leaves the app), or {:error, reason} to refuse (navigation stays put, as for an unknown destination). Runs in the router process: keep it fast when there's nothing to do.
  • :after_first_render — called once per VM, in its own process, once the app's first frame has actually reached the native layer: the root screen's render/1 returned without raising and Mob.Sender committed the tree (set_root). The router's first paint is asynchronous, so this is later than the router starting. A root screen whose render/1 raises never triggers it; if a restart of that screen later renders, it fires then. If the root screen navigates away before painting, the destination's first frame counts. The committed screen's module is appended to the hook's arguments. To wait for a frame from a particular screen, call Mob.Router.Hooks.rearm_first_render/0 from the hook when it's the wrong one: the next committed frame, from any screen, fires it again.
def on_start do
  Mob.Router.Hooks.register(:before_navigate, {MyPlugin, :before_navigate, []})
  Mob.Router.Hooks.register(:after_first_render, {MyPlugin, :first_render, []})
end

# MyPlugin.before_navigate(destination) :: :ok | {:redirect, m} | {:reset, m} | {:error, r}
# MyPlugin.first_render(screen_module)

mob_deliver uses both: :before_navigate fetches a just-in-time screen (or resets to its "please update" screen), :after_first_render ends a freshly installed update's probation — safe only because a crash in the root screen's render never reaches it, and only on a frame from the app's own root (it rearms while its update screen is the one showing).

Multiple plugins and conflicts

A host can activate any combination of plugins, so mobdev checks at build time that they compose: two plugins may not claim the same screen route, NIF module, native view key, migration namespace, supervised worker name, plist key, or notification match. A clash is a loud build error, not a silent last-write-wins. See MOB_PLUGINS.md → Cross-plugin conflict detection for the full list and the completeness guarantee. Keep your routes/namespaces/worker names specific to your plugin (the scaffold's `"<name>"` defaults do this).

Style packages (a sibling lane)

A package that ships a look rather than a capability uses the styles lane: a four-field priv/mob_style.exs (name, mob_version, style_spec_version, theme: — a module exporting theme/0) instead of a plugin manifest, activated via config :mob, :styles + config :mob, :default_style. Core applies the default style's theme at boot. See MOB_STYLES.md for the schema and current implementation status; mob_themes is the worked example. A single package may ship both manifests.

Worked examples

The mob_plugin_demo project carries a device-verified plugin per tier — read them as canonical patterns:

PluginTierDemonstrates
mob_palette_demo0Pure-Elixir, activated via mob.exs only
mob_demo_haptic_extras1C NIF + iOS framework (CoreHaptics)
mob_demo_zig_extras1Zig NIF + Android Kotlin bridge
mob_demo_perm1Extending the permission registry
mob_demo_signature_pad2Native SwiftUI / Compose component
mob_demo_kv_browser3Two screens + a migration + a bundled font + a plugin:// image
mob_demo_gen_screens3+4Spec-v2 screens_generator and tier-4 lifecycle/settings/notifications in one plugin
mob_demo_subapp4Lifecycle hooks, supervised worker, settings, notification handler
mob_demo_kit2 (expand)Pure-Elixir composite components (<DemoCard>, <DemoCombobox>) — no native code

Beyond the demo, the shipped first-party packages are full-size references: mob_camera (the heaviest extraction — ObjC + Zig + Kotlin + permission registry), mob_scanner (depends on mob_camera; an Activity host-requirement), mob_notify (delivery-stays-in-core seam + host_requirements), mob_ash (spec-v2 screens_generator against host config), and mob_themes (a style package). See the First-Party Packages catalog.

mob_demo_gen_screens is the clearest example of a single plugin spanning multiple tiers, and (with mob_demo_kv_browser + mob_demo_subapp) of multiple plugins stacking the same tier — two namespaced migrations, two supervised workers, two settings owners, and two notification handlers all active at once.