A mob plugin is a Hex package (or a local path dependency) that extends a host app. It can be a few helper functions, a set of screens with their own database tables, a background service, or a wrapper around a platform API in C, Kotlin and Swift. A host app turns it on in mob.exs; having it in deps is not enough.

This guide is for people writing plugins, and for the coding agents that help them. It covers the decision you make first (what kind of plugin), a walkthrough for each kind from scaffold to Hex, and the reference material both kinds need. The full manifest schema is in MOB_PLUGINS.md and the trust model in MOB_PLUGIN_SECURITY.md; this guide doesn't repeat them.

The walkthroughs were run end to end against a host app generated by mix mob.new (mob_new 0.6.2) on an Android emulator. The scaffold's release setup and the refusal of legacy v1 signatures are new in mob_dev 0.7.6; use 0.7.6 or later.

Which kind of plugin are you writing?

There are two kinds. Everything else follows from which one you pick.

BEAM-only plugins are Elixir code only. They can ship:

  • helper modules and Mob.Screens;
  • pure-Elixir composite components (a ui_components entry with expand: {Module, :function}, see Mob.Composite);
  • lifecycle hooks (on_start, on_resume, on_background), router hooks (Mob.Router.Hooks), supervised workers, or a whole OTP application with its own supervision tree;
  • typed settings, notification handlers, and Ecto migrations.

mob_deliver is the reference: signed over-the-air updates and just-in-time screens with no native code at all. It uses on_start, on_resume / on_background and both router hooks, and gets its supervision tree from its own OTP application, which mob starts at boot.

Native plugins integrate with the app's native build. Anything in this list makes a plugin native:

  • NIFs in C, Zig or Objective-C, statically linked into the app on both platforms;
  • native UI components (a SwiftUI view and a Compose function behind a <MyView> tag);
  • Kotlin and Swift bridge code, JNI and Objective-C glue;
  • iOS frameworks, Gradle dependencies, Android permissions, Info.plist keys;
  • host_requirements: manual steps such as an AndroidManifest.xml <service> fragment.

mob_nfc, mob_camera and mob_wake are native plugins.

What each kind costs:

BEAM-onlyNative
Reaches the device bymix mob.deploy (BEAMs and the runtime plugin manifest)mix mob.deploy --native (rebuild and reinstall the app)
Hot push (mix mob.push, dist deploy)Yes, all of itOnly the Elixir half; native code needs a rebuild
SigningRequired once it has a manifestRequired; the signature also covers every native source
Platform parityFree: the same BEAMs run on iOS and AndroidYou write and test both sides, or declare one platform
Testingmix test on the host covers most of itHost tests cover the Elixir half; the native half only runs on a device

If you can do it in Elixir, do. A plugin can start BEAM-only and grow a NIF later.

Where the tiers fit

Older docs and the tooling describe plugins in tiers 0 to 4. mix mob.new_plugin --tier N still picks its template by tier, and mix mob.plugins prints a tier for each plugin, computed from the manifest sections present:

TierTemplate shipsKind
0Helper module, no manifestBEAM-only
1C NIF + Erlang stub + Elixir wrapperNative
2Native UI component (Kotlin + Swift + Mob.Component)Native (an expand: composite is tier 2 but BEAM-only)
3Two screens + an Ecto migrationBEAM-only (fonts and images in assets: are copied by the native build)
4Lifecycle hooks + supervised worker + settings + notification handlerBEAM-only

The tier is the highest section present, not what it costs. A plugin with lifecycle and nifs is tier 4 and native. mix mob.plugins has a separate HOT-PUSH column (yes, partial, no) that answers the cost question directly.

Walkthrough: a BEAM-only plugin

This builds my_hooks, a plugin with its own OTP application, an on_start that reads app config and registers a router hook, and foreground/background hooks. Run the commands from the host app's root.

1. Scaffold

