# Writing a Plugin

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`](MOB_PLUGINS.md) and the trust model in
[`MOB_PLUGIN_SECURITY.md`](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.Screen`s;
- 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-only | Native |
|--|--|--|
| Reaches the device by | `mix 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 it | Only the Elixir half; native code needs a rebuild |
| Signing | Required once it has a manifest | Required; the signature also covers every native source |
| Platform parity | Free: the same BEAMs run on iOS and Android | You write and test both sides, or declare one platform |
| Testing | `mix test` on the host covers most of it | Host 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:

| Tier | Template ships | Kind |
|--|--|--|
| 0 | Helper module, no manifest | BEAM-only |
| 1 | C NIF + Erlang stub + Elixir wrapper | Native |
| 2 | Native UI component (Kotlin + Swift + `Mob.Component`) | Native (an `expand:` composite is tier 2 but BEAM-only) |
| 3 | Two screens + an Ecto migration | BEAM-only (fonts and images in `assets:` are copied by the native build) |
| 4 | Lifecycle hooks + supervised worker + settings + notification handler | BEAM-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

```bash
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:

```elixir
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`](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](#boot-order-and-configuration-on-device)), so the
supervision tree and its dependencies are up when `on_start` runs:

```elixir
# mix.exs
def application do
  [extra_applications: [:logger], mod: {MyHooks.Application, []}]
end
```

```elixir
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.

```elixir
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:

```bash
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:

```bash
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:

```bash
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](#9-publish-to-hex)). 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:

```bash
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](#9-publish-to-hex)).

### 7. Activate in a host app

Activation is two deliberate steps, plus trust:

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

# mob.exs
config :mob, :plugins, [..., :my_hooks]
```

```bash
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](#boot-order-and-configuration-on-device)):

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

Check what the host sees:

```bash
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:

```bash
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:

```bash
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:

```elixir
{: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

```bash
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

```elixir
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`](MOB_PLUGINS.md).

### 3. Implement

The C side registers functions under the module name:

```c
#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:

```erlang
-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).
```

```elixir
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:

```bash
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](agentic_coding.md) for connecting):

```bash
mix mob.connect --no-iex --no-restart --device <serial>
```

```elixir
: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.

```elixir
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 mob_dev 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.
Two exceptions for plist keys: a usage description (`*UsageDescription`) every
declaring plugin gives as a string is combined by mob_dev (≥ 0.7.19) so the
permission prompt gives every reason, and a scalar key the host's own
`ios/Info.plist` sets is not a clash, since the host's value wins.
See [`MOB_PLUGINS.md` → Cross-plugin conflict detection](MOB_PLUGINS.md) 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`](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:

| Plugin | Kind | Demonstrates |
|--|--|--|
| `mob_palette_demo` | BEAM-only | Helpers, no manifest, activated via `mob.exs` only |
| `mob_demo_kit` | BEAM-only | Pure-Elixir composite components (`<DemoCard>`, `<DemoCombobox>`) via `expand:` |
| `mob_demo_kv_browser` | BEAM-only (+ a font) | Two screens + a migration + a bundled font + a `plugin://` image |
| `mob_demo_subapp` | BEAM-only | Lifecycle hooks, supervised worker, settings, notification handler |
| `mob_demo_gen_screens` | BEAM-only | Spec-v2 `screens_generator` and lifecycle/settings/notifications in one plugin |
| `mob_demo_haptic_extras` | Native | C NIF + iOS framework (`CoreHaptics`) |
| `mob_demo_zig_extras` | Native | Zig NIF + Android Kotlin bridge |
| `mob_demo_perm` | Native | Extending the permission registry |
| `mob_demo_signature_pad` | Native | SwiftUI / 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](packages.md).

## For coding agents

### Checklist

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

| Step | Command | Expect |
|--|--|--|
| Scaffold | `mix mob.new_plugin <name> --tier N` (host root) | `plugins/<name>/` created |
| Host tests | `mix test` (plugin dir) | all pass |
| Validate | `mix 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` |
| Activate | deps + `config :mob, :plugins` + `mix mob.plugin.trust <name>` (host) | `mix mob.plugins` shows `clean` / `activated` |
| Deploy, BEAM-only | `mix mob.regen_plugin_manifest && mix mob.deploy --device <serial>` | app restarted, or hot-loaded (then restart it for `on_start`) |
| Deploy, native | `mix mob.deploy --native --device <serial>` | `BUILD SUCCESSFUL`, `Deployed to 1 device(s)` |
| Boot | device log | `[mob] plugin :<name>: OTP application started`, your `on_start` line, no `failed to start` |
| Call | `mix 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`.
