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:
| Tier | Ships | Native rebuild? |
|---|---|---|
| 0 | Pure-Elixir helpers (no manifest) | No — plain Hex pkg, hot-pushable |
| 1 | A NIF + Elixir wrapper | Yes |
| 2 | A native UI component (<MyView>) | Yes |
| 3 | Whole Mob.Screens + Ecto migrations + assets (fonts/images) | Yes |
| 4 | Lifecycle hooks + supervised workers + settings + notifications | Yes |
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(ahello/0to replace). - 1 — adds an Erlang NIF stub (
src/my_widget_nif.erl), the C source (priv/native/jni/my_widget_nif.c), and a:nifsmanifest. - 2 — adds a
Mob.Componentmodule + its Kotlin Composable + Swift View, and a:ui_componentsmanifest. - 3 — adds two
Mob.Screenmodules (list + detail), a:screens+:migrationsmanifest, and a namespaced Ecto migration. - 4 — adds a lifecycle module, a supervised
Worker, aNotificationshandler, a settings editor screen, and a:lifecycle+:settings+:notificationsmanifest.
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
:moduleis a C/Erlang token (not an Elixir module) —ERL_NIF_INITuses it as the registered name + the static-init symbol prefix. - Screen routes (
screens.default_route) and migrationrepo_namespacemust 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/2andMob.Plugins.put_setting/3(values are validated against the declared:type). - Pure-Elixir composite components (UI kits, no Swift/Kotlin): a
ui_componentsentry may declareexpand: {Module, :function}instead of native backing — your expander turns<MyTag …/>into a built-in widget tree at render time, withon_*event props auto-wired to the screen process. See the Components guide andMob.Composite; the worked example ismob_demo_kit. host_requirements: if your plugin needs something the build can't automate — typically anAndroidManifest.xmlfragment like a<service>,<activity>, or<provider>— declare each step as a string in the manifest'shost_requirementslist. Everymix mob.deploy --nativeof 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:
- App config.
config/config.exs(andconfig/runtime.exs, evaluated on the build machine at build time, not on the phone) is put into the application environment from the:mob_app_configmodule 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 withpersistent: true). The:mob_devkey is dropped. Logged as[mob] app config: loaded :mob_app_config (my_plugin: 3 keys, …). - Framework services (navigation registry, state, sender, device, …).
- Plugin OTP applications.
Application.ensure_all_started/1for each activated plugin (its manifestnameis 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. - Plugin
lifecycle.on_start, then the plugins'supervisedchildren. - 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 withmodule(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'srender/1returned without raising andMob.Sendercommitted the tree (set_root). The router's first paint is asynchronous, so this is later than the router starting. A root screen whoserender/1raises 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, callMob.Router.Hooks.rearm_first_render/0from 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:
| Plugin | Tier | Demonstrates |
|---|---|---|
mob_palette_demo | 0 | Pure-Elixir, activated via mob.exs only |
mob_demo_haptic_extras | 1 | C NIF + iOS framework (CoreHaptics) |
mob_demo_zig_extras | 1 | Zig NIF + Android Kotlin bridge |
mob_demo_perm | 1 | Extending the permission registry |
mob_demo_signature_pad | 2 | Native SwiftUI / Compose component |
mob_demo_kv_browser | 3 | Two screens + a migration + a bundled font + a plugin:// image |
mob_demo_gen_screens | 3+4 | Spec-v2 screens_generator and tier-4 lifecycle/settings/notifications in one plugin |
mob_demo_subapp | 4 | Lifecycle hooks, supervised worker, settings, notification handler |
mob_demo_kit | 2 (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.