Skip to content

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.


Terminal window
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 record
curl -sf -H "Authorization: Bearer $BREEZE_TOKEN" "$BREEZE_URL/orgs/partners/me" | jq .name

Recipe 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,site
Acme Manufacturing,Head Office
Acme Manufacturing,Detroit Plant
Bright Dental,Main Clinic

This 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.csv
set -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_ID
while 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)"
fi
done

Field 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})

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>rawKey
set -euo pipefail
: "${BREEZE_URL:?}" "${BREEZE_TOKEN:?}"
AUTH=(-H "Authorization: Bearer $BREEZE_TOKEN" -H "Content-Type: application/json")
TTL_MINUTES="${TTL_MINUTES:-43200}" # 30 days
CAPACITY="${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
done

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.


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.

Terminal window
$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]::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'

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.


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

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.

Terminal window
# Which devices in this org still have the incumbent RMM installed?
for id in $(curl -sf -H "Authorization: Bearer $BREEZE_TOKEN" \
"$BREEZE_URL/devices?orgId=$ORG_ID&limit=100" \
| jq -r '[.data[]?,.devices[]?][] | .id'); do
curl -sf -H "Authorization: Bearer $BREEZE_TOKEN" \
"$BREEZE_URL/devices/$id/management-posture" \
| jq -r --arg id "$id" '.. | .name? // empty' | sort -u \
| grep -iE 'datto|ninja|automate|kaseya|n-able|atera|syncro|pulseway|level|tactical' \
&& echo " ^ device $id"
done

Use it twice: before cutover to confirm you know what you are replacing, and after uninstall to prove the count reached zero.


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"
done

Field 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.


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
Management Posture is not surfaced as a fleet-wide migration report Recipe 5
PSA getCompanies() exists on every adapter but is not wired to org import Export from the PSA manually