diff --git a/README.md b/README.md
index 56930f1..5be435a 100644
--- a/README.md
+++ b/README.md
@@ -57,6 +57,8 @@ configuration doc.
- [Installing][installing] — Composer, Strauss, and the constants Strauss must leave alone.
- [Configuration][configuration] — the hook prefix, the container, every sub-plugin key.
+- [Recipes][recipes] — a settings toggle, a manifest of add-ons, and staging the absorption across
+ releases.
- [Conflict handling][conflicts] — the policies, when they run, and the guard's limits.
- [Filters][filters] — the runtime overrides for policies and notice text.
- [Notices][notices] — where the queue lives, who may see it, and how to render it yourself.
@@ -69,6 +71,7 @@ source.
[installing]: https://github.com/stellarwp/plugin-absorber/blob/main/docs/installing.md
[configuration]: https://github.com/stellarwp/plugin-absorber/blob/main/docs/configuration.md
+[recipes]: https://github.com/stellarwp/plugin-absorber/blob/main/docs/recipes.md
[conflicts]: https://github.com/stellarwp/plugin-absorber/blob/main/docs/conflict-handling.md
[filters]: https://github.com/stellarwp/plugin-absorber/blob/main/docs/filters.md
[notices]: https://github.com/stellarwp/plugin-absorber/blob/main/docs/notices.md
diff --git a/docs/conflict-handling.md b/docs/conflict-handling.md
index 78c993b..986580d 100644
--- a/docs/conflict-handling.md
+++ b/docs/conflict-handling.md
@@ -123,7 +123,8 @@ yet at the moment the guard is read, and the bundled copy would load on top of i
**Version negotiation.** The library never compares versions, so it will not spare a standalone that
is newer than the bundled copy. Express that yourself: check the version and return
-`Conflict_Policy::DEFER` from the `conflict_policy` [filter](filters.md), which has the final say.
+`Conflict_Policy::DEFER` from the `conflict_policy` [filter](filters.md), which has the final say —
+[the recipe](recipes.md#defer-to-a-newer-standalone) is ten lines.
**Renamed standalone directories.** `standalone_plugin_basename` is the path as installed. A site
that renamed the standalone's directory is not detected, and there is no fallback that derives the
diff --git a/docs/recipes.md b/docs/recipes.md
new file mode 100644
index 0000000..e6a8912
--- /dev/null
+++ b/docs/recipes.md
@@ -0,0 +1,202 @@
+# Recipes
+
+The shapes hosts actually write. [Configuration](configuration.md) is the key-by-key reference
+behind them.
+
+## Toggle a sub-plugin from a setting
+
+Register unconditionally and put the condition in `enabled`. It is re-read on every request rather
+than resolved at registration, so the settings screen saves an option and does nothing else:
+
+```php
+Absorber::register( [
+ 'slug' => 'give-recurring',
+ 'bundled_plugin_file' => GIVE_PLUGIN_DIR . 'sub-plugins/give-recurring/give-recurring.php',
+ 'plugin_loaded_constant' => 'GIVE_RECURRING_VERSION',
+ 'standalone_plugin_basename' => 'give-recurring/give-recurring.php',
+ 'enabled' => static fn() => (bool) get_option( 'give_recurring_enabled', true ),
+] );
+```
+
+`enabled` is the first of five gates, and the only one ahead of the guard constant:
+
+```mermaid
+flowchart TD
+ A["enabled"] -->|false| S1["skipped, silently"]
+ A -->|true| B["plugin_loaded_constant already defined?"]
+ B -->|yes| S2["skipped: a copy is already running"]
+ B -->|no| C["dependency_check"]
+ C -->|false| S3["skipped, dependency notice queued"]
+ C -->|true| D["bundled file is readable?"]
+ D -->|no| S4["skipped, reported with _doing_it_wrong"]
+ D -->|yes| E["should_load filter"]
+ E -->|false| S5["skipped, silently"]
+ E -->|true| F["require_once"]
+ F --> G["activation_callback, once ever"]
+```
+
+Three things follow from where it sits.
+
+**Switching the toggle off unloads nothing.** The `require_once` on the current request already
+happened; the next request is the one that skips it. Anything that has to stop immediately — a
+feature, an endpoint — is the sub-plugin's own business to gate.
+
+**A disabled sub-plugin is invisible to conflict resolution as well.** `Conflict\Detector` asks
+`is_enabled()` before it asks anything else, so an off toggle also stops the standalone being
+deactivated. That is the intent: off means this library leaves the plugin alone, standalone
+included, and a site running the standalone keeps running it.
+
+**Keep the callable cheap.** It is called on the conflict pass and again on the load pass, so at
+least twice on an admin page view. An option read is fine; a remote licence check belongs behind a
+cached value.
+
+## Register several add-ons from one manifest
+
+One array, one loop — which is also how you control the order, since sub-plugins load in
+registration order and anything extended at include time has to be registered before its extender:
+
+```php
+$sub_plugins = [
+ [
+ 'slug' => 'give-recurring',
+ 'plugin_loaded_constant' => 'GIVE_RECURRING_VERSION',
+ 'standalone_plugin_basename' => 'give-recurring/give-recurring.php',
+ ],
+ [
+ 'slug' => 'give-stripe',
+ 'plugin_loaded_constant' => 'GIVE_STRIPE_VERSION',
+ 'standalone_plugin_basename' => 'give-stripe/give-stripe.php',
+ ],
+];
+
+foreach ( $sub_plugins as $sub_plugin ) {
+ Absorber::register(
+ $sub_plugin + [
+ 'bundled_plugin_file' => sprintf(
+ '%1$ssub-plugins/%2$s/%2$s.php',
+ GIVE_PLUGIN_DIR,
+ $sub_plugin['slug']
+ ),
+ 'enabled' => static fn( Sub_Plugin $sub ) => give_addon_is_enabled( $sub->get_slug() ),
+ ]
+ );
+}
+```
+
+An entry the library cannot use throws `Config_Exception` out of the `Absorber::register()` call it
+is in, so a typo names itself in a stack trace pointing at your loop rather than surfacing later
+from inside a core hook. A duplicate `slug` is the one that does surface later — the registrations
+are buffered and collide at the first read, on `plugins_loaded`.
+
+## Choose a policy, and know what the site owner sees
+
+The policy is only reached for a sub-plugin that is enabled, names a `standalone_plugin_basename`,
+and whose standalone is active right now:
+
+```mermaid
+flowchart TD
+ A["plugins_loaded 5: is the standalone active?"] -->|no| Z["nothing to resolve"]
+ A -->|yes| P{"conflict_policy"}
+ P -->|DEACTIVATE| D1["deactivate silently, queue the merge notice, redirect"]
+ P -->|NOTICE_ONLY| N1["queue a notice, leave the standalone running"]
+ P -->|DEFER| F1["do nothing at all"]
+ D1 --> D2["next request: the bundled copy loads"]
+ N1 --> G["this request: the bundled copy stands down on the guard constant"]
+ F1 --> G
+```
+
+| Policy | The standalone | This request | Afterwards |
+|---|---|---|---|
+| `DEACTIVATE` | turned off, silently, network-wide | its code is still in memory, so the bundled copy stands down; the user is redirected back to the screen they asked for | the bundled copy loads, and a merge notice explains the swap |
+| `NOTICE_ONLY` | left running | the bundled copy stands down | unchanged until someone acts on the notice |
+| `DEFER` | left running | the bundled copy stands down | unchanged, and nothing is said |
+
+The redirect under `DEACTIVATE` is why the deactivation is not silent to the user: it re-renders the
+screen with the standalone's code gone. It happens once per request no matter how many standalones
+were turned off, and only on an interactive admin `GET` that carries no `action` —
+[conflict handling](conflict-handling.md#when-resolution-runs) has the full gate list.
+
+## Ship the absorption over several releases
+
+Bundling the code and taking over from the standalone do not have to be the same release. Moving
+the `conflict_policy` one step per release lets a site be warned before anything of theirs is turned
+off:
+
+```mermaid
+flowchart LR
+ R1["Release 1
bundle it, DEFER
standalone sites unchanged,
everyone else gets the bundled copy"] --> R2
+ R2["Release 2
NOTICE_ONLY
standalone sites are asked
to deactivate it"] --> R3
+ R3["Release 3
DEACTIVATE
the remainder are merged,
and told so"]
+```
+
+Release 1 is the safe one to leave in place for a while: the bundled copy ships dormant on every
+site that has the standalone, which is exactly the population you are least sure about, and the
+guard constant is doing the work rather than any decision of yours. Release 3 is the only one that
+touches a site's active plugins.
+
+Nothing here needs a code change per release beyond the constant — or none at all, if the policy
+comes from a callable reading a value you can move without shipping:
+
+```php
+'conflict_policy' => static fn() => get_option( 'give_absorption_stage', Conflict_Policy::DEFER ),
+```
+
+An unrecognised value is treated as `NOTICE_ONLY`, never as consent to deactivate, so a stale or
+misspelt option cannot turn a plugin off.
+
+## Defer to a newer standalone
+
+The library never compares versions — deliberately, since "newer" is a question only the host can
+answer. Express it as a `conflict_policy` filter, which runs last and has the final say. The
+standalone's code is already loaded by the time this is asked, so its own version constant is there
+to read:
+
+```php
+add_filter( 'give/plugin_absorber/conflict_policy', static function ( $policy, $sub_plugin ) {
+ if ( $sub_plugin->get_slug() !== 'give-recurring' ) {
+ return $policy;
+ }
+
+ $standalone = defined( 'GIVE_RECURRING_VERSION' ) ? GIVE_RECURRING_VERSION : '0';
+
+ // Let a standalone that is ahead of the bundled copy keep the site.
+ return version_compare( $standalone, GIVE_RECURRING_BUNDLED_VERSION, '>' )
+ ? Conflict_Policy::DEFER
+ : $policy;
+}, 10, 2 );
+```
+
+`DEFER` leaves the standalone active, and the guard constant then stands the bundled copy down on
+its own. That is the whole mechanism: no version is stored anywhere, and the site converges the
+moment the bundled copy catches up.
+
+## Do per-site work on multisite
+
+Deactivation is network-wide, the notice queue is a network option, and so is the activation
+record — so `activation_callback` runs **once for the network**, in whichever site's request
+reached the load pass first. Per-site work loops:
+
+```php
+'activation_callback' => static function ( Sub_Plugin $sub_plugin ) {
+ if ( ! is_multisite() ) {
+ \Give\Recurring\Install::create_tables();
+
+ return;
+ }
+
+ foreach ( get_sites( [ 'fields' => 'ids', 'number' => 0 ] ) as $site_id ) {
+ switch_to_blog( $site_id );
+ \Give\Recurring\Install::create_tables();
+ restore_current_blog();
+ }
+},
+```
+
+That is the right shape for a handful of sites and the wrong one for a large network, where the
+loop runs inside `plugins_loaded` on one unlucky request. Bind `Activator_Interface` instead and
+record "once, ever" per site — a per-site option, or your own migration table — so each site pays
+only for itself.
+
+Write the callback to be idempotent either way. "Once, ever" is bookkeeping rather than a lock: the
+record is written after the callback returns, so a failure is retried, and two first requests
+arriving together can both run it.