diff --git a/apps/docs/astro.config.mjs b/apps/docs/astro.config.mjs
index 3be7597df..7a35affd0 100644
--- a/apps/docs/astro.config.mjs
+++ b/apps/docs/astro.config.mjs
@@ -168,7 +168,6 @@ export default defineConfig({
},
{ slug: 'features/identity-console' },
{ slug: 'features/webhooks' },
- { slug: 'features/extensions' },
{ slug: 'features/plugins' },
{ slug: 'features/branding' },
{ slug: 'features/portal' },
@@ -178,6 +177,10 @@ export default defineConfig({
},
],
},
+ {
+ label: 'Migrating to Breeze',
+ items: [{ autogenerate: { directory: 'migration' } }],
+ },
{
label: 'Monitoring',
items: [
diff --git a/apps/docs/src/content/docs/migration/atera.mdx b/apps/docs/src/content/docs/migration/atera.mdx
new file mode 100644
index 000000000..d8e77e8b1
--- /dev/null
+++ b/apps/docs/src/content/docs/migration/atera.mdx
@@ -0,0 +1,152 @@
+---
+title: Atera → Breeze
+description: Export customers, agents and custom fields from Atera, deploy the Breeze agent via an Atera script, and remove the Atera agent cleanly.
+sidebar:
+ order: 8
+ label: Atera
+---
+
+import { Steps, Aside } from '@astrojs/starlight/components';
+
+Atera is the simplest migration in this section. Its API is a plain REST API with a single header for authentication, its scripts are ordinary PowerShell and batch, and its data model is shallow. A small Atera estate can be through Phase 3 in an afternoon.
+
+The one thing that needs thought is the **ticketing split**: Atera bundles a PSA. Breeze has its own [ticketing](/features/ticketing/) and also integrates with external PSAs, so you need to decide which you are moving to before you migrate anything else.
+
+Read [Migrating to Breeze](/migration/overview/) first.
+
+---
+
+## Hierarchy Mapping
+
+Atera is flat — Customers contain Agents, with an optional Site/Folder layer that many estates never use.
+
+| Atera | Breeze | Notes |
+|---|---|---|
+| Account | Partner | |
+| **Customer** | **Organization** | Direct match. |
+| Site / folder | **Site** | If unused, create one `Main` site per organization. |
+| Agent | Device | |
+| Contact | — | Breeze cannot create contacts programmatically today; enter them by hand or via your PSA. |
+
+---
+
+## Phase 0 — Export from Atera
+
+Generate an API key under **Admin → API**. Everything below uses `X-API-KEY` against `https://app.atera.com/api/v3`. Atera paginates at 50 items per page by default — mind `itemsInPage` and `totalPages` on every collection call.
+
+
+
+1. **Export customers** — your Breeze organization list:
+
+ ```bash
+ H="X-API-KEY: $ATERA_KEY"
+ echo 'organization,site' > tree.csv
+ page=1
+ while :; do
+ r=$(curl -sf -H "$H" "https://app.atera.com/api/v3/customers?page=$page&itemsInPage=50")
+ echo "$r" | jq -r '.items[] | [.CustomerName, "Main"] | @csv' >> tree.csv
+ [ "$page" -ge "$(echo "$r" | jq -r .totalPages)" ] && break
+ page=$((page+1))
+ done
+ ```
+
+ Feed `tree.csv` to [Recipe 1](/migration/toolkit/#recipe-1--bootstrap-the-tenancy-tree-from-csv).
+
+2. **Export agents** — your reconciliation checklist and licence audit:
+
+ ```bash
+ page=1
+ while :; do
+ r=$(curl -sf -H "$H" "https://app.atera.com/api/v3/agents?page=$page&itemsInPage=50")
+ echo "$r" | jq -r '.items[] | [.CustomerName, .MachineName, .OS,
+ .LastSeen, .Online] | @tsv'
+ [ "$page" -ge "$(echo "$r" | jq -r .totalPages)" ] && break
+ page=$((page+1))
+ done > atera-agents.tsv
+ ```
+
+3. **Export custom fields.** Atera custom values are read one field at a time per object:
+
+ ```bash
+ curl -sf -H "$H" \
+ "https://app.atera.com/api/v3/customvalues/agent/$AGENT_ID/$FIELD_NAME" | jq .
+ ```
+
+ List your configured fields in **Admin → Custom Fields** first, then loop that call across agents.
+
+4. **Export contacts and tickets if you are leaving Atera's PSA.** `GET /contacts` and `GET /tickets`. Ticket history does not migrate into Breeze — export it to storage for reference before you cancel.
+
+
+
+---
+
+## Phase 3 — Deploy the Breeze Agent with an Atera Script
+
+
+
+1. **Create the script.** **Admin → Scripts → New Script**, type **PowerShell**, and paste the Windows payload from [Recipe 3](/migration/toolkit/#recipe-3--the-push-payload). Atera scripts run as SYSTEM.
+
+2. **Parameterise the key.** Atera supports script parameters — declare `Server`, `Key`, and `Secret` and supply the per-customer enrollment key from [Recipe 2](/migration/toolkit/#recipe-2--mint-bulk-enrollment-keys) when you schedule.
+
+3. **Add AV/EDR exclusions in both directions** first — see [Antivirus Exceptions](/deploy/antivirus-exceptions/).
+
+4. **Schedule it as an automation profile, daily.** **Admin → Automation Profiles**, targeting one customer, daily for the length of your rollout window. The `agent.yaml` check makes repeat runs a no-op and the schedule sweeps up offline laptops.
+
+
+
+---
+
+## Migrating Scripts
+
+Atera's script library is plain PowerShell, batch, shell, and Python — bodies port directly.
+
+| Atera | Breeze |
+|---|---|
+| Script parameters | Script [parameters](/features/scripts/) |
+| PowerShell / Batch / Bash / Python | `language: powershell` \| `cmd` \| `bash` \| `python` |
+| Automation profiles | [Automations](/features/automations/) |
+| Script exit code | `exitCodeSeverityMapping` for severity by exit code |
+
+Bulk-load with [Recipe 6](/migration/toolkit/#recipe-6--bulk-script-import), `availability: "partner"`.
+
+---
+
+## Thresholds, Alerting, and the PSA Decision
+
+Atera's monitoring is configured through **Threshold Profiles** applied per customer or agent. Rebuild them as Breeze [monitors](/features/service-monitoring/) plus [alert rules](/features/alerts/), defined **partner-wide** so one profile covers your whole estate rather than one per customer.
+
+Then make the ticketing decision explicitly:
+
+- **Moving to Breeze ticketing.** Use [Breeze tickets](/features/ticketing/) and point alert rules at them. Atera ticket history does not migrate — export it and keep it as an archive.
+- **Moving to an external PSA.** Connect it via [PSA integrations](/features/psa-integrations/) and point alert rules there.
+
+Either way, disable Atera's alerting for a customer only after Breeze alerting is proven for that customer.
+
+---
+
+## Phase 6 — Decommission the Atera Agent
+
+Only after [Recipe 4](/migration/toolkit/#recipe-4--reconcile-enrollment) is clean for that customer.
+
+
+
+1. **Disable the customer's threshold profiles.** Leave the agent installed.
+2. **Wait one full patch cycle.**
+3. **Uninstall via Atera:**
+
+ ```powershell
+ $p = Get-CimInstance Win32_Product | Where-Object { $_.Name -like 'AteraAgent*' }
+ if ($p) { msiexec /x $p.IdentifyingNumber /qn /norestart }
+ Get-Service AteraAgent -ErrorAction SilentlyContinue | Stop-Service -Force
+ ```
+
+ On macOS and Linux, run Atera's supplied uninstall script from `/opt/AteraAgent/`.
+
+4. **Verify from Breeze.** [Recipe 5](/migration/toolkit/#recipe-5--find-endpoints-still-running-the-old-agent) — Breeze [Management Posture](/features/management-posture/) fingerprints Atera. Drive the count to zero.
+5. **Delete the customer in Atera** and reduce your device count, after exporting tickets and anything else you must retain.
+
+
+
+
diff --git a/apps/docs/src/content/docs/migration/connectwise-automate.mdx b/apps/docs/src/content/docs/migration/connectwise-automate.mdx
new file mode 100644
index 000000000..d4fb95e1a
--- /dev/null
+++ b/apps/docs/src/content/docs/migration/connectwise-automate.mdx
@@ -0,0 +1,183 @@
+---
+title: ConnectWise Automate → Breeze
+description: Export clients, locations, computers and EDFs from Automate (LabTech), push the Breeze agent via an Automate script, and remove LTService cleanly.
+sidebar:
+ order: 5
+ label: ConnectWise Automate
+---
+
+import { Steps, Aside } from '@astrojs/starlight/components';
+
+ConnectWise Automate (formerly LabTech) is the **most expensive migration** in this section, and it is worth being honest about why: its scripts, internal monitors, and remote monitors are all proprietary step-based objects with no export path into anything else. None of them port. Everything else — the client tree, the computer list, the EDFs — moves fine.
+
+Plan for re-authoring, not converting. In exchange, most MSPs find that a decade of accumulated Automate scripting compresses into a fraction of its original size.
+
+Read [Migrating to Breeze](/migration/overview/) first.
+
+---
+
+## Hierarchy Mapping
+
+| Automate | Breeze | Notes |
+|---|---|---|
+| Automate instance | Partner | |
+| **Client** | **Organization** | Direct match. |
+| **Location** | **Site** | Direct match. Automate always creates a default location per client. |
+| Computer group (static/auto-join) | Device Group | Auto-join groups map to Breeze [dynamic device groups](/features/device-groups/). |
+| Computer | Device | |
+| Contact | — | Breeze cannot create contacts programmatically today; enter them by hand or keep them in your PSA. |
+
+---
+
+## Phase 0 — Export from Automate
+
+You have two routes. The REST API is the supported one; direct SQL against the `labtech` MySQL database is faster and far more complete, and is what most migration projects actually use for a bulk export. Use SQL for the export, and the API for anything you need to write back.
+
+### Option A — Direct SQL (recommended for export)
+
+```sql
+-- Client → Location tree, ready for Recipe 1
+SELECT c.Name AS organization, l.Name AS site
+FROM clients c
+JOIN locations l ON l.ClientID = c.ClientID
+WHERE c.Name NOT IN ('Deleted Clients')
+ORDER BY c.Name, l.Name;
+```
+
+```sql
+-- Device inventory + last-contact, for reconciliation and licence cleanup
+SELECT c.Name AS client, l.Name AS location, comp.Name AS hostname,
+ comp.OS, comp.LastContact,
+ DATEDIFF(NOW(), comp.LastContact) AS days_stale
+FROM computers comp
+JOIN clients c ON c.ClientID = comp.ClientID
+JOIN locations l ON l.LocationID = comp.LocationID
+ORDER BY days_stale DESC;
+```
+
+```sql
+-- EDFs (Extra Data Fields) at computer scope
+SELECT comp.Name AS hostname, ef.Name AS field, ed.Value
+FROM extradatavalues ed
+JOIN extrafield ef ON ef.ID = ed.ExtraFieldID
+JOIN computers comp ON comp.ComputerID = ed.ExtraDataID
+WHERE ed.EDFType = 2 AND ed.Value <> '';
+```
+
+Export to CSV, reshape to `organization,site`, and feed [Recipe 1](/migration/toolkit/#recipe-1--bootstrap-the-tenancy-tree-from-csv).
+
+### Option B — REST API
+
+Automate's REST API lives at `https:///cwa/api/v1`, authenticated by `POST /apitoken` with an Automate username and password (and a two-factor code where enforced).
+
+```bash
+TOKEN=$(curl -sf -X POST "https://$AUTOMATE/cwa/api/v1/apitoken" \
+ -H 'Content-Type: application/json' \
+ -d "{\"UserName\":\"$USER\",\"Password\":\"$PASS\"}" | jq -r .AccessToken)
+
+curl -sf -H "Authorization: Bearer $TOKEN" \
+ "https://$AUTOMATE/cwa/api/v1/Clients?pageSize=1000" | jq -r '.[] | [.Id,.Name] | @tsv'
+curl -sf -H "Authorization: Bearer $TOKEN" \
+ "https://$AUTOMATE/cwa/api/v1/Computers?pageSize=1000" \
+ | jq -r '.[] | [.Client.Name, .Location.Name, .ComputerName, .OperatingSystemName, .LastContact] | @tsv'
+```
+
+
+
+---
+
+## Phase 3 — Deploy the Breeze Agent with an Automate Script
+
+
+
+1. **Create a script.** In the Automate Control Center: **Automation → Scripts → New Script**, script type **Computer Script**. You need exactly one function — a `Script Execute` / `Shell` step that runs PowerShell as SYSTEM (LTService already runs as SYSTEM, so no elevation is needed).
+
+2. **Use a script parameter for the key.** Define a `@breezekey@` script parameter and pass the per-location enrollment key from [Recipe 2](/migration/toolkit/#recipe-2--mint-bulk-enrollment-keys) when scheduling. Alternatively store the key in a location-level EDF and read it with `@edf(...)@` so one script serves every client.
+
+3. **Body:** the Windows PowerShell payload from [Recipe 3](/migration/toolkit/#recipe-3--the-push-payload). Keep the `agent.yaml` existence check — it is what makes the scheduled re-runs safe.
+
+
+
+4. **Add AV/EDR exclusions in both directions** before the push — see [Antivirus Exceptions](/deploy/antivirus-exceptions/).
+
+5. **Schedule against a group, daily.** Create an auto-join group for the target client and schedule the script daily for the length of your rollout window. This picks up machines that were offline, and — importantly for Automate estates — retries against machines where LTService is wedged and recovers on its next check-in.
+
+
+
+---
+
+## Scripts: Plan to Re-author
+
+Automate scripts are step lists stored in the database and exported as proprietary XML. There is no converter, and building one is not a good use of the migration budget.
+
+The practical approach:
+
+
+
+1. **Rank by actual use.** Query what has actually run:
+
+ ```sql
+ SELECT s.ScriptName, COUNT(*) AS runs, MAX(sl.DateRan) AS last_run
+ FROM scriptlogs sl JOIN scripts s ON s.ScriptId = sl.ScriptId
+ WHERE sl.DateRan > DATE_SUB(NOW(), INTERVAL 12 MONTH)
+ GROUP BY s.ScriptName ORDER BY runs DESC;
+ ```
+
+2. **Delete the tail.** Anything with zero runs in 12 months does not migrate. On a typical Automate estate this removes 70–85% of the library.
+
+3. **Check the Breeze system library** (`GET /scripts/system-library`) before re-authoring anything. Disk cleanup, service restart, printer spooler, profile cleanup, reboot-required checks — the standard Automate toolkit is largely already there.
+
+4. **Re-author what remains** as plain PowerShell or bash. Scripts whose steps were `Shell`, `Execute Script`, or `File Download` translate almost mechanically; scripts built from `If/Then` step logic against Automate's own database do not translate at all and should be reconsidered rather than reproduced.
+
+5. **Bulk-load** with [Recipe 6](/migration/toolkit/#recipe-6--bulk-script-import), `availability: "partner"`.
+
+
+
+---
+
+## Monitors
+
+Automate has two kinds and neither ports:
+
+- **Internal monitors** are SQL queries against the Automate database. They have no meaning outside Automate. Re-express the *intent* as Breeze [monitors](/features/service-monitoring/) and [alert rules](/features/alerts/).
+- **Remote monitors** are agent-side checks (service state, performance counter, event log, drive space). These map well onto Breeze's equivalents — service monitoring, [event log forwarding](/features/event-log-forwarding/), and disk thresholds.
+
+Rank by alert volume over the last 90 days and rebuild the top of the list partner-wide. Automate estates typically carry hundreds of monitors of which a dozen generate every ticket that mattered.
+
+---
+
+## EDFs → Custom Fields
+
+Map computer-scope EDFs to Breeze [custom fields](/features/custom-fields/), and client/location-scope EDFs to organization-level fields or your PSA. Backfill by joining the EDF export against Breeze devices on hostname.
+
+---
+
+## Phase 6 — Decommission LTService
+
+Only after [Recipe 4](/migration/toolkit/#recipe-4--reconcile-enrollment) is clean for that client.
+
+
+
+1. **Disable alerting** — remove the client's computers from monitor targets. Leave the agent installed.
+2. **Wait one full patch cycle.**
+3. **Uninstall via Automate.** Use the built-in *Agent Uninstall* script, or run ConnectWise's `Agent_Uninstall.exe` from the LTSVC directory:
+
+ ```powershell
+ $u = "$env:windir\LTSvc\Agent_Uninstall.exe"
+ if (Test-Path $u) { Start-Process $u -Wait }
+ ```
+
+ Automate agents are notoriously persistent. If the standard uninstaller leaves remnants, ConnectWise's own `LabTechUninstaller`/`Agent_Uninstall` cleanup routine removes the `LTService` and `LTSvcMon` services, `%windir%\LTSvc`, and the `HKLM\SOFTWARE\LabTech` keys.
+
+4. **Verify from Breeze.** [Recipe 5](/migration/toolkit/#recipe-5--find-endpoints-still-running-the-old-agent) — Breeze [Management Posture](/features/management-posture/) fingerprints both ConnectWise Automate and ScreenConnect independently, so it will tell you if the remote-access component survived the RMM uninstall. That distinction matters: leftover ScreenConnect is an unmanaged remote-access path into your customers' networks.
+5. **Delete the client in Automate** and reduce your agent count.
+
+
+
+
diff --git a/apps/docs/src/content/docs/migration/datto-rmm.mdx b/apps/docs/src/content/docs/migration/datto-rmm.mdx
new file mode 100644
index 000000000..efb4092a8
--- /dev/null
+++ b/apps/docs/src/content/docs/migration/datto-rmm.mdx
@@ -0,0 +1,191 @@
+---
+title: Datto RMM → Breeze
+description: Export sites, devices and UDFs from Datto RMM, deploy the Breeze agent via a Datto component, and decommission the CentraStage agent cleanly.
+sidebar:
+ order: 3
+ label: Datto RMM
+---
+
+import { Steps, Aside } from '@astrojs/starlight/components';
+
+Datto RMM (formerly CentraStage) is one of the easier migrations. Its API is clean and well-documented, its Components are ordinary PowerShell and bash with an environment-variable wrapper, and its job engine is a reliable way to push the Breeze agent.
+
+The two things that need real thought are **UDFs** and the **flat site model**.
+
+Read [Migrating to Breeze](/migration/overview/) first — this page only covers the Datto-specific parts.
+
+---
+
+## Hierarchy Mapping
+
+Datto RMM is flat: an account contains **Sites**, and a Site contains Devices. There is no client-above-site layer, so most MSPs use one Datto site per customer.
+
+| Datto RMM | Breeze | Notes |
+|---|---|---|
+| Account | Partner | Your MSP tenant. |
+| Site | **Organization** | One Breeze org per Datto site. |
+| — | Site | Create one site per org (name it `Main`) unless the customer genuinely has multiple locations. |
+| Device Group / filter | Device Group | Datto's saved device filters map to Breeze [dynamic device groups](/features/device-groups/). |
+| Device | Device | |
+
+
+
+---
+
+## Phase 0 — Export from Datto
+
+Datto RMM's API v2 is at `https://-api.centrastage.net/api/v2`, where `` matches your web console (`concord`, `merlot`, `pinotage`, `syrah`, `vidal`, `zinfandel`). Create an API key under **Setup → Users → your user → API Access**, which yields an API key and secret key.
+
+
+
+1. **Get a token** (OAuth2 password grant, with the fixed public client):
+
+ ```bash
+ ZONE=merlot
+ TOKEN=$(curl -sf -u 'public-client:public' \
+ -X POST "https://$ZONE-api.centrastage.net/auth/oauth/token" \
+ -d 'grant_type=password' \
+ --data-urlencode "username=$DATTO_API_KEY" \
+ --data-urlencode "password=$DATTO_SECRET_KEY" | jq -r .access_token)
+ ```
+
+2. **Export the site list** — this becomes your Breeze organization list:
+
+ ```bash
+ curl -sf -H "Authorization: Bearer $TOKEN" \
+ "https://$ZONE-api.centrastage.net/api/v2/account/sites?max=250" \
+ | jq -r '.sites[] | [.name, "Main"] | @csv' > tree.csv
+ sed -i '1i organization,site' tree.csv
+ ```
+
+ Feed `tree.csv` straight into [Recipe 1](/migration/toolkit/#recipe-1--bootstrap-the-tenancy-tree-from-csv).
+
+3. **Export devices per site** — your reconciliation checklist and your license clean-up list:
+
+ ```bash
+ for uid in $(curl -sf -H "Authorization: Bearer $TOKEN" \
+ "https://$ZONE-api.centrastage.net/api/v2/account/sites?max=250" | jq -r '.sites[].uid'); do
+ curl -sf -H "Authorization: Bearer $TOKEN" \
+ "https://$ZONE-api.centrastage.net/api/v2/site/$uid/devices?max=500" \
+ | jq -r --arg s "$uid" '.devices[] |
+ [$s, .hostname, .operatingSystem, .deviceType.category, .lastSeen, .online] | @tsv'
+ done > datto-devices.tsv
+ ```
+
+4. **Export UDFs.** Datto gives every device 30 user-defined fields (`udf1`…`udf30`), and they are usually load-bearing — warranty dates, asset tags, install dates. `GET /api/v2/device/{uid}` returns them under `udf`. Dump them before you lose access:
+
+ ```bash
+ curl -sf -H "Authorization: Bearer $TOKEN" \
+ "https://$ZONE-api.centrastage.net/api/v2/device/$DEVICE_UID" | jq '{hostname, udf}'
+ ```
+
+
+
+
+
+---
+
+## Phase 3 — Deploy the Breeze Agent with a Datto Component
+
+This is the fastest part of the migration and the reason to keep Datto running until the end.
+
+
+
+1. **Create a component.** In Datto RMM go to **Automation → Components → New Component**, category **Scripts**, and add two variables so one component serves every site:
+
+ | Variable | Type | Purpose |
+ |---|---|---|
+ | `BreezeServer` | Value | `https://breeze.yourdomain.com` |
+ | `BreezeKey` | Value | The per-site enrollment key from [Recipe 2](/migration/toolkit/#recipe-2--mint-bulk-enrollment-keys) |
+ | `BreezeSecret` | Value | Your `AGENT_ENROLLMENT_SECRET`, if configured |
+
+ Datto exposes component variables to the script as environment variables of the same name.
+
+2. **Paste the script.** Use the Windows PowerShell payload from [Recipe 3](/migration/toolkit/#recipe-3--the-push-payload), reading the variables from the environment:
+
+ ```powershell
+ $Server = $env:BreezeServer
+ $Key = $env:BreezeKey
+ $Secret = $env:BreezeSecret
+ ```
+
+ Datto components run as SYSTEM, so no elevation wrapper is needed.
+
+3. **Add antivirus exclusions first.** Push the [Breeze AV exclusions](/deploy/antivirus-exceptions/) — and add Datto's own paths to any Breeze-managed AV policy — *before* the enrollment job. Two RMM agents each running a watchdog is a well-known trigger for behavioural detections.
+
+4. **Run it as a scheduled job, not a quick job.** Create a **Job** targeting one site, scheduled daily for the length of your rollout window. The `agent.yaml` existence check makes re-runs a no-op, so the daily schedule quietly sweeps up laptops that were offline on the first pass. A one-shot Quick Job typically reaches 85–92% of a fleet; a two-week daily job reaches 98%+.
+
+5. **Roll in waves.** Pilot site → 10% of sites → the rest.
+
+
+
+---
+
+## Migrating Components
+
+Datto Components are ordinary scripts with a variable wrapper, so the bodies port with minimal edits.
+
+| Datto concept | Breeze equivalent |
+|---|---|
+| Component **variables** (`$env:VarName`) | Script [parameters](/features/scripts/) |
+| **stdout** → job output | Script output, captured per execution |
+| Exit code `0` = success | Same, plus `exitCodeSeverityMapping` to raise alerts by exit code |
+| `Write-Host '<-Start Result->…'` UDF writes | Breeze [custom fields](/features/custom-fields/) |
+| ComStore components | Check the Breeze [system script library](/features/scripts/) first |
+
+The one genuine rewrite is Datto's **result-to-UDF** convention — components that emit `<-Start Result->key=value<-End Result->` to write back into a UDF. In Breeze, write to a custom field via the API from within the script instead.
+
+Import the converted bodies in bulk with [Recipe 6](/migration/toolkit/#recipe-6--bulk-script-import). Set `availability: "partner"` so one copy serves every customer.
+
+---
+
+## Migrating UDFs
+
+
+
+1. Decide which of `udf1`…`udf30` are actually populated — usually 3 to 6 of them. Dump all thirty across a sample of 50 devices and count non-empty values.
+2. Create matching Breeze [custom fields](/features/custom-fields/) with real names and types. This is the moment to stop calling it `udf7`.
+3. Backfill by joining your Datto per-device UDF export against Breeze devices on hostname, then `POST` the values via the device custom-field-values endpoint — one of the few surfaces that does accept an `X-API-Key`.
+
+
+
+---
+
+## Monitors and Alerting
+
+Datto monitors live inside **Policies**, one monitor type per policy, targeted by site or device filter. Breeze splits this differently: [monitors](/features/service-monitoring/) define *what* is measured and [alert rules](/features/alerts/) define *when it fires and who hears about it*.
+
+Do not port one-for-one. Export your last 90 days of Datto alerts, sort by volume, and rebuild only what actually generated action. The top three alert types on most Datto fleets are disk space, offline, and patch failure — start there, and define them **partner-wide** so they apply to every organization at once.
+
+Re-point ticketing at the same time: connect your PSA to Breeze via [PSA integrations](/features/psa-integrations/), then disable Datto's PSA integration for that site so you are not double-ticketing.
+
+---
+
+## Phase 6 — Decommission the Datto Agent
+
+Only after [Recipe 4](/migration/toolkit/#recipe-4--reconcile-enrollment) shows zero unexplained gaps for that site.
+
+
+
+1. **Disable alerting** in the Datto policies bound to that site. Leave the agent installed.
+2. **Wait one full patch cycle** — a month. This is where you find the monitor you forgot.
+3. **Uninstall via Datto itself**, using the ComStore *Agent Removal* component, or a script step:
+
+ ```powershell
+ & "${env:ProgramFiles(x86)}\CentraStage\uninst.exe" /S
+ ```
+
+ On macOS, run Datto's supplied `uninstall.sh` from `/usr/local/share/CentraStage/`.
+
+4. **Verify from Breeze, not from Datto.** Run [Recipe 5](/migration/toolkit/#recipe-5--find-endpoints-still-running-the-old-agent) — Breeze's [Management Posture](/features/management-posture/) fingerprints Datto RMM directly, so it reports machines whose Datto agent was already broken and therefore invisible in the Datto console. Drive the count to zero.
+5. **Delete the site in Datto** and reduce your licence count. Export anything you are contractually required to retain first — site deletion is irreversible.
+
+
+
+
diff --git a/apps/docs/src/content/docs/migration/kaseya-vsa.mdx b/apps/docs/src/content/docs/migration/kaseya-vsa.mdx
new file mode 100644
index 000000000..642c7300d
--- /dev/null
+++ b/apps/docs/src/content/docs/migration/kaseya-vsa.mdx
@@ -0,0 +1,162 @@
+---
+title: Kaseya VSA → Breeze
+description: Export organizations, machine groups and agents from Kaseya VSA, push the Breeze agent via an Agent Procedure, and remove the Kaseya agent cleanly.
+sidebar:
+ order: 6
+ label: Kaseya VSA
+---
+
+import { Steps, Aside } from '@astrojs/starlight/components';
+
+Kaseya VSA migrations are dominated by two facts: **Agent Procedures do not port** (they are proprietary step lists, like Automate scripts), and the **machine-group naming model** is unlike anything else. Device and org data export cleanly through the REST API.
+
+This page covers VSA 9 (on-premises and classic SaaS) and VSA X where the concepts differ.
+
+Read [Migrating to Breeze](/migration/overview/) first.
+
+---
+
+## Hierarchy Mapping
+
+Kaseya's identity for a machine is `machineName.groupName.orgName` — the machine group is embedded in the agent's identity, which is why re-organising machines in VSA is painful and why migration is a good moment to fix your structure.
+
+| Kaseya VSA | Breeze | Notes |
+|---|---|---|
+| VSA instance | Partner | |
+| **Organization** | **Organization** | Direct match. |
+| **Machine Group** | **Site** | Machine groups are usually locations or departments. Nested subgroups (`hq.acme`) flatten to one site each. |
+| View / filter | Device Group | VSA Views map to Breeze [dynamic device groups](/features/device-groups/). |
+| Agent | Device | |
+
+
+
+---
+
+## Phase 0 — Export from VSA
+
+VSA's REST API is at `https:///api/v1.0`. Authentication is a two-step token exchange using a Base64 `Basic` header built from your username and a SHA-256 hash of the password concatenated with a random string — awkward enough that most people generate the token once from the VSA UI (**System → User Security → Users → REST API**) and paste it.
+
+
+
+1. **Export the org → machine group tree:**
+
+ ```bash
+ AUTH="Authorization: Bearer $VSA_TOKEN"
+
+ curl -sf -H "$AUTH" "https://$VSA/api/v1.0/system/orgs?\$top=1000" \
+ | jq -r '.Result[] | [.OrgId, .OrgName] | @tsv' > vsa-orgs.tsv
+
+ curl -sf -H "$AUTH" "https://$VSA/api/v1.0/system/machinegroups?\$top=5000" \
+ | jq -r '.Result[] | [.OrgName, .MachineGroupName] | @csv' > tree.csv
+ sed -i '1i organization,site' tree.csv
+ ```
+
+ Feed `tree.csv` to [Recipe 1](/migration/toolkit/#recipe-1--bootstrap-the-tenancy-tree-from-csv).
+
+2. **Export agents** — your reconciliation checklist:
+
+ ```bash
+ curl -sf -H "$AUTH" "https://$VSA/api/v1.0/assetmgmt/agents?\$top=10000" \
+ | jq -r '.Result[] | [.OrgName, .GroupName, .ComputerName,
+ .OSName, .LastCheckInTime, .Online] | @tsv' > vsa-agents.tsv
+ ```
+
+3. **Export custom fields:**
+
+ ```bash
+ curl -sf -H "$AUTH" "https://$VSA/api/v1.0/assetmgmt/assets/customfields" | jq .
+ ```
+
+4. **List your Agent Procedures for triage** (you will re-author, not import):
+
+ ```bash
+ curl -sf -H "$AUTH" "https://$VSA/api/v1.0/automation/agentprocs?\$top=2000" \
+ | jq -r '.Result[] | [.AgentProcedureId, .AgentProcedureName] | @tsv'
+ ```
+
+
+
+---
+
+## Phase 3 — Deploy the Breeze Agent with an Agent Procedure
+
+
+
+1. **Create the procedure.** **Agent Procedures → Manage Procedures → New Procedure**. You need one step: `Execute Shell Command` (or `Execute PowerShell Command (64-bit)` on modern VSA), run as **System**.
+
+2. **Parameterise the enrollment key.** Define a procedure variable for the per-machine-group key from [Recipe 2](/migration/toolkit/#recipe-2--mint-bulk-enrollment-keys). If you prefer one procedure for the whole estate, store the key in a custom field per machine group and read it with `#customfield#`.
+
+3. **Body:** the Windows PowerShell payload from [Recipe 3](/migration/toolkit/#recipe-3--the-push-payload).
+
+
+
+4. **Add AV/EDR exclusions in both directions** first — see [Antivirus Exceptions](/deploy/antivirus-exceptions/).
+
+5. **Schedule it, don't run it once.** Schedule the procedure against one machine group with **Distribution Window** spread over a few hours and a **daily recurrence** for the length of your rollout window. Enable *Skip if offline* with retry so the schedule sweeps up laptops. The `agent.yaml` check keeps re-runs harmless.
+
+
+
+---
+
+## Agent Procedures: Plan to Re-author
+
+Agent Procedures are step-based XML. There is no path from a procedure into a PowerShell script, and writing a converter is not worth it.
+
+
+
+1. **Rank by use.** VSA's *Agent Procedure Status* and script logs show what has actually executed in the last year. Anything at zero does not migrate.
+2. **Check the Breeze system library first** — `GET /scripts/system-library`. The standard maintenance procedures are already there.
+3. **Re-author the survivors** as PowerShell or bash. Procedures built from `executeShellCommand` / `writeFile` steps translate almost directly; procedures built from VSA's `getVariable`/`if` step logic need rethinking rather than reproducing.
+4. **Bulk-load** with [Recipe 6](/migration/toolkit/#recipe-6--bulk-script-import), `availability: "partner"`.
+
+
+
+---
+
+## Monitor Sets and Patching
+
+| Kaseya VSA | Breeze |
+|---|---|
+| Monitor Sets (counter/service/process) | [Monitors](/features/service-monitoring/) + [alert rules](/features/alerts/) |
+| Event Log Sets | [Event log forwarding](/features/event-log-forwarding/) with alert rules |
+| Alarms → tickets | Alert rules bound to a [PSA integration](/features/psa-integrations/) |
+| Patch Management policies | [Patch policies](/features/patch-management/) |
+| Patch approval by classification | [Update rings](/features/update-rings/) |
+| Machine group scheduling | Site timezone + [maintenance windows](/features/maintenance-windows/) |
+
+Rebuild the top of your alarm-volume list partner-wide rather than porting monitor sets one for one.
+
+---
+
+## Phase 6 — Decommission the Kaseya Agent
+
+Only after [Recipe 4](/migration/toolkit/#recipe-4--reconcile-enrollment) is clean for that organization.
+
+
+
+1. **Suspend alarms** for the machine group (**Agent → Suspend Alarms**). Leave the agent installed.
+2. **Wait one full patch cycle.**
+3. **Uninstall via VSA.** The supported route is **Agent → Uninstall Agent**, which removes the agent and deletes the account. To do it from a procedure instead:
+
+ ```powershell
+ $s = Get-Service -Name 'KaseyaAgent*' -ErrorAction SilentlyContinue
+ foreach ($svc in $s) {
+ $dir = (Get-CimInstance Win32_Service -Filter "Name='$($svc.Name)'").PathName -replace '^"?([^"]+)\\[^\\]+$','$1'
+ if (Test-Path "$dir\KASetup.exe") { & "$dir\KASetup.exe" /r /s /g }
+ }
+ ```
+
+ On macOS, run `/Library/Kaseya/*/Uninstaller.app` or Kaseya's supplied uninstall script.
+
+4. **Verify from Breeze.** [Recipe 5](/migration/toolkit/#recipe-5--find-endpoints-still-running-the-old-agent) — Breeze [Management Posture](/features/management-posture/) fingerprints Kaseya VSA. Drive the count to zero.
+5. **Delete the organization in VSA** and reduce your agent count, after exporting anything you must retain.
+
+
+
+
diff --git a/apps/docs/src/content/docs/migration/n-central.mdx b/apps/docs/src/content/docs/migration/n-central.mdx
new file mode 100644
index 000000000..71c7dfcad
--- /dev/null
+++ b/apps/docs/src/content/docs/migration/n-central.mdx
@@ -0,0 +1,157 @@
+---
+title: N-able N-central → Breeze
+description: Export customers, sites and devices from N-central, deploy the Breeze agent via a scheduled AMP, and remove the N-able agent and probe cleanly.
+sidebar:
+ order: 7
+ label: N-able N-central
+---
+
+import { Steps, Aside } from '@astrojs/starlight/components';
+
+N-central migrations have one structural wrinkle nobody expects: the **probe**. Alongside a per-endpoint agent, N-central deploys one or more Windows probes per site that perform network discovery and agentless monitoring of switches, printers, and ESXi hosts. Migrating the agents is straightforward; deciding what replaces the probe's agentless coverage is the part that gets forgotten until cutover.
+
+Service Templates and AMPs are the other two N-central-specific pieces. This page covers both.
+
+Read [Migrating to Breeze](/migration/overview/) first.
+
+---
+
+## Hierarchy Mapping
+
+| N-central | Breeze | Notes |
+|---|---|---|
+| Service Organization (SO) | Partner | |
+| **Customer** | **Organization** | Direct match. |
+| **Site** | **Site** | Direct match. Many N-central estates only use the implicit single site per customer. |
+| Device filter | Device Group | N-central filters map to Breeze [dynamic device groups](/features/device-groups/). |
+| Device | Device | |
+| **Probe** | — | Replaced by Breeze [network discovery](/features/discovery/) and [SNMP monitoring](/features/snmp/). See below. |
+
+---
+
+## Phase 0 — Export from N-central
+
+N-central exposes a legacy SOAP API and a newer REST API (`/api/…`, JWT-authenticated via a user's API access token generated under **Administration → User Management → your user → API Access**). The REST API is the better choice for a migration export; the SOAP API remains the only way to reach some older endpoints.
+
+
+
+1. **Authenticate** and exchange your API-User token for a JWT:
+
+ ```bash
+ NC=ncentral.yourdomain.com
+ TOKEN=$(curl -sf -X POST "https://$NC/api/auth/authenticate" \
+ -H "Authorization: Bearer $NC_API_USER_TOKEN" | jq -r .tokens.access.token)
+ ```
+
+2. **Export the customer → site tree:**
+
+ ```bash
+ curl -sf -H "Authorization: Bearer $TOKEN" \
+ "https://$NC/api/org-units?pageSize=1000" > orgunits.json
+
+ echo 'organization,site' > tree.csv
+ jq -r '
+ [.data[] | select(.orgUnitType=="CUSTOMER")] as $c
+ | .data[] | select(.orgUnitType=="SITE") as $s
+ | ($c[] | select(.orgUnitId == $s.parentId)) as $parent
+ | [$parent.orgUnitName, $s.orgUnitName] | @csv' orgunits.json >> tree.csv
+ ```
+
+ Customers with no child site rows are single-site — emit them with a `Main` site.
+
+3. **Export devices** — your reconciliation checklist:
+
+ ```bash
+ curl -sf -H "Authorization: Bearer $TOKEN" \
+ "https://$NC/api/devices?pageSize=5000" \
+ | jq -r '.data[] | [.customerName, .siteName, .longName,
+ .osName, .deviceClass, .lastLoggedInUser] | @tsv' > nc-devices.tsv
+ ```
+
+ Filter `deviceClass` — N-central inventories network gear and printers discovered by the probe alongside real agent-managed endpoints. Only the agent-managed rows are migration targets; the rest belong in the probe-replacement plan below.
+
+4. **Export custom properties**, which N-central carries at customer, site, and device level:
+
+ ```bash
+ curl -sf -H "Authorization: Bearer $TOKEN" \
+ "https://$NC/api/devices/$DEVICE_ID/custom-properties" | jq .
+ ```
+
+5. **Export Service Templates for reference.** You will rebuild these rather than import them, but you need the effective thresholds to hand.
+
+
+
+---
+
+## Phase 3 — Deploy the Breeze Agent with an AMP
+
+
+
+1. **Build the AMP.** N-central's Automation Manager (a desktop authoring tool) produces `.amp` files. You need a single **Execute PowerShell Script** object; the AMP is just a wrapper around it.
+
+2. **Parameterise the enrollment key.** Expose `BreezeServer`, `BreezeKey`, and `BreezeSecret` as AMP input parameters so one AMP serves every site, and supply the per-site key from [Recipe 2](/migration/toolkit/#recipe-2--mint-bulk-enrollment-keys) when you schedule it.
+
+3. **Body:** the Windows PowerShell payload from [Recipe 3](/migration/toolkit/#recipe-3--the-push-payload). AMPs execute as SYSTEM under the agent, so no elevation wrapper is needed.
+
+4. **Add AV/EDR exclusions in both directions** first — see [Antivirus Exceptions](/deploy/antivirus-exceptions/).
+
+5. **Upload and schedule.** **Configuration → Scheduled Tasks → Add → Automation Policy**, targeting one customer, on a **daily** recurrence for the length of your rollout window. Set the task to retry on failure. The `agent.yaml` check makes repeat runs a no-op.
+
+6. **Roll in waves.** Pilot customer → 10% → the rest.
+
+
+
+---
+
+## Replacing the Probe
+
+This is the N-central-specific planning item. Before cutover, enumerate what your probes actually monitor — it is almost always more than people remember.
+
+| Probe function | Breeze replacement |
+|---|---|
+| Network discovery / new-device detection | [Network discovery](/features/discovery/) |
+| SNMP monitoring of switches, firewalls, UPSes | [SNMP monitoring](/features/snmp/) |
+| Agentless printer / device status | SNMP monitors |
+| ESXi / Hyper-V host monitoring | Agent on the host where supported; SNMP otherwise |
+| WMI-based agentless Windows checks | Install the Breeze agent — there is no agentless Windows path |
+
+Breeze's discovery and SNMP monitoring run from an enrolled agent acting as the collector on that network, so **designate a persistent, always-on machine per site** — the same box that runs the N-central probe today is usually the right choice. Do this in Phase 5, before you decommission, so you are never without network visibility.
+
+
+
+---
+
+## Service Templates and AMPs
+
+**Service Templates** are N-central's bundle of service monitors with thresholds, applied by device class or filter. Rebuild them as Breeze [monitors](/features/service-monitoring/) plus [alert rules](/features/alerts/), defined partner-wide so one definition covers every customer.
+
+The translation is usually a simplification. A typical N-central estate runs a dozen templates that differ only in threshold values; in Breeze those become one partner-wide monitor set with an organization-level override where a customer genuinely differs.
+
+**AMPs** are XML wrappers, and the useful part — the embedded PowerShell or VBScript — is extractable. Open the `.amp` in Automation Manager, copy the script object bodies out, and load them with [Recipe 6](/migration/toolkit/#recipe-6--bulk-script-import). AMPs built entirely from Automation Manager's own drag-and-drop objects (rather than a script object) do not port and need re-authoring.
+
+---
+
+## Phase 6 — Decommission the N-able Agent
+
+Only after [Recipe 4](/migration/toolkit/#recipe-4--reconcile-enrollment) is clean for that customer, **and** the probe's agentless coverage has been replaced.
+
+
+
+1. **Disable notifications** for the customer at the SO level. Leave the agent installed.
+2. **Wait one full patch cycle.**
+3. **Uninstall via N-central.** Deleting a device in N-central triggers agent removal. To do it by AMP:
+
+ ```powershell
+ $p = Get-CimInstance Win32_Product |
+ Where-Object { $_.Name -match 'Windows Agent|N-able|SolarWinds MSP' }
+ foreach ($x in $p) { msiexec /x $x.IdentifyingNumber /qn /norestart }
+ ```
+
+ Remove the **probe** separately — it is a distinct install (*Windows Probe*) on a different machine.
+
+4. **Verify from Breeze.** [Recipe 5](/migration/toolkit/#recipe-5--find-endpoints-still-running-the-old-agent) — Breeze [Management Posture](/features/management-posture/) fingerprints N-able. Drive the count to zero, and check the probe machines explicitly since they will not appear in an agent-only list.
+5. **Delete the customer in N-central** and reduce your licence count, after exporting anything you must retain.
+
+
diff --git a/apps/docs/src/content/docs/migration/ninjaone.mdx b/apps/docs/src/content/docs/migration/ninjaone.mdx
new file mode 100644
index 000000000..5e763eea9
--- /dev/null
+++ b/apps/docs/src/content/docs/migration/ninjaone.mdx
@@ -0,0 +1,170 @@
+---
+title: NinjaOne → Breeze
+description: Export organizations, locations, devices and custom fields from NinjaOne, deploy the Breeze agent via a scheduled Ninja script, and decommission the NinjaRMMAgent.
+sidebar:
+ order: 4
+ label: NinjaOne
+---
+
+import { Steps, Aside } from '@astrojs/starlight/components';
+
+NinjaOne is the cleanest migration of any RMM in this section. Its hierarchy maps onto Breeze's one-for-one, its API is a well-behaved OAuth2 REST API, and its scripts are plain PowerShell and shell. Most of the effort goes into **policy inheritance**, which has no direct Breeze equivalent and generally simplifies on the way across.
+
+Read [Migrating to Breeze](/migration/overview/) first.
+
+---
+
+## Hierarchy Mapping
+
+| NinjaOne | Breeze | Notes |
+|---|---|---|
+| Instance | Partner | |
+| **Organization** | **Organization** | Direct match. |
+| **Location** | **Site** | Direct match, including the "Main Office" default. |
+| Device role / group | Device Group | Ninja's device roles map well to Breeze [dynamic device groups](/features/device-groups/). |
+| Device | Device | |
+
+This is as close to a lift-and-shift as RMM tenancy gets.
+
+---
+
+## Phase 0 — Export from NinjaOne
+
+Create an API client under **Administration → Apps → API**, with grant type **Client Credentials** and the `monitoring` and `management` scopes. Your region determines the host — `app.ninjarmm.com` (US), `eu.ninjarmm.com`, `oc.ninjarmm.com`, `ca.ninjarmm.com`.
+
+
+
+1. **Get a token:**
+
+ ```bash
+ HOST=app.ninjarmm.com
+ TOKEN=$(curl -sf -X POST "https://$HOST/ws/oauth/token" \
+ -d grant_type=client_credentials \
+ -d "client_id=$NINJA_CLIENT_ID" \
+ -d "client_secret=$NINJA_CLIENT_SECRET" \
+ -d 'scope=monitoring management' | jq -r .access_token)
+ ```
+
+2. **Export the org → location tree.** This is your Breeze tenancy CSV, produced directly:
+
+ ```bash
+ curl -sf -H "Authorization: Bearer $TOKEN" "https://$HOST/v2/organizations" > orgs.json
+ curl -sf -H "Authorization: Bearer $TOKEN" "https://$HOST/v2/locations" > locs.json
+
+ echo 'organization,site' > tree.csv
+ jq -r --slurpfile o orgs.json '
+ .[] as $l | ($o[0][] | select(.id == $l.organizationId)) as $org
+ | [$org.name, $l.name] | @csv' locs.json >> tree.csv
+ ```
+
+ Feed it to [Recipe 1](/migration/toolkit/#recipe-1--bootstrap-the-tenancy-tree-from-csv).
+
+3. **Export devices** — your reconciliation checklist:
+
+ ```bash
+ curl -sf -H "Authorization: Bearer $TOKEN" \
+ "https://$HOST/v2/devices-detailed" \
+ | jq -r '.[] | [.organizationId, .locationId, .systemName,
+ .nodeClass, .offline, .lastContact] | @tsv' > ninja-devices.tsv
+ ```
+
+4. **Export custom fields.** Ninja custom fields exist at global, organization, location and device scope. Dump the device-scoped values, which are the ones that usually matter:
+
+ ```bash
+ curl -sf -H "Authorization: Bearer $TOKEN" \
+ "https://$HOST/v2/queries/custom-fields" | jq . > ninja-custom-fields.json
+ ```
+
+5. **Export policies for reference:** `GET /v2/policies`. You are not going to import these, but you need them open in a second window while you rebuild.
+
+
+
+---
+
+## Phase 3 — Deploy the Breeze Agent with a Ninja Script
+
+
+
+1. **Create the script.** **Administration → Library → Automation → Create → PowerShell**, run as **System**. Use the Windows payload from [Recipe 3](/migration/toolkit/#recipe-3--the-push-payload).
+
+ Ninja passes script variables as parameters, so parameterise the enrollment key rather than hardcoding it:
+
+ ```powershell
+ param(
+ [string]$Server = 'https://breeze.yourdomain.com',
+ [string]$Key,
+ [string]$Secret
+ )
+ ```
+
+ You can also read a Ninja custom field with `Ninja-Property-Get`, which lets you store the per-location enrollment key as an organization custom field and have one script resolve it automatically. That is the tidiest approach if you have more than a handful of customers.
+
+2. **Add AV/EDR exclusions first** — both directions. See [Antivirus Exceptions](/deploy/antivirus-exceptions/).
+
+3. **Schedule it, don't run it once.** Create a **Scheduled Task** targeting one organization, running daily. The `agent.yaml` check makes it idempotent, so it sweeps up offline laptops over the following days. A single manual run reliably misses 8–15% of a fleet.
+
+4. **Roll in waves.** Pilot org → 10% → the rest.
+
+
+
+---
+
+## Migrating Scripts
+
+Ninja's automation library is PowerShell, batch, and shell, so bodies port directly. Rewrite the wrapper:
+
+| NinjaOne | Breeze |
+|---|---|
+| Script parameters / `$env:` variables | Script [parameters](/features/scripts/) |
+| `Ninja-Property-Get` / `Ninja-Property-Set` | [Custom field](/features/custom-fields/) reads/writes via the API |
+| Exit code `0` = success | Same, plus `exitCodeSeverityMapping` for severity by exit code |
+| Run As: System / Logged-on user | `runAs: "system"` / `"user"` |
+| Script categories | `category` field |
+
+The only real work is `Ninja-Property-Get` / `Ninja-Property-Set`, which have no drop-in replacement — replace with an authenticated call to the Breeze device custom-field-values endpoint.
+
+Bulk-load the converted scripts with [Recipe 6](/migration/toolkit/#recipe-6--bulk-script-import), setting `availability: "partner"`.
+
+---
+
+## Policies: The Part That Needs Design
+
+This is where NinjaOne migrations take their time. Ninja uses **nested, inheriting policies** — a parent policy with child overrides per organization or device role. Breeze does not model inheritance that way. Instead it is **partner-wide first**: a policy is owned by the partner and applies across every organization, or it is owned by one organization.
+
+The translation:
+
+| NinjaOne | Breeze |
+|---|---|
+| Base policy applied everywhere | Partner-wide [configuration policy](/features/configuration-policies/) |
+| Per-role child policy (Workstation / Server) | Separate partner-wide policy bound to a dynamic [device group](/features/device-groups/) by role |
+| Per-organization override | Organization-owned policy |
+| Policy conditions → alerts | [Monitors](/features/service-monitoring/) + [alert rules](/features/alerts/) |
+| Patching section of a policy | [Patch policies](/features/patch-management/) and [update rings](/features/update-rings/) |
+
+
+
+---
+
+## Phase 6 — Decommission NinjaRMMAgent
+
+Only after [Recipe 4](/migration/toolkit/#recipe-4--reconcile-enrollment) is clean for that organization.
+
+
+
+1. **Disable alerting** on the Ninja policies bound to that org; leave the agent installed.
+2. **Wait one full patch cycle.**
+3. **Uninstall via Ninja.** The supported path is to delete the device in the Ninja console, which triggers agent removal. To do it by script:
+
+ ```powershell
+ $p = Get-CimInstance Win32_Product | Where-Object { $_.Name -like 'NinjaRMMAgent*' }
+ if ($p) { msiexec /x $p.IdentifyingNumber /qn /norestart }
+ ```
+
+ On macOS, run Ninja's `/Applications/NinjaRMMAgent/uninstall.sh`.
+
+4. **Verify from Breeze.** [Recipe 5](/migration/toolkit/#recipe-5--find-endpoints-still-running-the-old-agent) — Breeze [Management Posture](/features/management-posture/) fingerprints NinjaOne. Drive the count to zero. This catches the machines whose Ninja agent was already dead and which the Ninja console therefore cannot report on.
+5. **Remove the organization in Ninja** and reduce your licence count, after exporting anything you must retain.
+
+
diff --git a/apps/docs/src/content/docs/migration/other-rmms.mdx b/apps/docs/src/content/docs/migration/other-rmms.mdx
new file mode 100644
index 000000000..2465fbf17
--- /dev/null
+++ b/apps/docs/src/content/docs/migration/other-rmms.mdx
@@ -0,0 +1,162 @@
+---
+title: Other RMMs → Breeze
+description: Migration notes for Pulseway, Action1, Automox, Level, Tactical RMM, ScreenConnect and any RMM without a dedicated guide.
+sidebar:
+ order: 10
+ label: Other RMMs
+---
+
+import { Steps, Aside } from '@astrojs/starlight/components';
+
+The [overview playbook](/migration/overview/) and the [Migration Toolkit](/migration/toolkit/) are vendor-neutral, so they carry a migration from any RMM. This page holds the vendor-specific notes for platforms without a dedicated guide, and a checklist for adapting the playbook to one that is not listed at all.
+
+---
+
+## Pulseway
+
+Pulseway has the richest hierarchy of any RMM here, and it maps onto Breeze exactly.
+
+| Pulseway | Breeze |
+|---|---|
+| Account | Partner |
+| **Organization** | **Organization** |
+| **Site** | **Site** |
+| **Group** | **Device Group** |
+| System | Device |
+
+**Export** — REST API at `https://api.pulseway.com/v2/`, HTTP Basic auth with a token created under **Server Admin → API**:
+
+```bash
+A=(-u "$PW_USER:$PW_TOKEN")
+curl -sf "${A[@]}" 'https://api.pulseway.com/v2/organizations' | jq -r '.Data[] | [.Id,.Name] | @tsv'
+curl -sf "${A[@]}" 'https://api.pulseway.com/v2/sites' | jq -r '.Data[] | [.OrganizationId,.Name] | @tsv'
+curl -sf "${A[@]}" 'https://api.pulseway.com/v2/systems?$top=1000' \
+ | jq -r '.Data[] | [.Name,.Description,.IsOnline] | @tsv'
+```
+
+Join organizations and sites into the `organization,site` CSV for [Recipe 1](/migration/toolkit/#recipe-1--bootstrap-the-tenancy-tree-from-csv), and carry the group layer across as [device groups](/features/device-groups/).
+
+**Deploy** — Pulseway automation scripts are PowerShell and bash and run as SYSTEM. Create a workflow or scheduled task with the [Recipe 3](/migration/toolkit/#recipe-3--the-push-payload) payload, scheduled daily.
+
+**Scripts** port cleanly; only the variable wrapper changes.
+
+**Uninstall** — `msiexec /x` the *Pulseway* product, or run `/usr/bin/pulseway-uninstall` on Linux. Breeze [Management Posture](/features/management-posture/) fingerprints Pulseway, so use [Recipe 5](/migration/toolkit/#recipe-5--find-endpoints-still-running-the-old-agent) to verify.
+
+---
+
+## Action1
+
+Action1 is patch-and-script focused, so a migration is usually an *expansion* into full RMM rather than a like-for-like replacement. Expect to build monitoring and alerting in Breeze that had no Action1 equivalent.
+
+| Action1 | Breeze |
+|---|---|
+| Organization | **Organization** |
+| — | **Site** (create one `Main`) |
+| Endpoint group | Device Group |
+| Endpoint | Device |
+
+**Export** — REST API at `https://app.action1.com/api/3.0` (or your regional host), OAuth2 client credentials from **Settings → API & Integrations**:
+
+```bash
+TOKEN=$(curl -sf -X POST 'https://app.action1.com/api/3.0/oauth2/token' \
+ -d grant_type=client_credentials \
+ -d "client_id=$A1_ID" -d "client_secret=$A1_SECRET" | jq -r .access_token)
+
+curl -sf -H "Authorization: Bearer $TOKEN" 'https://app.action1.com/api/3.0/organizations' | jq .
+curl -sf -H "Authorization: Bearer $TOKEN" \
+ "https://app.action1.com/api/3.0/endpoints/managed/$ORG_ID" | jq -r '.items[].name'
+```
+
+**Deploy** — Action1's script/package deployment runs as SYSTEM; use the [Recipe 3](/migration/toolkit/#recipe-3--the-push-payload) payload on a recurring automation.
+
+**Patch policy** is where the design work is: Action1 automations map to Breeze [patch policies](/features/patch-management/) plus [update rings](/features/update-rings/), and the ring model is likely a better fit than a per-org automation.
+
+**Uninstall** — `msiexec /x` the *Action1 Agent* product.
+
+---
+
+## Automox
+
+Automox is patch management only. In almost every case it is a *supplement* being consolidated into Breeze rather than a full RMM being replaced, so migrate it alongside — not instead of — whatever RMM you also run.
+
+| Automox | Breeze |
+|---|---|
+| Organization | **Organization** |
+| Group | Device Group |
+| Device | Device |
+| Policy (patch/required software) | [Patch policy](/features/patch-management/) + [update rings](/features/update-rings/) |
+| **Worklet** | Script (bash/PowerShell — ports directly) |
+
+**Export** — `https://console.automox.com/api`, API key from **Settings → Keys**:
+
+```bash
+H="Authorization: Bearer $AUTOMOX_KEY"
+curl -sf -H "$H" 'https://console.automox.com/api/orgs' | jq -r '.[] | [.id,.name] | @tsv'
+curl -sf -H "$H" "https://console.automox.com/api/servers?o=$ORG_ID&limit=500" \
+ | jq -r '.[] | [.name, .os_family, .last_disconnect_time] | @tsv'
+```
+
+**Worklets** are the valuable part: each is a plain bash or PowerShell pair (evaluation + remediation). The remediation body ports directly into a Breeze script; the evaluation body maps well onto a Breeze [monitor](/features/service-monitoring/) or a script with `exitCodeSeverityMapping`.
+
+**Uninstall** — `msiexec /x` the *Automox Agent*, or `/opt/amagent/uninstall.sh` on Linux. Breeze Management Posture fingerprints Automox.
+
+---
+
+## Level
+
+Level's hierarchy is Organization → Group → Device, mapping to Breeze Organization → Site (create `Main`) → Device Group → Device. Its API is a bearer-token REST and GraphQL surface; scripts are PowerShell and bash and port directly. Deploy via a Level script on a recurring schedule using the [Recipe 3](/migration/toolkit/#recipe-3--the-push-payload) payload. Breeze Management Posture fingerprints Level.
+
+---
+
+## Tactical RMM
+
+Tactical RMM is self-hosted and open source, which makes it the easiest export of any platform here — you have direct database access.
+
+| Tactical RMM | Breeze |
+|---|---|
+| Client | **Organization** |
+| Site | **Site** |
+| Agent | Device |
+
+Export the client → site tree straight from the Postgres database (`clients_client`, `clients_site`) or via the API at `/clients/`. Tactical scripts are PowerShell, bash, and Python and port directly. Deploy via a Tactical script task on a schedule. Uninstall with Tactical's own agent-removal action. Breeze Management Posture fingerprints Tactical RMM.
+
+---
+
+## ScreenConnect / ConnectWise Control (remote access only)
+
+ScreenConnect is remote access, not RMM — there is no fleet monitoring to migrate. What matters is that it is frequently installed *alongside* another RMM and survives that RMM's uninstall.
+
+Breeze [remote access](/features/remote-access/) replaces it functionally. The migration action is a security one:
+
+
+1. Use [Recipe 5](/migration/toolkit/#recipe-5--find-endpoints-still-running-the-old-agent) — Breeze Management Posture fingerprints ScreenConnect independently of ConnectWise Automate.
+2. Confirm Breeze remote access works for the technicians who relied on it.
+3. Uninstall the ScreenConnect client (`msiexec /x` the *ScreenConnect Client* product, service name `ScreenConnect Client (…)`).
+4. Re-run the posture report and drive the count to zero.
+
+
+
+
+---
+
+## Adapting the Playbook to an Unlisted RMM
+
+Every guide in this section is the same seven questions. Answer them for your platform and you have a migration plan.
+
+| # | Question | Feeds |
+|---|---|---|
+| 1 | What is the tenancy hierarchy, and how does it map to `Organization → Site → Device Group`? | [Recipe 1](/migration/toolkit/#recipe-1--bootstrap-the-tenancy-tree-from-csv) |
+| 2 | How do I export the client/site tree and the device list as CSV? | Recipe 1, [Recipe 4](/migration/toolkit/#recipe-4--reconcile-enrollment) |
+| 3 | Can its script engine run PowerShell/bash as SYSTEM on a schedule? | [Recipe 3](/migration/toolkit/#recipe-3--the-push-payload) |
+| 4 | Are its scripts text-based (port) or step-based (re-author)? | [Recipe 6](/migration/toolkit/#recipe-6--bulk-script-import) |
+| 5 | Where do its custom fields / UDFs live, and which are populated? | [Custom fields](/features/custom-fields/) |
+| 6 | Which alerts actually generated tickets in the last 90 days? | [Monitors](/features/service-monitoring/) + [alert rules](/features/alerts/) |
+| 7 | What is the silent uninstall command, and does the platform bundle a *separate* remote-access agent? | [Recipe 5](/migration/toolkit/#recipe-5--find-endpoints-still-running-the-old-agent) |
+
+Question 7 is the one people miss. Datto, Atera, Syncro, and ConnectWise all ship remote-access components installed separately from the RMM agent, and all of them can survive the RMM uninstall.
+
+
diff --git a/apps/docs/src/content/docs/migration/overview.mdx b/apps/docs/src/content/docs/migration/overview.mdx
new file mode 100644
index 000000000..ca2a7f5c6
--- /dev/null
+++ b/apps/docs/src/content/docs/migration/overview.mdx
@@ -0,0 +1,204 @@
+---
+title: Migrating to Breeze
+description: The vendor-neutral playbook for moving an MSP fleet off an incumbent RMM and onto Breeze — phases, sequencing, what migrates, and what does not.
+sidebar:
+ order: 1
+ label: Overview
+---
+
+import { Steps, Aside, CardGrid, LinkCard } from '@astrojs/starlight/components';
+
+Migrating an RMM is not a data migration. It is a **re-deployment of your management plane** across every endpoint you manage, done without losing visibility on any of them. The endpoint data your incumbent holds — inventory, performance history, patch state — is regenerated by Breeze within hours of an agent enrolling. What actually has to move is your *configuration*: the customer tree, the scripts, the alert thresholds, the patch policies, and the muscle memory of your technicians.
+
+This page is the vendor-neutral playbook. The per-vendor pages layer the specifics on top.
+
+
+
+
+
+
+
+
+
+
+
+
+---
+
+## The Core Insight
+
+
+
+The corollary is the sequencing rule that governs everything below: **the incumbent is the last thing you turn off, not the first.** A migration that removes the old agent before the new one is verified strands endpoints with no management at all — and those are exactly the endpoints (roaming laptops, seasonal machines, the one server behind a broken VPN) you will not get back without a site visit.
+
+---
+
+## What Migrates, What Regenerates, What Is Lost
+
+Set expectations with your team and your customers before you start. Nothing below is a Breeze limitation specifically — it is true of every RMM-to-RMM move.
+
+| Category | Status | Notes |
+|---|---|---|
+| Customer / site / device tree | **You rebuild it** | Scripted from the incumbent's API. See [Phase 2](#phase-2--rebuild-the-tenancy-tree). |
+| Hardware & software inventory | **Regenerates** | Full inventory lands within minutes of enrollment. No migration needed. |
+| Patch state & compliance | **Regenerates** | Breeze re-scans; missing-patch state is accurate on first scan cycle. |
+| Performance / metric history | **Lost** | Starts from zero at enrollment. Keep the incumbent read-only if you need historical charts. |
+| Alert history | **Lost** | Open alerts should be triaged out before cutover, not migrated. |
+| Scripts | **Sometimes** | Portable if the source engine is PowerShell/bash-based. Not portable from step-based engines — see below. |
+| Monitors & alert thresholds | **You rebuild them** | Re-author as Breeze [monitors](/features/service-monitoring/) and [alert rules](/features/alerts/). Treat as an opportunity to prune. |
+| Patch policies | **You rebuild them** | Map to Breeze [patch policies](/features/patch-management/) and update rings. |
+| Custom fields / UDFs / EDFs | **Scriptable** | Export from source, re-create as Breeze [custom fields](/features/custom-fields/), backfill per device. |
+| Documentation / passwords | **Out of scope** | Usually lives in IT Glue / Hudu / a PSA, not the RMM. Verify before assuming. |
+| Tickets | **Out of scope** | Lives in your PSA. Re-point the PSA at Breeze via [PSA integrations](/features/psa-integrations/); the ticket history stays where it is. |
+
+### The script-portability split
+
+This is the single largest variable in migration cost, and it is determined entirely by your incumbent:
+
+- **Text-based engines port cleanly.** Datto RMM components, NinjaOne scripts, Atera, Syncro, Pulseway, Action1, Automox worklets are PowerShell / bash / batch with a thin variable-injection wrapper. Rewrite the wrapper, keep the body.
+- **Step-based engines do not port at all.** ConnectWise Automate scripts, Kaseya Agent Procedures, and N-able AMPs are proprietary XML step lists. There is no converter, and writing one is not worth it. Budget for re-authoring your top 20 scripts and deleting the rest.
+
+
+
+---
+
+## The Seven Phases
+
+
+
+1. ### Phase 0 — Inventory and decide
+
+ Pull a full export from the incumbent: every client, every site, every device, with OS, role, last-seen, and agent version. You need this for three reasons — it is your migration checklist, your license reconciliation, and your proof of completion.
+
+ Decide up front:
+
+ - **Which customers go first.** Pick two: one small and friendly, one representative of your median customer. Do not start with your largest.
+ - **What you are not migrating.** Devices that have not checked in for 90+ days are almost always decommissioned hardware still consuming a license. Migration is the cheapest time you will ever have to clean this up.
+ - **Your overlap window.** Two to four weeks of running both agents is normal. Budget the double licensing.
+
+2. ### Phase 1 — Stand up Breeze and set partner-wide defaults
+
+ Get Breeze running ([Quickstart](/getting-started/quickstart/)) and configure everything you can **once, at the partner level**, before you create a single customer. Breeze is partner-wide-first: policies, script libraries, alert templates, patch policies, and maintenance windows can all be owned by the partner and applied across every organization.
+
+ Doing this now is the difference between configuring one policy and configuring it eighty times. Set your scope selector to **All orgs** and define:
+
+ - Alert rules and [alert templates](/features/alert-templates/)
+ - [Patch policies](/features/patch-management/) and update rings
+ - [Maintenance windows](/features/maintenance-windows/)
+ - [Configuration policies](/features/configuration-policies/)
+ - Your script library
+
+3. ### Phase 2 — Rebuild the tenancy tree
+
+ Map the incumbent's hierarchy onto Breeze's `Partner → Organization → Site → Device Group → Device`. The mapping is usually clean:
+
+ | Incumbent | Breeze |
+ |---|---|
+ | NinjaOne Organization → Location | Organization → Site |
+ | Datto RMM Site | Organization (one site each) |
+ | ConnectWise Automate Client → Location | Organization → Site |
+ | Kaseya VSA Organization → Machine Group | Organization → Site |
+ | N-central Customer → Site | Organization → Site |
+ | Atera Customer | Organization |
+ | Pulseway Organization → Site → Group | Organization → Site → Device Group |
+
+ Breeze has no bulk import UI today, so this is scripted against the API — `POST /orgs/organizations` and `POST /orgs/sites`. The [Migration Toolkit](/migration/toolkit/) has a ready-to-run script that reads a two-column CSV and builds the whole tree, and each per-vendor page has the export query that produces that CSV.
+
+4. ### Phase 3 — Deploy the Breeze agent from the incumbent
+
+ For each organization, create an enrollment key sized to that customer's device count, then push the install through the incumbent's script engine.
+
+ Key settings that matter for a bulk migration — the defaults are tuned for single installs, not fleets:
+
+ | Setting | Default | Migration value |
+ |---|---|---|
+ | `maxUsage` | 1 | Device count + 20% headroom |
+ | `expiresAt` | 60 minutes | Long enough to cover your rollout wave |
+ | `siteId` | — | Pin it, so devices land in the right site without per-device logic |
+
+ Add **mutual antivirus and EDR exclusions for both agents** before you push. Two RMM agents on one box, each with a watchdog restarting the other's processes, is a reliable way to trip behavioural AV. See [Antivirus Exceptions](/deploy/antivirus-exceptions/).
+
+ Then roll in waves — pilot customer, then 10%, then the rest. Do not push to the whole fleet on day one.
+
+5. ### Phase 4 — Verify enrollment device-by-device
+
+ This is the phase people skip, and it is the one that determines whether the migration succeeds. Reconcile your Phase 0 export against Breeze's device list per organization and drive the gap to zero.
+
+ The stragglers always fall into the same buckets:
+
+ - **Offline at push time** — laptops, seasonal machines. Re-run the job on a schedule for two weeks.
+ - **Old agent broken** — the incumbent's own agent was already dead, so the push never ran. These need manual attention and were invisible to you before now.
+ - **Blocked by AV/EDR** — check exclusions.
+ - **Unsupported OS** — verify against the Breeze [agent platform matrix](/agents/installation/).
+
+ Do not proceed to Phase 6 for any customer until their gap is zero or every remaining device is explicitly accounted for.
+
+6. ### Phase 5 — Re-point integrations and rebuild content
+
+ Run in parallel here. With agents reporting into Breeze:
+
+ - Re-point your **PSA** so tickets are created by Breeze, not the incumbent. Connect via [PSA integrations](/features/psa-integrations/) and map organizations. Run both alert sources briefly, then disable alerting in the incumbent so you are not double-ticketing.
+ - Migrate scripts (see the portability split above).
+ - Re-create monitors, thresholds, and custom fields.
+ - Re-point EDR, DNS filtering, backup, and documentation integrations.
+ - Re-create technician accounts, roles, and MFA. Do this properly rather than mirroring the incumbent's grown-over-time permission sprawl.
+
+7. ### Phase 6 — Cut over and decommission
+
+ Only after a customer's verification gap is zero:
+
+ 1. **Disable alerting in the incumbent** for that customer. Keep the agent installed and reporting.
+ 2. **Wait one full patch cycle** — typically one month. This is the window where you discover what you forgot.
+ 3. **Uninstall the incumbent agent** via its own script engine, per customer. Each vendor page documents the correct uninstall command.
+ 4. **Verify the uninstall** through Breeze's software inventory: search for the incumbent's agent across the fleet and confirm the count reaches zero.
+ 5. **Downgrade or cancel** the incumbent licence and export any historical data you are contractually required to retain — *before* the tenant is deleted, not after.
+
+
+
+---
+
+## Sequencing Rules
+
+These are the rules that prevent the failure modes worth caring about.
+
+| Rule | Why |
+|---|---|
+| Never uninstall before enrollment is verified **for that specific device** | Fleet-level "we're at 98%" is not device-level verification. The 2% is where the site visits live. |
+| Add AV/EDR exclusions before the push, not after | Two watchdogged agents look like malware to behavioural detection. |
+| Keep the incumbent alerting until Breeze alerting is proven | Otherwise you have a window with no monitoring on production servers. |
+| Configure partner-wide before creating orgs | Retrofitting per-org config into partner-wide config later is manual and error-prone. |
+| Do not migrate a script you have not run in 12 months | You are migrating maintenance burden, not capability. |
+| Export the incumbent's historical data before cancelling | Tenant deletion is irreversible and the data is often contractually required. |
+
+---
+
+## Rough Timeline
+
+For a typical 1,500-endpoint, 60-customer MSP. Scale the middle phases with customer count, not device count — per-customer overhead dominates.
+
+| Phase | Elapsed | Effort |
+|---|---|---|
+| 0 — Inventory & decide | Week 1 | 1–2 days |
+| 1 — Stand up Breeze, partner-wide config | Weeks 1–2 | 3–5 days |
+| 2 — Rebuild tenancy tree | Week 2 | Hours (scripted) |
+| 3 — Agent deployment waves | Weeks 3–6 | 1 day per wave |
+| 4 — Verification | Weeks 3–8 | Ongoing, ~2h/customer |
+| 5 — Integrations & content rebuild | Weeks 3–8 | The long pole — 1–3 weeks |
+| 6 — Cutover & decommission | Weeks 8–12 | ~1h/customer |
+
+The long pole is almost never the agent rollout. It is re-authoring monitors and scripts, and re-establishing technician workflow.
+
+---
+
+## Next
+
+
+
+
+
+
+
diff --git a/apps/docs/src/content/docs/migration/syncro.mdx b/apps/docs/src/content/docs/migration/syncro.mdx
new file mode 100644
index 000000000..5ac484a76
--- /dev/null
+++ b/apps/docs/src/content/docs/migration/syncro.mdx
@@ -0,0 +1,157 @@
+---
+title: Syncro → Breeze
+description: Export customers and assets from Syncro, deploy the Breeze agent via a Syncro script, and remove the Syncro agent cleanly.
+sidebar:
+ order: 9
+ label: Syncro
+---
+
+import { Steps, Aside } from '@astrojs/starlight/components';
+
+Syncro is a straightforward migration on the RMM side — clean REST API, plain PowerShell and shell scripts, shallow hierarchy. What makes a Syncro migration a *project* is that Syncro is an all-in-one: RMM, PSA, ticketing, invoicing, and inventory in one product. Breeze covers the RMM half and has its own [ticketing](/features/ticketing/), [invoicing](/features/invoices/), and [contracts](/features/contracts/) — but you must decide, up front, which of Syncro's non-RMM functions you are replacing with Breeze and which with a separate PSA.
+
+Do that before Phase 0. Everything else follows from it.
+
+Read [Migrating to Breeze](/migration/overview/) first.
+
+---
+
+## Hierarchy Mapping
+
+Syncro is flat — Customers own Assets, with no site layer.
+
+| Syncro | Breeze | Notes |
+|---|---|---|
+| Account | Partner | |
+| **Customer** | **Organization** | Direct match. |
+| — | **Site** | Create one `Main` site per organization. Use the customer's address to set a real timezone. |
+| Asset tag / policy folder | Device Group | Map to Breeze [dynamic device groups](/features/device-groups/). |
+| Asset | Device | Syncro assets include non-agent records (manually added hardware) — filter to agent-managed only. |
+| Contact | — | Breeze cannot create contacts programmatically today. |
+
+---
+
+## Phase 0 — Export from Syncro
+
+Generate an API token under **Admin → API Tokens** with read permissions on customers and assets. The base URL is your subdomain: `https://.syncromsp.com/api/v1`.
+
+
+
+1. **Export customers** — your Breeze organization list:
+
+ ```bash
+ H="Authorization: Bearer $SYNCRO_TOKEN"
+ BASE="https://$SYNCRO_SUB.syncromsp.com/api/v1"
+
+ echo 'organization,site' > tree.csv
+ page=1
+ while :; do
+ r=$(curl -sf -H "$H" "$BASE/customers?page=$page")
+ echo "$r" | jq -r '.customers[] | [(.business_name // .fullname), "Main"] | @csv' >> tree.csv
+ [ "$page" -ge "$(echo "$r" | jq -r .meta.total_pages)" ] && break
+ page=$((page+1))
+ done
+ ```
+
+ Syncro customers may be businesses or individuals — `business_name` is null for the latter, hence the fallback. Feed `tree.csv` to [Recipe 1](/migration/toolkit/#recipe-1--bootstrap-the-tenancy-tree-from-csv).
+
+2. **Export assets** — your reconciliation checklist:
+
+ ```bash
+ page=1
+ while :; do
+ r=$(curl -sf -H "$H" "$BASE/customer_assets?page=$page")
+ echo "$r" | jq -r '.assets[] | [.customer.business_name, .name, .asset_type,
+ .properties."kabuto_information".general.os_name,
+ .updated_at] | @tsv'
+ [ "$page" -ge "$(echo "$r" | jq -r .meta.total_pages)" ] && break
+ page=$((page+1))
+ done > syncro-assets.tsv
+ ```
+
+ Filter on `asset_type` — only Syncro RMM assets have an agent. Manually created asset records are inventory, not migration targets.
+
+3. **Export what you are keeping from the PSA side.** If you are leaving Syncro entirely: `GET /tickets`, `GET /invoices`, `GET /contacts`, `GET /estimates`. None of this migrates into Breeze automatically. Export it to storage before you cancel — Syncro is your system of record for billing history.
+
+
+
+
+
+---
+
+## Phase 3 — Deploy the Breeze Agent with a Syncro Script
+
+
+
+1. **Create the script.** **Admin → Scripts → New Script**, platform **Windows**, language **PowerShell**. Paste the Windows payload from [Recipe 3](/migration/toolkit/#recipe-3--the-push-payload). Syncro scripts run as SYSTEM by default.
+
+2. **Parameterise the key.** Syncro supports script variables — declare `Server`, `Key`, and `Secret`, and supply the per-customer enrollment key from [Recipe 2](/migration/toolkit/#recipe-2--mint-bulk-enrollment-keys) when you attach the script to a policy.
+
+3. **Add AV/EDR exclusions in both directions** first — see [Antivirus Exceptions](/deploy/antivirus-exceptions/).
+
+4. **Attach to a policy on a daily schedule.** Syncro runs scripts via **Policies**, so create a migration policy targeting one customer's assets with a daily schedule for the length of your rollout window. The `agent.yaml` check makes repeat runs a no-op and the schedule sweeps up offline machines.
+
+
+
+---
+
+## Migrating Scripts
+
+Syncro's script library is PowerShell, batch, and shell — bodies port directly.
+
+| Syncro | Breeze |
+|---|---|
+| Script variables (`$variableName`) | Script [parameters](/features/scripts/) |
+| `Rmm-Alert` (raise an alert from a script) | `exitCodeSeverityMapping` on the script |
+| `Set-Asset-Field` | [Custom field](/features/custom-fields/) writes via the API |
+| `Create-Syncro-Ticket` | Alert rule bound to [Breeze ticketing](/features/ticketing/) or a [PSA integration](/features/psa-integrations/) |
+| Policy-attached scripts | [Automations](/features/automations/) |
+
+The Syncro-specific cmdlets (`Rmm-Alert`, `Set-Asset-Field`, `Create-Syncro-Ticket`, `Log-Activity`) are injected into the runtime and will fail on any other platform, so every script that uses one needs that line replaced. Grep your library for `Syncro` before you start — it tells you exactly how much rewriting there is.
+
+Bulk-load with [Recipe 6](/migration/toolkit/#recipe-6--bulk-script-import), `availability: "partner"`.
+
+---
+
+## Policies and Alerting
+
+Syncro's **Policies** bundle monitoring thresholds, patching, and scheduled scripts into one object per customer. Breeze splits these three concerns apart:
+
+| Syncro policy section | Breeze |
+|---|---|
+| Monitoring / thresholds | [Monitors](/features/service-monitoring/) + [alert rules](/features/alerts/) |
+| Patching | [Patch policies](/features/patch-management/) + [update rings](/features/update-rings/) |
+| Scheduled scripts | [Automations](/features/automations/) |
+| Applied per customer | Define **partner-wide** instead — one definition covers every organization |
+
+Most Syncro estates have one policy per customer that differs only cosmetically. Consolidate to two or three partner-wide definitions.
+
+---
+
+## Phase 6 — Decommission the Syncro Agent
+
+Only after [Recipe 4](/migration/toolkit/#recipe-4--reconcile-enrollment) is clean for that customer.
+
+
+
+1. **Disable the customer's Syncro policy alerts.** Leave the agent installed.
+2. **Wait one full patch cycle.**
+3. **Uninstall via Syncro.** Deleting the asset in Syncro triggers agent removal. By script:
+
+ ```powershell
+ $p = Get-CimInstance Win32_Product | Where-Object { $_.Name -like 'Syncro*' -or $_.Name -like 'Kabuto*' }
+ foreach ($x in $p) { msiexec /x $x.IdentifyingNumber /qn /norestart }
+ ```
+
+ Syncro's agent runs as the `Syncro` and `SyncroLive` services (historically `Kabuto`); confirm both are gone.
+
+4. **Verify from Breeze.** [Recipe 5](/migration/toolkit/#recipe-5--find-endpoints-still-running-the-old-agent) — Breeze [Management Posture](/features/management-posture/) fingerprints SyncroMSP. Drive the count to zero.
+5. **Keep the Syncro account open** until your billing and ticket-history retention needs are met — see the caution above — then export and cancel.
+
+
+
+
diff --git a/apps/docs/src/content/docs/migration/toolkit.mdx b/apps/docs/src/content/docs/migration/toolkit.mdx
new file mode 100644
index 000000000..593ce073e
--- /dev/null
+++ b/apps/docs/src/content/docs/migration/toolkit.mdx
@@ -0,0 +1,376 @@
+---
+title: Migration Toolkit
+description: Ready-to-run API recipes for bootstrapping the tenancy tree, minting bulk enrollment keys, and reconciling enrollment against your old RMM.
+sidebar:
+ order: 2
+ label: Migration Toolkit
+---
+
+import { Steps, Aside, Tabs, TabItem } from '@astrojs/starlight/components';
+
+Every migration step that needs to happen a hundred times is either a bulk endpoint (orgs, sites, scripts) or a scripted loop against the REST API. This page holds the recipes; the per-vendor pages tell you how to produce their inputs.
+
+Everything here uses only documented, stable endpoints — see the [API Reference](/reference/api/).
+
+---
+
+## Authentication for Migration Scripts
+
+Two credentials matter here, and they split cleanly by job:
+
+- **A partner service principal** — for anything unattended: Recipe 2's provisioning loop, scheduled syncs, anything that must run without a human at the keyboard. It authenticates against the Partner API (`$BREEZE_URL/partner-api/*`) via the `X-API-Key` header, never touches MFA, and never expires mid-run. Deliberately create-only: it can mint orgs, sites, and enrollment keys, but there is **no** DELETE surface — tearing down tenancy stays a human, MFA-gated act.
+- **A partner-admin user JWT** — for the bulk endpoints that live on the main API: org/site import (Recipe 1) and script bundles (Recipe 6). These are `requireMfa()`-gated: log in as a partner admin, complete the MFA challenge (when `ENABLE_2FA` is on, the JWT must carry `mfa: true` — a bare `POST /auth/login` token gets `403 MFA required`), and export the access token. Tokens are short-lived, so long runs should refresh via `POST /auth/refresh`.
+
+
+
+```bash
+export BREEZE_URL="https://breeze.yourdomain.com/api/v1"
+export BREEZE_TOKEN="eyJ..." # partner-admin JWT with MFA satisfied (Recipes 1, 6)
+export BREEZE_PARTNER_KEY="..." # partner service principal key (Recipe 2)
+
+# Sanity check the JWT — should return your partner record
+curl -sf -H "Authorization: Bearer $BREEZE_TOKEN" "$BREEZE_URL/orgs/partners/me" | jq .name
+
+# Sanity check the service principal — counts the orgs it can see
+curl -sf -H "X-API-Key: $BREEZE_PARTNER_KEY" "$BREEZE_URL/partner-api/organizations" | jq '.data | length'
+```
+
+Partner API writes share a deliberately tight rate bucket — `min(key limit, 120)` writes per hour per principal, answered with `429` + `Retry-After` when exhausted. A few hundred sites will fit in a couple of waves; pace the loop rather than fighting the limiter.
+
+---
+
+## Recipe 1 — Bootstrap the Tenancy Tree from CSV
+
+Every per-vendor page produces a CSV in this shape:
+
+```csv
+organization,site
+Acme Manufacturing,Head Office
+Acme Manufacturing,Detroit Plant
+Bright Dental,Main Clinic
+```
+
+The easiest way to load it is the web UI: **Settings → Organizations → Bulk import** takes the CSV, lets you map columns, shows a per-row preview (create / matched / conflict), and commits. Scripted, the same preview → commit pair lives at `POST /orgs/import/preview` and `POST /orgs/import`. Both take JSON — the CSV is parsed on your side, the API never sees it — and accept up to **1,000 rows per request**. Rows sharing an `organization` value become one org with many sites; slugs are derived and de-duplicated for you.
+
+Preview annotates every row before anything is written:
+
+| Annotation | Meaning |
+|---|---|
+| `create` | New org (and site) will be created |
+| `link-match` | Matched an existing org by `(externalSystem, externalId)` — the safe, stable match |
+| `name-match` | Matched an existing org by name only — commit refuses it unless you acknowledge with `expectedAnnotation: "name-match"` |
+| `matched-soft-deleted` | Matched a deleted org — commit refuses unless you also pass `reactivate: true` |
+| `conflict` | Row can't proceed (see `conflictReason`) |
+
+```bash
+#!/usr/bin/env bash
+# import-tree.sh — preview, then commit, a two-column CSV (organization,site).
+# Usage: ./import-tree.sh tree.csv
+set -euo pipefail
+: "${BREEZE_URL:?}" "${BREEZE_TOKEN:?}"
+CSV="$1"
+AUTH=(-H "Authorization: Bearer $BREEZE_TOKEN" -H "Content-Type: application/json")
+
+# CSV → {"rows":[{"organization":...,"site":...},...]} (≤1000 rows per request)
+payload=$(tail -n +2 "$CSV" | jq -Rnc '
+ [inputs | split(",") | select((.[0] // "") != "")
+ | {organization: (.[0] | gsub("^\\s+|\\s+$";"")),
+ site: ((.[1] // "") | gsub("^\\s+|\\s+$";""))}
+ | if .site == "" then del(.site) else . end]
+ | {rows: .}')
+
+# 1. Preview — writes nothing. Eyeball everything that is not a plain create.
+curl -sf "${AUTH[@]}" -X POST "$BREEZE_URL/orgs/import/preview" -d "$payload" \
+ | jq -r '.rows[] | select(.annotation != "create")
+ | "\(.annotation)\t\(.organization)\t\(.matchedOrganizationName // .conflictReason // "")"'
+
+# 2. Commit. mode=skip leaves matched orgs untouched, so re-runs are idempotent;
+# mode=update patches only the fields present in the row.
+curl -sf "${AUTH[@]}" -X POST "$BREEZE_URL/orgs/import" \
+ -d "$(jq -c '. + {mode:"skip"}' <<<"$payload")" \
+ | jq '{imported: (.imported|length), updated: (.updated|length),
+ skipped: (.skipped|length), errors: .errors}'
+```
+
+Commit re-derives every row's annotation against fresh database state and rejects (into `errors`, per row — the rest proceed) any row whose annotation changed since preview. `name-match` rows are never committed silently: echo back `expectedAnnotation: "name-match"` (and ideally `expectedOrganizationId`) to confirm the match. The web UI does this handshake for you.
+
+
+
+**Row fields for `POST /orgs/import` and `/orgs/import/preview`**
+
+| Field | Required | Notes |
+|---|---|---|
+| `organization` | yes | ≤255 chars; repeat across rows to attach multiple sites |
+| `site` | no | A group with no sites gets one default site named after the org |
+| `externalId` / `externalSystem` | no | Dedupe identity, stored as an `organization_external_links` row |
+| `timezone` | no | IANA, validated |
+| `address`, `contact` | no | `contact` is `{name, email, phone}` |
+
+Commit-only per-row fields: `expectedAnnotation`, `expectedOrganizationId`, `reactivate`. Top-level: `mode` (`skip` default, or `update`).
+
+The import covers **organizations and sites** (plus their external-link rows). It does not import devices — devices arrive by enrolling agents (Recipes 2–3). The single-record endpoints (`POST /orgs/organizations`, `POST /orgs/sites`) still exist for one-offs and carry fields the import rows don't (`contractStart`, `billingContact`, …).
+
+---
+
+## Recipe 2 — Mint Bulk Enrollment Keys
+
+This is the unattended step, so it runs on the Partner API with a service principal (scopes: `sites:read` to enumerate, `enrollment-keys:write` to mint — add `organizations:write`/`sites:write` if the same principal also provisions the tree). Enrollment-key defaults are tuned for installing one agent by hand: **`maxUsage: 1`** and a short TTL. For a migration wave you want the opposite end of both ranges.
+
+| Field | Range | Migration value |
+|---|---|---|
+| `maxUsage` | 1 – 100,000 | Device count + 20% |
+| `ttlMinutes` | 1 – 525,600 (365 days) | Length of your rollout window, e.g. `43200` for 30 days |
+| `siteId` | — | Pin it. Devices land in the right site with no per-device logic. |
+
+
+
+```bash
+#!/usr/bin/env bash
+# mint-keys.sh — one long-lived, high-capacity enrollment key per site,
+# fully unattended via a partner service principal. No JWT, no MFA.
+# Prints: siteorgIdsiteIdrawKey
+set -euo pipefail
+: "${BREEZE_URL:?}" "${BREEZE_PARTNER_KEY:?}"
+AUTH=(-H "X-API-Key: $BREEZE_PARTNER_KEY" -H "Content-Type: application/json")
+TTL_MINUTES="${TTL_MINUTES:-43200}" # 30 days
+CAPACITY="${CAPACITY:-250}"
+
+curl -sf "${AUTH[@]}" "$BREEZE_URL/partner-api/sites?limit=500" \
+ | jq -r '.data[] | [.orgId, .siteId, .name] | @tsv' \
+| while IFS=$'\t' read -r orgId siteId siteName; do
+ raw=$(curl -sf "${AUTH[@]}" -X POST "$BREEZE_URL/partner-api/enrollment-keys" \
+ -d "$(jq -nc --arg o "$orgId" --arg s "$siteId" \
+ --arg n "migration: $siteName" \
+ --argjson m "$CAPACITY" --argjson t "$TTL_MINUTES" \
+ '{orgId:$o, siteId:$s, name:$n, maxUsage:$m, ttlMinutes:$t}')" \
+ | jq -r .key)
+ printf '%s\t%s\t%s\t%s\n' "$siteName" "$orgId" "$siteId" "$raw"
+ done
+```
+
+Over 500 sites? The list endpoints paginate by cursor — follow `nextCursor` while `hasMore` is true (`limit` caps at 500). The same principal can build the tree itself: `POST /partner-api/organizations` (`name`, `slug`, `type?`, `status?` — `active`|`trial` only; lifecycle transitions stay human) and `POST /partner-api/sites` (`orgId`, `name`, `timezone?`, `address?`, `contact?`). Two responses to handle deliberately: a duplicate slug returns `409` `partner_provisioning_slug_conflict`, and hitting your partner's organization cap returns `409` `partner_provisioning_org_limit_reached` — the latter is a billing conversation, not a retry. `partnerId` never comes from the request body; the principal's partner always wins.
+
+
+
+If the Partner API doesn't cover something you need mid-run, the JWT flow from the auth section still works: the same enrollment-key shape lives at `POST /enrollment-keys` (there `orgId` can be omitted for single-org partners, and the default TTL is 60 minutes via `ENROLLMENT_KEY_DEFAULT_TTL_MINUTES`).
+
+Treat the resulting file as a credential: it is a list of tokens that can enroll devices into your customers' tenants. Delete it once the wave is complete, or shorten the TTL by rotating.
+
+---
+
+## Recipe 3 — The Push Payload
+
+This is what you paste into your incumbent RMM's script engine. It runs as SYSTEM, downloads the agent binary, enrolls, and installs the service. Substitute the per-site enrollment key from Recipe 2.
+
+
+
+ ```powershell
+ $ErrorActionPreference = 'Stop'
+ $Server = 'https://breeze.yourdomain.com'
+ $Key = '<64-hex-enrollment-key>'
+ $Secret = '' # omit if not configured server-side
+
+ $Dir = "$env:ProgramFiles\Breeze"
+ New-Item -ItemType Directory -Force -Path $Dir | Out-Null
+ $Exe = Join-Path $Dir 'breeze-agent.exe'
+
+ # Already enrolled? Do nothing — makes the job safe to re-run on a schedule.
+ if (Test-Path "$env:ProgramData\Breeze\agent.yaml") { Write-Output 'already enrolled'; exit 0 }
+
+ [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
+ Invoke-WebRequest -UseBasicParsing -Uri "$Server/api/v1/agents/download/windows/amd64" -OutFile $Exe
+
+ & $Exe enroll $Key --server $Server --enrollment-secret $Secret --quiet
+ if ($LASTEXITCODE -ne 0) { throw "enroll failed: $LASTEXITCODE" }
+ & $Exe service install
+ Write-Output 'breeze agent enrolled'
+ ```
+
+
+ ```bash
+ #!/usr/bin/env bash
+ set -euo pipefail
+ SERVER='https://breeze.yourdomain.com'
+ KEY='<64-hex-enrollment-key>'
+ SECRET=''
+ SITE_ID=''
+
+ case "$(uname -s)" in
+ Darwin) OS=darwin; CFG='/Library/Application Support/Breeze/agent.yaml' ;;
+ Linux) OS=linux; CFG='/etc/breeze/agent.yaml' ;;
+ esac
+ case "$(uname -m)" in
+ x86_64) ARCH=amd64 ;;
+ arm64|aarch64) ARCH=arm64 ;;
+ esac
+
+ [ -f "$CFG" ] && { echo 'already enrolled'; exit 0; }
+
+ curl -fsSL -o /usr/local/bin/breeze-agent "$SERVER/api/v1/agents/download/$OS/$ARCH"
+ chmod +x /usr/local/bin/breeze-agent
+
+ /usr/local/bin/breeze-agent enroll "$KEY" \
+ --server "$SERVER" --enrollment-secret "$SECRET" --site-id "$SITE_ID" --quiet
+ /usr/local/bin/breeze-agent service install
+ echo 'breeze agent enrolled'
+ ```
+
+
+
+The `agent.yaml` existence check is what makes this safe to schedule. Set the job to run daily for the length of your rollout window and it will pick up machines that were offline on the first pass without re-enrolling the ones that succeeded.
+
+
+
+---
+
+## Recipe 4 — Reconcile Enrollment
+
+The verification gate for Phase 4. Compare a per-org device-name list from the incumbent against what actually enrolled.
+
+```bash
+#!/usr/bin/env bash
+# reconcile.sh — list devices present in the old RMM but missing from Breeze.
+# Usage: ./reconcile.sh
+set -euo pipefail
+: "${BREEZE_URL:?}" "${BREEZE_TOKEN:?}"
+
+ORG_ID="$1"; EXPECTED="$2"
+
+curl -sf -H "Authorization: Bearer $BREEZE_TOKEN" \
+ "$BREEZE_URL/devices?orgId=$ORG_ID&limit=100" \
+ | jq -r '[.data[]?,.devices[]?][] | .hostname' \
+ | tr '[:upper:]' '[:lower:]' | sort -u > /tmp/breeze-devices.txt
+
+tr '[:upper:]' '[:lower:]' < "$EXPECTED" | sort -u > /tmp/expected.txt
+
+echo "expected: $(wc -l < /tmp/expected.txt) enrolled: $(wc -l < /tmp/breeze-devices.txt)"
+echo '--- missing from Breeze ---'
+comm -23 /tmp/expected.txt /tmp/breeze-devices.txt
+```
+
+Mind the pagination — `limit` is capped at 100 per page, so page through `?page=N` for orgs above that size.
+
+---
+
+## Recipe 5 — Find Endpoints Still Running the Old Agent
+
+Breeze's agent fingerprints other management tooling already installed on each endpoint and reports it as **[Management Posture](/features/management-posture/)**. Datto RMM, NinjaOne, ConnectWise Automate, ScreenConnect, Kaseya VSA, N-able, Atera, SyncroMSP, Pulseway, Level, Tactical RMM and Automox are all fingerprinted.
+
+This is the authoritative decommission report — far better than trusting the incumbent's own console, which cannot tell you about a machine whose agent is broken.
+
+One call summarises the whole fleet (drop `orgId` to sweep every org you can see):
+
+```bash
+# Fleet-wide: which products are still installed, and how many devices per org?
+curl -sf -H "Authorization: Bearer $BREEZE_TOKEN" \
+ "$BREEZE_URL/devices/management-posture/summary?orgId=$ORG_ID" \
+ | jq -r '.data.orgs[] | .orgId as $o
+ | .products[] | "\($o)\t\(.product)\t\(.status)\t\(.deviceCount) devices"'
+```
+
+Two numbers in the response matter as much as the detections. `totals.neverScanned` is devices that have **never reported posture** — they are unknowns, not clean, and each one is typically a broken or ancient agent. `totals.stale` is devices whose last posture report is older than `stalenessDays` (default 7, tunable to 365). A migration is not done while either is non-zero.
+
+To list the actual machines behind a count, page through the drill-down endpoint:
+
+```bash
+# Which devices still run NinjaOne?
+curl -sf -H "Authorization: Bearer $BREEZE_TOKEN" \
+ "$BREEZE_URL/devices/management-posture/devices?product=NinjaOne&limit=500" \
+ | jq -r '.data.devices[] | "\(.hostname)\t\(.orgId)"'
+```
+
+Use it twice: before cutover to confirm you know what you are replacing, and after uninstall to prove the count reached zero. The same report lives in the web UI under **Devices → Posture**, with CSV export.
+
+---
+
+## Recipe 6 — Bulk Script Import
+
+Breeze moves whole script libraries as a **bundle**: one JSON document, up to **200 scripts** (≤256KB of content each, ≤20MB total request), imported in a single preview → import pair. The same format exports, so it doubles as backup and portability — pull your library out of one Breeze tenant (staging, another region) and load it into another, or keep the bundle file in version control.
+
+```bash
+# Export selected scripts as a bundle (ids = comma-separated, up to 200)
+curl -sf -H "Authorization: Bearer $BREEZE_TOKEN" \
+ "$BREEZE_URL/scripts/bundle/export?ids=$IDS" > breeze-scripts.json
+```
+
+Migrating off another RMM, you build the bundle yourself from a directory of script files:
+
+```bash
+#!/usr/bin/env bash
+# import-scripts.sh — bundle a directory of scripts, preview, then import.
+set -euo pipefail
+: "${BREEZE_URL:?}" "${BREEZE_TOKEN:?}"
+AUTH=(-H "Authorization: Bearer $BREEZE_TOKEN" -H "Content-Type: application/json")
+
+entries=()
+for f in "$1"/*; do
+ case "$f" in
+ *.ps1) lang=powershell; os='["windows"]' ;;
+ *.sh) lang=bash; os='["linux","macos"]' ;;
+ *.py) lang=python; os='["windows","linux","macos"]' ;;
+ *.bat|*.cmd) lang=cmd; os='["windows"]' ;;
+ *) continue ;;
+ esac
+ name=$(basename "$f"); name="${name%.*}"
+ entries+=("$(jq -nc --arg n "$name" --arg l "$lang" --argjson o "$os" --rawfile c "$f" \
+ '{name:$n, language:$l, osTypes:$o, content:$c, runAs:"system",
+ timeoutSeconds:300, description:"Imported during RMM migration"}')")
+done
+bundle=$(printf '%s\n' "${entries[@]}" | jq -sc '{bundleVersion: 1, scripts: .}')
+
+# 1. Preview — writes nothing; annotates each entry new / name-conflict / invalid.
+curl -sf "${AUTH[@]}" -X POST "$BREEZE_URL/scripts/bundle/preview" \
+ -d "$(jq -nc --argjson b "$bundle" '{bundle: $b, availability: "partner"}')" \
+ | jq -r '.entries[] | "\(.status)\t\(.name)\t\(.error // "")"'
+
+# 2. Import. mode picks the name-conflict strategy:
+# skip | rename ("Name (2)") | new-version (snapshots the old body, bumps version)
+curl -sf "${AUTH[@]}" -X POST "$BREEZE_URL/scripts/bundle/import" \
+ -d "$(jq -nc --argjson b "$bundle" '{bundle: $b, availability: "partner", mode: "skip"}')" \
+ | jq '{imported, skipped, renamed, versioned, errors}'
+```
+
+**Bundle entry fields** (same vocabulary as `POST /scripts`)
+
+| Field | Required | Notes |
+|---|---|---|
+| `name` | yes | ≤255 chars |
+| `osTypes` | yes | Array, at least one of `windows`, `macos`, `linux` |
+| `language` | yes | `powershell`, `bash`, `python`, `cmd` |
+| `content` | yes | The script body, ≤256KB |
+| `runAs` | no | `system` (default), `user`, `elevated` |
+| `timeoutSeconds` | no | Default 300, hard cap 3600 — the agent clamps at one hour |
+| `description`, `category`, `tags`, `parameters` | no | Tags are resolved/created in the target scope |
+| `exitCodeSeverityMapping` | no | Map exit codes to alert severities; a mapping that maps *every* code to null is rejected |
+
+Two availability notes. `availability` defaults to `org` — publishing to your whole partner library (`"partner"`, the right choice for a shared MSP toolkit) must be an explicit ask, and it requires **full partner org access**: a technician whose partner account is restricted to selected orgs gets a `403` on the partner-wide path rather than a mystery. And bundles are treated as untrusted input — entries are validated one by one (a bad entry lands in `errors` while the rest import), nothing in a bundle is ever executed at import time, system/tenancy flags inside a bundle are stripped and never honored, and every imported script is individually audited with the bundle's SHA-256.
+
+Check `GET /scripts/system-library` before importing — a large share of typical custom scripts already ship with Breeze, and `POST /scripts/import/:id` clones one into your library without you maintaining it.
+
+---
+
+## Known Rough Edges
+
+These are real friction points in the current release. Each is tracked; if one blocks you, say so on the issue.
+
+| Gap | Workaround |
+|---|---|
+| No bulk import for **devices** (orgs and sites now have one — Recipe 1) | Devices arrive by enrolling agents: Recipes 2–3 |
+| Bulk org import lives on the main API only (JWT + MFA), not the Partner API | One interactive run, or the web UI; unattended provisioning goes through Recipe 2's per-record Partner API creates |
+| `POST /devices/provision` is single-device only | Loop it |
+| PSA `getCompanies()` exists on every adapter but is not wired to org import | Export from the PSA manually — the import's `externalId`/`externalSystem` seam is built for this, and a PSA-backed source is the planned next phase (#3246) |