Migration Toolkit
Breeze has no bulk-import UI today. Every migration step that needs to happen a hundred times is scripted 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.
Authentication for Migration Scripts
Section titled “Authentication for Migration Scripts”export BREEZE_URL="https://breeze.yourdomain.com/api/v1"export BREEZE_TOKEN="eyJ..." # partner-admin JWT with MFA satisfied
# Sanity check — should return your partner recordcurl -sf -H "Authorization: Bearer $BREEZE_TOKEN" "$BREEZE_URL/orgs/partners/me" | jq .nameRecipe 1 — Bootstrap the Tenancy Tree from CSV
Section titled “Recipe 1 — Bootstrap the Tenancy Tree from CSV”Every per-vendor page produces a CSV in this shape:
organization,siteAcme Manufacturing,Head OfficeAcme Manufacturing,Detroit PlantBright Dental,Main ClinicThis script creates each organization once and each site under it, and is safe to re-run — it skips names that already exist, so you can fix a bad row and run it again.
#!/usr/bin/env bash# bootstrap-tree.sh — build Breeze orgs + sites from a two-column CSV.# Usage: ./bootstrap-tree.sh tree.csvset -euo pipefail
: "${BREEZE_URL:?}" "${BREEZE_TOKEN:?}"CSV="$1"AUTH=(-H "Authorization: Bearer $BREEZE_TOKEN" -H "Content-Type: application/json")
api() { curl -sf "${AUTH[@]}" "$@"; }
# Breeze slugs are 1-100 chars; derive one deterministically from the name.slugify() { echo "$1" | tr '[:upper:]' '[:lower:]' \ | sed -E 's/[^a-z0-9]+/-/g; s/^-+|-+$//g' | cut -c1-100}
# Cache existing orgs so re-runs are idempotent.declare -A ORG_IDwhile IFS=$'\t' read -r id name; do ORG_ID["$name"]="$id"; done < <( api "$BREEZE_URL/orgs/organizations?limit=100" | jq -r '.data[]? // .organizations[]? | [.id,.name] | @tsv')
tail -n +2 "$CSV" | while IFS=, read -r org site; do org="$(echo "$org" | xargs)"; site="$(echo "$site" | xargs)" [ -z "$org" ] && continue
if [ -z "${ORG_ID[$org]:-}" ]; then id=$(api -X POST "$BREEZE_URL/orgs/organizations" \ -d "$(jq -nc --arg n "$org" --arg s "$(slugify "$org")" \ '{name:$n, slug:$s, type:"customer", status:"active"}')" | jq -r .id) ORG_ID["$org"]="$id" echo "org + $org ($id)" fi
[ -z "$site" ] && continue existing=$(api "$BREEZE_URL/orgs/sites?orgId=${ORG_ID[$org]}" \ | jq -r --arg s "$site" '[.data[]?,.sites[]?] | map(select(.name==$s)) | .[0].id // empty') if [ -z "$existing" ]; then sid=$(api -X POST "$BREEZE_URL/orgs/sites" \ -d "$(jq -nc --arg o "${ORG_ID[$org]}" --arg n "$site" \ '{orgId:$o, name:$n, timezone:"UTC"}')" | jq -r .id) echo "site + $org / $site ($sid)" fidoneField reference
| Endpoint | Required | Useful optional |
|---|---|---|
POST /orgs/organizations |
name, slug (≤100 chars) |
type (customer|internal), status (active|trial|suspended|churned), contractStart, contractEnd, billingContact |
POST /orgs/sites |
orgId, name |
timezone (IANA, defaults UTC), address, contact ({name,email,phone}) |
Recipe 2 — Mint Bulk Enrollment Keys
Section titled “Recipe 2 — Mint Bulk Enrollment Keys”The defaults on POST /enrollment-keys are tuned for installing one agent by hand: maxUsage: 1 and a 60-minute TTL (configurable via ENROLLMENT_KEY_DEFAULT_TTL_MINUTES). 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. |
#!/usr/bin/env bash# mint-keys.sh — one long-lived, high-capacity enrollment key per site.# Prints: org<TAB>site<TAB>siteId<TAB>rawKeyset -euo pipefail: "${BREEZE_URL:?}" "${BREEZE_TOKEN:?}"AUTH=(-H "Authorization: Bearer $BREEZE_TOKEN" -H "Content-Type: application/json")TTL_MINUTES="${TTL_MINUTES:-43200}" # 30 daysCAPACITY="${CAPACITY:-250}"
curl -sf "${AUTH[@]}" "$BREEZE_URL/orgs/organizations?limit=100" \ | jq -r '[.data[]?,.organizations[]?][] | [.id,.name] | @tsv' \| while IFS=$'\t' read -r orgId orgName; do curl -sf "${AUTH[@]}" "$BREEZE_URL/orgs/sites?orgId=$orgId" \ | jq -r '[.data[]?,.sites[]?][] | [.id,.name] | @tsv' \ | while IFS=$'\t' read -r siteId siteName; do raw=$(curl -sf "${AUTH[@]}" -X POST "$BREEZE_URL/enrollment-keys" \ -d "$(jq -nc --arg o "$orgId" --arg s "$siteId" \ --arg n "migration: $orgName / $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' "$orgName" "$siteName" "$siteId" "$raw" done doneTreat 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
Section titled “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.
$ErrorActionPreference = 'Stop'$Server = 'https://breeze.yourdomain.com'$Key = '<64-hex-enrollment-key>'$Secret = '<AGENT_ENROLLMENT_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]::Tls12Invoke-WebRequest -UseBasicParsing -Uri "$Server/api/v1/agents/download/windows/amd64" -OutFile $Exe
& $Exe enroll $Key --server $Server --enrollment-secret $Secret --quietif ($LASTEXITCODE -ne 0) { throw "enroll failed: $LASTEXITCODE" }& $Exe service installWrite-Output 'breeze agent enrolled'#!/usr/bin/env bashset -euo pipefailSERVER='https://breeze.yourdomain.com'KEY='<64-hex-enrollment-key>'SECRET='<AGENT_ENROLLMENT_SECRET>'SITE_ID='<site-uuid>'
case "$(uname -s)" in Darwin) OS=darwin; CFG='/Library/Application Support/Breeze/agent.yaml' ;; Linux) OS=linux; CFG='/etc/breeze/agent.yaml' ;;esaccase "$(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 installecho '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
Section titled “Recipe 4 — Reconcile Enrollment”The verification gate for Phase 4. Compare a per-org device-name list from the incumbent against what actually enrolled.
#!/usr/bin/env bash# reconcile.sh — list devices present in the old RMM but missing from Breeze.# Usage: ./reconcile.sh <breeze-org-id> <old-rmm-hostnames.txt>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.txtMind 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
Section titled “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. 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):
# 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:
# 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
Section titled “Recipe 6 — Bulk Script Import”Breeze has no script import endpoint, but POST /scripts accepts one script per call, so a directory of .ps1 / .sh files loops cleanly.
#!/usr/bin/env bash# import-scripts.sh — load a directory of scripts into the Breeze library.set -euo pipefail: "${BREEZE_URL:?}" "${BREEZE_TOKEN:?}"
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%.*}" curl -sf -H "Authorization: Bearer $BREEZE_TOKEN" -H "Content-Type: application/json" \ -X POST "$BREEZE_URL/scripts" \ -d "$(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, availability:"partner", description:"Imported during RMM migration"}')" \ >/dev/null && echo "imported $name"doneField reference for 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 |
availability |
no | partner publishes to your whole partner library — the right default when migrating a shared MSP toolkit. org keeps it to one customer. |
runAs |
no | system (default), user, elevated |
timeoutSeconds |
no | Default 300, hard cap 3600 — the agent clamps at one hour |
exitCodeSeverityMapping |
no | Map exit codes to alert severities; a good replacement for incumbent scripts that raised alerts by writing to a monitor |
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
Section titled “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 CSV/bulk import for orgs, sites, or devices | Recipe 1 |
| Tenancy-tree writes are MFA-gated and JWT-only — no machine-to-machine provisioning credential | Run scripts with a freshly-minted partner-admin JWT; refresh mid-run |
| Partner API is read-only — no write/ingest side | Use the main API |
| No script import/export endpoint or bundle format | Recipe 6 |
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 |