mix mob.new_plugin my_hooks --tier 4
plugins/my_hooks/
  mix.exs                            # package metadata, mob + mob_dev deps
  lib/my_hooks.ex                    # lifecycle MFAs
  lib/my_hooks/worker.ex             # supervised GenServer
  lib/my_hooks/notifications.ex      # notification handler
  lib/my_hooks/settings_screen.ex    # settings editor screen
  priv/mob_plugin.exs                # the manifest
  test/test_helper.exs
  test/my_hooks_test.exs             # structural checks of the manifest
  README.md
  CHANGELOG.md                       # release notes come from here
  .gitignore                         # ignores priv/mob_plugin.sig
  .github/workflows/release.yml      # signs and publishes on a version bump

The generated code compiles and the manifest validates as-is. Delete what you don't need: a plugin that only needs on_start can drop settings, notifications and the worker from the manifest and their modules.

The generated mix.exs depends on :mob and, for development only, on :mob_dev (which provides mix mob.validate_plugin, mob.plugin.keygen and mob.plugin.sign inside the plugin directory). Its package/0 lists the files Hex ships, including all of priv/, and @source_url is a placeholder (https://github.com/OWNER/my_hooks) that you set to the plugin's repository; the release workflow refuses to publish until you do.

2. The manifest

priv/mob_plugin.exs is data: a map with three required keys (name, mob_version, plugin_spec_version) and the sections you use. The scaffold's lifecycle section:

lifecycle: %{
  on_start: {MyHooks, :start, []},
  on_resume: {MyHooks, :on_resume, []},
  on_background: {MyHooks, :on_background, []},
  supervised: [MyHooks.Worker]
}

Every key is in MOB_PLUGINS.md.

3. Implement

If the plugin needs processes of its own, give it an OTP application. mob starts every activated plugin's application before any plugin's on_start (see Boot order), so the supervision tree and its dependencies are up when on_start runs:

# mix.exs
def application do
  [extra_applications: [:logger], mod: {MyHooks.Application, []}]
end
defmodule MyHooks.Application do
  @moduledoc false
  use Application

  @impl Application
  def start(_type, _args) do
    Supervisor.start_link([], strategy: :one_for_one, name: MyHooks.Supervisor)
  end
end

on_start reads configuration at runtime and registers hooks. It runs during boot, so it must not raise, return {:error, _} or block: any of those stops the app from starting.

defmodule MyHooks do
  require Logger

  def start do
    greeting = Application.get_env(:my_hooks, :greeting, "not configured")
    Logger.info("[my_hooks] on_start, greeting=#{inspect(greeting)}")
    Mob.Router.Hooks.register(:after_first_render, {__MODULE__, :first_render, []})
  rescue
    e -> Logger.error("[my_hooks] on_start failed: " <> Exception.message(e))
  end

  def first_render(screen), do: Logger.info("[my_hooks] first frame from #{inspect(screen)}")
  def on_resume, do: Logger.info("[my_hooks] on_resume")
  def on_background, do: Logger.info("[my_hooks] on_background")
end

4. Test on the host

From the plugin directory:

cd plugins/my_hooks
mix deps.get
mix test

The generated tests check that the manifest evaluates and that every module it names loads. Put the plugin's logic in plain functions and test them here; the device is for wiring.

5. Validate

Still in the plugin directory:

mix mob.validate_plugin
✓ manifest valid

It checks the required fields, that every path the manifest names exists, and that the installed :mob satisfies mob_version. It exits non-zero on any error.

6. Sign

Every plugin with a manifest is signed, and a host refuses to build with an activated plugin whose signature is missing, invalid or from a key it doesn't trust. Generate a key pair once per plugin:

mix mob.plugin.keygen
  generated  ed25519 keypair for my_hooks
  private key: ~/.mob/keys/my_hooks.priv (mode 0600)
  public key:  plugins/my_hooks/priv/mob_plugin.pub
  fingerprint: ed25519:pjffs39rb+Ap9SvGELI5FHDMv+oomvf5wcpiNlnTeuA=

The private key never leaves your machine (and CI's secret store; see Publish). Commit priv/mob_plugin.pub. The fingerprint is the public key, so it stays the same when you re-sign.

To test the plugin in a host app, sign it locally:

mix mob.plugin.sign && mix mob.validate_plugin

This writes priv/mob_plugin.sig, a v2 envelope: an Ed25519 signature over the SHA-256 of every file the native build reads from the plugin (the manifest, NIF sources and every other file in their directories, Kotlin and Swift files, migrations, fonts, images). The host checks those hashes before it evaluates the manifest, so a tampered manifest is never run, and it refuses a signature that leaves out any build input. Re-sign after changing any of them. Elixir code under lib/ is not a build input and is not covered; Hex's package checksum and mix.lock pin it.

mob_dev refuses legacy v1 signatures (plugin :<name> ships a legacy v1 signature). If you published a release signed before v2, re-sign with mix mob.plugin.sign and publish a new version.

priv/mob_plugin.sig is gitignored and never committed: you sign locally to develop, and the release workflow signs again, over exactly the files it publishes (see Publish).

7. Activate in a host app

Activation is two deliberate steps, plus trust:

# mix.exs
{:my_hooks, path: "plugins/my_hooks"}

# mob.exs
config :mob, :plugins, [..., :my_hooks]
mix deps.get
mix mob.plugin.trust my_hooks

mix mob.plugin.trust shows the plugin's fingerprint and declared capabilities, asks y/N, and records the key in config :mob, :trusted_plugins in mob.exs. A later release signed by a different key is refused until you trust it again. For an unsigned prototype, config :mob, :acknowledge_unsafe_plugins, [:my_hooks] lets the build through with a banner on every build.

App configuration for the plugin goes in the host's config/config.exs like any dependency's; it reaches the device (see Boot order):

config :my_hooks, greeting: "hello from config.exs"

Check what the host sees:

mix mob.plugins
  PLUGIN                    TIER    HOT-PUSH  VETTING         STATUS
  ──────────────────────────────────────────────────────────────────────────
  my_hooks                  tier 4  yes       clean           activated

8. Deploy and verify on a device

A BEAM-only plugin doesn't need a native rebuild. Regenerate the runtime plugin manifest (priv/generated/mob_plugins.exs, which the device reads at boot to find screens, lifecycle hooks, settings and notification handlers), then deploy:

mix mob.regen_plugin_manifest
mix mob.deploy --device <serial>

on_start runs at boot, so the app has to start for it to run. The deploy summary says which happened: Apps restarted (the plugin is live), or Hot-loaded into the running app — not restarted, in which case the old process is still running and you restart the app (mix mob.connect restarts it unless you pass --no-restart). Then read the device log (adb logcat -s Elixir on Android):

[mob] app config: loaded :mob_app_config (my_app: 1 key, mob: 2 keys, my_hooks: 1 key)
[my_hooks] OTP application started
[mob] plugin :my_hooks: OTP application started
[my_hooks] on_start, greeting="hello from config.exs"
[my_hooks] first frame from MyApp.HomeScreen

Press Home and bring the app back to see [my_hooks] on_background and [my_hooks] on_resume. On Android these need the lifecycle hook in the app's MainActivity.kt (mob_new 0.6.2+ generates it; mix mob.doctor says what to add to an older app).

From here, Elixir changes reach the running app without a restart: mix mob.deploy --device <serial> hot-loads over distribution and the app keeps its state.

9. Publish to Hex

The scaffold's .github/workflows/release.yml follows the first-party plugins' pattern (mob_wake, mob_deliver, mob_nfc): mix.exs is the source of truth for the version, and bumping it publishes. You never sign a release by hand.

One-time setup:

  1. Set @source_url in mix.exs to the plugin's GitHub repository.
  2. Commit priv/mob_plugin.pub (priv/mob_plugin.sig stays ignored).
  3. Add two repository secrets (Settings → Secrets and variables → Actions): HEX_API_KEY, and MOB_PLUGIN_SIGN_KEY, set to the exact contents of ~/.mob/keys/my_hooks.priv. The scaffold's next-steps output prints the gh secret set commands for both.

Then, for each release, add a ## [x.y.z] section to CHANGELOG.md, bump version: in mix.exs, and push to main or master. The workflow tags the version, creates a GitHub Release from the CHANGELOG section, and, unless that version is already on Hex:

  1. fails if @source_url is still the placeholder, if MOB_PLUGIN_SIGN_KEY is empty (it never publishes an unsigned plugin), or if the key doesn't derive the committed priv/mob_plugin.pub (a wrong secret would publish a signature no host can verify);
  2. runs mix mob.validate_plugin;
  3. writes the key to ~/.mob/keys/<name>.priv, runs mix mob.plugin.sign, and deletes the key;
  4. runs mix hex.publish --yes.

Each step skips what's already done, so re-running it from the Actions tab finishes a release that stopped part way. You can run the key check locally before adding the secret:

MOB_PLUGIN_SIGN_KEY="$(cat ~/.mob/keys/my_hooks.priv)" elixir -e '
priv = System.fetch_env!("MOB_PLUGIN_SIGN_KEY") |> String.trim() |> Base.decode64!()
{pub, _} = :crypto.generate_key(:eddsa, :ed25519, priv)
committed = "priv/mob_plugin.pub" |> File.read!() |> String.trim() |> Base.decode64!()
IO.puts(if pub == committed, do: "signing key matches priv/mob_plugin.pub", else: "MISMATCH")'

and check what the package will contain with mix hex.build (the file list must include priv/mob_plugin.exs, priv/mob_plugin.pub and, after signing, priv/mob_plugin.sig).

Hosts then depend on the Hex release and trust its key once, as in step 7:

{:my_hooks, "~> 0.1"}

Walkthrough: a native plugin

This builds my_math, a C NIF that adds two integers. Steps that are the same as for a BEAM-only plugin (test, validate, sign, activate, publish) are listed with what differs.

1. Scaffold

mix mob.new_plugin my_math --tier 1
plugins/my_math/
  mix.exs
  lib/my_math.ex                     # Elixir wrapper (defdelegate)
  src/my_math_nif.erl                # Erlang NIF stub, tolerant on_load
  priv/mob_plugin.exs                # manifest with :nifs
  priv/native/jni/my_math_nif.c      # the C NIF
  test/test_helper.exs
  test/my_math_test.exs
  README.md, CHANGELOG.md, .gitignore, .github/workflows/release.yml

For a native UI component use --tier 2 (a Mob.Component, a Kotlin Composable and a Swift view).

2. The manifest

nifs: [
  %{module: :my_math_nif, native_dir: "priv/native/jni"}
]

:module is the NIF's Erlang module name and a C token, not an Elixir module: ERL_NIF_INIT uses it as the registered module name and as the prefix of the static init symbol (my_math_nif_nif_init). :lang selects :c (the default), :zig or :objc. Permissions, Gradle dependencies, iOS frameworks, Info.plist keys, Kotlin/Swift bridges and host_requirements all go in the same manifest; see MOB_PLUGINS.md.

3. Implement

The C side registers functions under the module name:

#include <erl_nif.h>

static ERL_NIF_TERM add(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) {
  int a, b;
  (void)argc;
  if (!enif_get_int(env, argv[0], &a) || !enif_get_int(env, argv[1], &b))
    return enif_make_badarg(env);
  return enif_make_int(env, a + b);
}

static ErlNifFunc nif_funcs[] = {
    {"add", 2, add},
};

ERL_NIF_INIT(my_math_nif, nif_funcs, NULL, NULL, NULL, NULL)

The Erlang stub declares the same functions. Its on_load tolerates a load failure, so the module still loads on the host, where the NIF isn't linked:

-module(my_math_nif).
-export([add/2]).
-on_load(init/0).

init() ->
    case erlang:load_nif("my_math_nif", 0) of
        ok -> ok;
        {error, _} -> ok
    end.

add(_A, _B) -> erlang:nif_error(nif_not_loaded).
defmodule MyMath do
  defdelegate add(a, b), to: :my_math_nif
end

4–7. Test, validate, sign, activate

As for a BEAM-only plugin, with two differences:

  • Host tests can't call the NIF (:nif_not_loaded on the host). Test the Elixir around it: argument handling, parsing of what the NIF returns.

  • Signing covers the C source and every other file in native_dir, so re-run mix mob.plugin.sign after every native change. With a stale signature, mix mob.plugins prints skipping my_math: invalid_signature (and lists the plugin as having no manifest), and a native build stops:

    ** (Mix) plugin signature check failed — refusing to build (see MOB_PLUGIN_SECURITY.md, Phase 2):
    
      - signature for plugin :my_math is invalid — this can indicate
        tampering with the plugin's manifest or source files.

8. Deploy and verify on a device

Native code reaches the device only through a native build:

mix mob.deploy --native --device <serial>

The build compiles and links the NIF, merges permissions and other native contributions, regenerates the runtime plugin manifest and reinstalls the app. A plain mix mob.deploy after activating a NIF plugin warns that the installed app was built without it. Call the NIF on the device over distribution (see Agentic coding for connecting):

mix mob.connect --no-iex --no-restart --device <serial>
:rpc.call(:"my_app_android_emulator_5560@127.0.0.1", MyMath, :add, [40, 2])
#=> 42

Repeat for each platform you support. mix mob.validate_plugin warns when a UI component has only one platform's side.

9. Publish

As for a BEAM-only plugin. The scaffold's package/0 already ships src and all of priv, which the host's native build compiles from deps/<name>/priv/. Leaving a signed file out of the package fails verification on every host.

Boot order and configuration on device

A Mob app boots from its own BEAM entry, not a release, so the start/0 that use Mob.App generates 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, …). Logger is already running by then, so config :logger, level: … is applied with Logger.configure/1; settings Logger reads only at its own startup (:default_handler, :default_formatter, …) don't take effect. A config change reaches a running app at its next start, not on a hot push. See Mob.AppConfig.
  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, in activation order, then the plugins' supervised children. An on_start that raises or returns {:error, reason} fails the app's boot.
  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.

Foreground and background hooks

A manifest's lifecycle may declare on_resume and on_background MFAs. Mob.Plugins.Lifecycle subscribes to Mob.Device's :app events and calls on_resume on :did_become_active and on_background on :did_enter_background; a hook that raises is logged and the other plugins' hooks still run. On Android those events exist only when the app-owned MainActivity.kt and beam_jni.c carry the lifecycle hook (generated by mob_new 0.6.2+; mix mob.doctor says what to add to an older app). Without it the hooks never fire and Mob.Device.foreground?/0 stays true.

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 (a screen module or a route atom) before a navigation that mounts a screen (push_screen, reset_to; pops don't call it). 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). Hooks run in registration order and the first non-:ok answer wins; a hook that raises, exits or returns anything else counts as a refusal. 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 (signed OTA updates and just-in-time screens) is the reference plugin for these hooks and uses both: :before_navigate fetches a just-in-time screen (or resets to its forced-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). Its rollback relies on one more framework behaviour: on a device, an app left with no live screen (the root screen failed to start, or the current screen crashed past its restart limit) logs why and ends its process with System.halt(1), so the next launch is a cold boot.

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 for each template:

PluginKindDemonstrates
mob_palette_demoBEAM-onlyHelpers, no manifest, activated via mob.exs only
mob_demo_kitBEAM-onlyPure-Elixir composite components (<DemoCard>, <DemoCombobox>) via expand:
mob_demo_kv_browserBEAM-only (+ a font)Two screens + a migration + a bundled font + a plugin:// image
mob_demo_subappBEAM-onlyLifecycle hooks, supervised worker, settings, notification handler
mob_demo_gen_screensBEAM-onlySpec-v2 screens_generator and lifecycle/settings/notifications in one plugin
mob_demo_haptic_extrasNativeC NIF + iOS framework (CoreHaptics)
mob_demo_zig_extrasNativeZig NIF + Android Kotlin bridge
mob_demo_permNativeExtending the permission registry
mob_demo_signature_padNativeSwiftUI / Compose component

The shipped first-party packages are full-size references. BEAM-only: mob_deliver (OTP application, lifecycle and router hooks) and mob_ash (spec-v2 screens_generator against host config). Native: mob_camera (Objective-C + Zig + Kotlin + permission registry), mob_scanner (depends on mob_camera; an Activity host requirement), mob_nfc, mob_wake, and mob_notify (host_requirements). mob_themes is a style package. See the First-Party Packages catalog.

For coding agents

Checklist

Run each check and read its output; an exit code alone proves nothing.

StepCommandExpect
Scaffoldmix mob.new_plugin <name> --tier N (host root)plugins/<name>/ created
Host testsmix test (plugin dir)all pass
Validatemix mob.validate_plugin (plugin dir)✓ manifest valid
Sign (local)mix mob.plugin.keygen once, then mix mob.plugin.sign && mix mob.validate_plugin (plugin dir)fingerprint printed, ✓ manifest valid
Activatedeps + config :mob, :plugins + mix mob.plugin.trust <name> (host)mix mob.plugins shows clean / activated
Deploy, BEAM-onlymix mob.regen_plugin_manifest && mix mob.deploy --device <serial>app restarted, or hot-loaded (then restart it for on_start)
Deploy, nativemix mob.deploy --native --device <serial>BUILD SUCCESSFUL, Deployed to 1 device(s)
Bootdevice log[mob] plugin :<name>: OTP application started, your on_start line, no failed to start
Callmix mob.connect --no-iex --no-restart --device <serial>, then :rpc.call(node, Mod, :fun, args)the value, not {:badrpc, _} or :nif_not_loaded

Rules that bite

  • on_start must not raise, return {:error, _} or hang. It runs inside boot; any of those stops the app from starting. Rescue, log and return :ok. Do slow work in your OTP application's processes, not in on_start.
  • Router hooks run inside the router. A :before_navigate hook that raises refuses the navigation; one that blocks freezes navigation. Return :ok fast when there's nothing to do.
  • Read configuration at runtime with Application.get_env/3. The host's config/*.exs arrives through mob_app_config at boot, after your code was compiled. Use Application.compile_env/3 only for values that must be fixed at build time, such as a trust root (mob_deliver's :trusted_publish_key).
  • No compile-time ~r// literals. They compile to a call that OTP 28 removed, and the device runs OTP 28. Use Regex.compile!/2 at runtime.
  • NIF :module is a C token ([a-z][a-z0-9_]*), not an Elixir module.
  • Names are global across plugins. Screen routes, migration namespaces, supervised worker names, NIF modules, native view keys, plist keys and notification matches must be unique among the host's activated plugins. Prefix them with the plugin name.
  • Re-sign after changing any build input (manifest, native sources, Kotlin/Swift, migrations, assets). Changes under lib/ don't need it. A stale signature shows as skipping <name>: invalid_signature in mix mob.plugins, which then lists the plugin as tier 0 … no manifest; that line means "re-sign", not "the manifest is gone".
  • Never commit priv/mob_plugin.sig or a private key, and never sign a release by hand. The release workflow signs what it publishes. Commit priv/mob_plugin.pub only.
  • Always pass --device. Without it, mix mob.deploy picks an emulator or simulator by itself, which may be one someone else is using.
  • Native code needs --native. :nif_not_loaded on the device means the installed app was built without your NIF.
  • Settings are Mob.Plugins.get_setting/2 and Mob.Plugins.put_setting/3, validated against the schema's :type.