MDM reference
Depth behind Bulk enrollment with your MDM.
SCIM or MDM
The two solve different halves of fleet onboarding.
| Mechanism | Source of truth | Credential | Best for |
|---|---|---|---|
| Inbound SCIM | Your IdP owns users, groups, teams, roles, activation, and offboarding. | Static SCIM bearer sent to <gateway>/scim/v2. Only its hash is stored. | Identity lifecycle and immediate deactivation of existing keys. |
| MDM provisioning | Your device manager runs enrollment on each endpoint. | Reusable enp_… bootstrap token. Each device receives its own ark_… key. | Installing tool config and enrolling managed or headless machines. |
A SCIM deactivation blocks that identity's already-issued key. Revoking an enp_… token only stops
future device enrollments. Setup:
Set up inbound SCIM.
Managed Macs
The profile is a managed-preferences payload for ai.anyray.connect with two keys:
EnrollmentUrl (the reusable enrollment link, a credential) and EnrollmentEmail ($EMAIL).
Who can enroll in the console shows the email domains baked into the profile. Changing domains
needs a new profile.
Create & download appears only after the public signed installer is published. The profile is shown once. If it is lost, create a replacement and revoke the old entry under MDM Users.
umask 077
curl --fail-with-body -sS -X POST \
https://gateway.example.com/admin/provisioning-tokens \
-H "Authorization: Bearer $ANYRAY_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"kind": "mdm",
"identityMode": "email",
"proofMode": "managed-preference",
"allowedDomains": ["example.com"]
}' | jq -er '.mobileconfig' > anyray-enroll.mobileconfig
Retries, repair, and opting out
If the profile lands first, the package starts enrollment for the signed-in desktop user. If the package arrives first, its LaunchAgent waits for the managed preference. A Mac with no desktop user enrolls at the next login.
The desktop app starts enrollment on its first doctor poll. A temporary failure retries in 30 seconds and doubles to a 10-minute cap while the dot stays yellow. A permanent rejection turns the dot red with "contact your admin" and re-arms after 30 minutes. The package runner keeps its own cadence:
- Temporary failures (no network, gateway down) retry after five minutes.
- A permanent failure (revoked link, email outside the allowed domains) stops until the profile changes.
- Later logins reuse a healthy enrollment, repair an expired one, and move the Mac to a rotated profile.
- If
~/.anyrayis deleted, the next key refresh re-enrolls without waiting for a login. - A full local
anyray-connect --revertopts that account out until Connect is applied again. - A scoped
--tools <id> --revertdoes not opt out. The console tool policy decides what this run applies, so the next managed enrollment re-applies that tool if the admin still enables it. See the Policy sync note under Fleet surfaces.
If your MDM cannot fill the email
Leave EnrollmentEmail empty, as with Jamf Now. The Mac enrolls as
<local username>@<first allowed domain>. With no allowed domain and no email, enrollment stops
and the Mac reports the failure.
What is bound to each Mac
Connect generates a key for the signed-in user, sends the managed email and the enrollment link
to Anyray, and receives a DevCert with source provisioning binding the email and deployment to
that key. Requests use the Mac's short-lived ark_… key. The certificate renews on its own
while the Mac is in use, also under SSO, so no end-user sign-in is ever needed. The gateway does
not call the MDM or fleetd.
Remove or rotate the profile
Rotating the provisioning token returns a new one-time profile with a new enrollment link. Deploy the new profile, then remove the old one. Macs move to the new link at their next login.
Rotation stops new enrollment with the old link, and removing the profile removes its enrollment
link. Neither action revokes a DevCert or ark_… key already issued. To offboard a person,
revoke the user, then the live key.
Windows and Linux
The Windows installer verifies the binary, installs it in the user's profile, registers a per-user
scheduled task, and invokes managed enrollment once. The Linux package installs
/usr/bin/anyray-connect, a systemd user service, and an XDG autostart entry.
When moving a fleet to the desktop app, verify app ownership and login state for every
assigned user before retiring the CLI package's system-wide enrollment bootstrap. On
macOS, IT unloads ai.anyray.connect.managed-enroll from each affected login session,
then removes /Library/LaunchAgents/ai.anyray.connect.managed-enroll.plist through MDM.
The per-user app does not elevate itself to remove a system-wide job. Keep the bootstrap
on machines that still need CLI-only enrollment. This is separate from the old tray job,
ai.anyray.connect-tray, which the desktop app migrates itself.
Linux desktop packages replace the anyray-connect CLI package while retaining
/usr/bin/anyray-connect. The package transaction removes the CLI package's global
managed-enrollment service and autostart file. Deploy the desktop to all assigned users
of that machine before making this replacement.
The logon runner is silent and idempotent. Each logon reuses a healthy key, repairs an expired one,
and re-binds the machine after a changed EnrollmentEmail or rotated link. Failures retry at the
next logon. The runner never prints the enrollment link.
Email fallback
When the MDM cannot substitute a usable email, Connect falls back to
<local username>@<first allowed domain>, minus any leading DOMAIN\ prefix on Windows. With
no allowed domain, enrollment stops and retries at the next trigger.
Rejected policy files (Linux)
Connect ignores policy files that are not regular, are not root-owned, are group or other-readable, or exceed 64 KiB.
anyray-connect doctor --json reports a rejected local policy as
enrollment.managedPreference: "invalid". The adjacent reason code is one of unparsable,
malformed-url, untrusted-file, or unreadable; it never includes the link, email, or file path.
Provisioning tokens
allowedDomains holds one domain from the Email domains card; omit it to use the first.
Domains match exactly: sub.customer.example.com does not satisfy
customer.example.com.
Machine mode for shared or headless machines
Mint the token with Mode machine. Connect derives a stable machine id and records the user as
machine:<id>. Set a default team at mint so spend rolls up. Use --machine in place of
--user:
- macOS / Linux
- Windows (PowerShell)
curl -fsSL https://app.anyray.ai/connect.sh | sh -s -- --enroll https://app.anyray.ai/enroll/enp_synthetic_example --machine --yes
& ([scriptblock]::Create((irm https://app.anyray.ai/connect.ps1))) "--enroll" "https://app.anyray.ai/enroll/enp_synthetic_example" "--machine" "--yes"
A machine:… certificate keeps its fixed 1-year lifetime, so your MDM re-enrolls the device
unattended when it lapses. Machine-mode tokens are blocked once SSO is active.
Enroll the machine too with --endpoint
An enrollment command configures tools only. Adding --endpoint also installs the endpoint agent
and brings the machine under policy, on macOS, Linux, and Windows. The console's MDM command never
carries the flag, so append it yourself. --no-endpoint overrides it.
The agent package is shared across organizations and fetches the org-specific fleet URL and enroll
secret after client-key authentication. On macOS and Linux the command raises one sudo prompt
itself. Windows has no equivalent: run it from a PowerShell session started with Run as
Administrator, or it configures tool routing and leaves the machine unenrolled.
For locked-down fleets where developers cannot install privileged software, push the shared agent package and the organization's enrollment profile through MDM.
Configuring Desktop switches the app to third-party inference, which signs it out of claude.ai. The
admin console stops delivering connectors, plugins, and model policy to that machine. Connect never
takes that trade from a sweep: --yes configures every other detected tool and names Desktop in the
output. Add --tools claude-desktop when you do want it, and reverse it per machine with
anyray-connect desktop disconnect. See
Claude Desktop.
Carry local Claude Desktop configuration
When an MDM policy does configure Claude Desktop for third-party inference, Connect copies local
MCP servers, user-created skills, and user-installed plugins into the third-party profile on its
own. MCP servers copy on the next reconcile; skills and plugins copy the next time Claude Desktop
is fully quit, because the app rewrites those stores on exit. It never changes the first-party
profile, and an entry already in the third-party profile wins. anyray-connect doctor shows the
result under Claude Desktop profile; the states are listed in the
Connect reference.
Chats from the claude.ai account are not local profile data. Export them from claude.ai, then run
anyray-connect migrate --export <file.zip> after Claude Desktop is fully quit.
Legacy MDM tokens
The older no-sign-in lane, verified by endpoint evidence. New rollouts use the
macOS package path
instead. Existing MDM-kind tokens without proofMode: managed-preference keep their behavior: they
enroll with no sign-in, SSO on or off, require email identity mode, and are trusted only after the
endpoint agent proves the device is enrolled in a registered MDM server.
Managed-device evidence today covers macOS fleets under Jamf. Both evidence lanes read the same
policy from the endpoint pack, Anyray - enrolled in the expected MDM. The injected
EnrollmentEmail is MDM-supplied attribution, not a verified identity. The agent proves the
device is managed, not who is at the keyboard.
Set up the legacy lane
On the Users page, open the MDM enrollment card. Add each MDM server under Registered MDM servers, one bare HTTPS URL per line, then save:
https://mdm.customer.example.com
An empty list blocks this lane. Saving a changed list re-templates the policy on the active lane.
Hosts report the new result on their next policy run, about ten seconds for the local service, so a
fresh registration can refuse briefly with mdm_not_verified.
The console no longer mints this token. Call post
/admin/provisioning-tokens with kind: "mdm" and identityMode: "email". Copy the
origin-qualified enp_synthetic… URL from the response, because the raw token is not recoverable
later. Rotate with post /admin/provisioning-tokens/:id/rotate. A refused
rotation leaves the current token intact.
Deploy a custom profile to all target Macs that writes the managed preference domain
ai.anyray.connect, key EnrollmentEmail, with the literal Jamf variable $EMAIL. Jamf replaces
that value from the computer's User and Location record.
For Intune, write the same value as a machine policy:
$keyPath = 'HKLM:\SOFTWARE\Policies\Anyray\Connect'
New-Item -Path $keyPath -Force | Out-Null
Set-ItemProperty -Path $keyPath -Name 'EnrollmentEmail' `
-Value 'dev@example.com' -Type String
Connect checks the per-user macOS managed preference first, then the device-wide one. On Windows it
reads HKLM\SOFTWARE\Policies\Anyray\Connect\EnrollmentEmail. The managed email is honoured only
for an MDM-kind token, and it then wins over --user. The team comes from the token's default team.
Target All Managed Macs. Install the shared Anyray endpoint-agent package and deploy the organization's endpoint enrollment profile before the enrollment command runs. Scope the Jamf email profile to the same Macs. The agent must report Anyray - enrolled in the expected MDM before the gateway accepts the machine.
Create a Smart Group named agent installed and scope Policy B to it. Add both Enrollment
Complete and Recurring Check-in triggers. Set execution frequency to Once per user per
computer. Run the enrollment command as the signed-in console user, with no --user flag:
console_user=$(/usr/bin/stat -f %Su /dev/console)
if [ -n "$console_user" ] && [ "$console_user" != "root" ]; then
/usr/bin/sudo -u "$console_user" -H /bin/sh -c '
cd "$HOME" || exit 1
curl -fsSL https://app.anyray.ai/connect.sh | sh -s -- \
--enroll https://app.anyray.ai/enroll/enp_synthetic_example --yes
'
fi
Connect reads EnrollmentEmail, enrolls with source: mdm, and configures the tools selected by
policy. No browser sign-in occurs.
What the gateway verifies
/connect/verify and later /connect/key/heartbeat requests carry the machine UUID beside the
signed proof, which the gateway hashes before storage. It then requires all of:
- An MDM server URL registered in the console.
- The machine UUID joining to a reporting endpoint agent.
- The pack policy Anyray - enrolled in the expected MDM passing on that host.
- The endpoint service reachable, with evidence current enough to use.
A refusal returns managed_device_required with one closed reason:
| Reason | Meaning |
|---|---|
no_mdm_server_registered | The console has no registered MDM server URL. |
agent_missing | The machine UUID does not join to an endpoint-agent host, or that host has not reported for over 7 days. |
mdm_not_verified | The MDM policy is failing, or has no result yet, on the host. |
endpoint_unreachable | The evidence lane cannot be read safely, or has not yet converged on the current registered MDM server list. |
Each refusal lands in the audit log (anyray_audit_log, category client-keys, action
mdm-device-refused) with the user, the reason, and the lane. Metadata only.
The gateway repeats this check on every key heartbeat, and every refusal is audited. Only
mdm_not_verified revokes the key. The other three leave it in place for the next heartbeat.
The shipped code does not force a heartbeat every 30 minutes for every MDM provisioning key. That scheduler serves short-lived keys and a small set of seats with no other refresh trigger. Default provisioning keys are long-lived, and the endpoint-agent freshness policy runs its refresh remediation only near key expiry.
Use SCIM deactivation for an immediate gateway block. For console-managed offboarding, remove the person, revoke the live key, then retire the Mac in MDM.
An optional Jamf offboarding policy can run anyray-connect --revert as the console user to remove
local tool configuration and the stored enrollment. That is endpoint cleanup. Gateway-side
revocation remains the access control.
Fleet surfaces
Run the enrollment command in the signed-in user's context. One push per surface.
| Surface | Push | Notes |
|---|---|---|
| Claude Code | Routed by enrollment. | An API-key fleet can also pin routing with managed settings. |
| Codex app / CLI / IDE | Enroll as the signed-in user. | API-key and ChatGPT-seat routing are automatic. If the model picker omits custom models, set ANYRAY_CODEX_MODEL and rerun Connect. For managed API-key auth, deploy the desktop helpers. |
| Claude Desktop | The console-generated org policy .mobileconfig plus the helpers. | Third-party inference ends claude.ai admin-console delivery. Connector grants are not exportable, so users re-authorize after deployment. To keep the app first-party, push a first-party profile. |
| JetBrains | The acp.json artifact (below). | Needs IDE 2025.3.2+ with JetBrains AI 253.30387.147+. Versions below the floor are left unchanged. ACP traffic routes automatically. Native JetBrains AI and Copilot traffic does not. |
| Devin Desktop | Enroll as the signed-in user, then enable the ACP agents once in Devin and restart. | Native usage is visible only through the Devin connector. |
| VS Code Copilot Chat | Enroll as the signed-in user. Developers select an Anyray · … model. | The key goes to VS Code SecretStorage. Native traffic stays on GitHub. The extension installs into every profile registered at apply time. A later profile needs a re-run. |
| Cursor | anyray-connect <gateway> --tools cursor --user <email> --yes, then restart once for MCP. | Native Team Claude/Opus stays the default, and no provider key is added. Native inference stays on Cursor with no gateway trace. See Cursor. |
| OpenCode | Routed by enrollment, including an install that happens later. | Its Anthropic and OpenAI providers take the org key; a Copilot seat routes through the seat lane. A ChatGPT Pro/Plus sign-in inside OpenCode cannot be routed: its endpoint is hard-coded, so those turns never reach the gateway. |
ACP surfaces (JetBrains, Devin) need Node, and have no WSL support. For Remote Development, run Connect as the remote backend user.
For Devin Enterprise and GitHub Copilot Business/Enterprise reporting, configure subscription spend connectors once on the gateway.
Policy sync
The values emitted by anyray-connect managed bootstrap enrollment and older clients. Enrolled
clients treat /connect/policy as the live source for routing origin, team attribution, enabled
tools, and shared skills. sync and the detached refresh worker repair drift automatically.
Tool reconciliation is enable-side authoritative with revert tombstones. An enabled id the machine
never ran enrolls fleet-wide. A developer's intentional revert sticks, and only the locked tier
overrides the tombstone. An omitted id is never reverted or removed.
On a managed Mac, the periodic managed-enrollment run reads the same /connect/policy tool list,
and it, not the developer, decides what this run configures. It applies every tool the policy
enables and reverts a tool the policy no longer enables, even if the Mac still has it configured. A
developer's local revert never blocks this: the tombstone is not consulted, so an admin-enabled
tool always lands on the next managed enrollment. This run's applied NDJSON event lists every
tool it skipped or reverted, with the reason, so a dropped tool always has a line explaining why.
Claude Desktop and Codex fleet policies
Owned by Desktop apps: managed fleets and the
desktop command reference. In short: deploy a stable
/usr/local/bin/anyray-connect, install both helpers with desktop helper --write, then emit
policy with desktop fleet static (Claude Desktop) or desktop codex --mobileconfig (Codex),
passing the same fleet-stable helper path. Neither wrapper holds a key.
JetBrains and shell artifacts
Credentialless managed --print artifacts, explicit flags only. Stdout is the artifact.
anyray-connect managed --print --tool jetbrains \
--bin /usr/local/bin/anyray-connect --npx /usr/local/bin/npx > acp.json
Entries launch Connect's ACP agents at the fleet-stable paths. Gateway, attribution, and the
personal key resolve from each user's own enrollment at agent launch, so the file carries none of
them. An MDM file drop replaces the whole file, so merge the agent_servers object in your config
tool if developers add their own agents.
anyray-connect managed --print --tool shell --gateway https://customer.example.com > anyray.sh
The snippet exports the two gateway base URLs and nothing else, so a shared file never carries a credential or shadows a seat sign-in.
Zero-touch bootstrap for ACP and editor surfaces
A developer who never ran Connect still needs one enrollment. Deploy the fleet bootstrap file, and the ACP launcher starts enrollment for them.
anyray-connect managed --print --tool bootstrap \
--sso https://app.anyray.ai/sso/tnt_synthetic --gateway https://customer.example.com \
--bin /usr/local/bin/anyray-connect > bootstrap.json
--bin is optional and records where your MDM installed Connect, for trigger surfaces that are not
the binary itself. Today the VS Code extension uses it.
Deploy the bootstrap file admin or root-owned, never user-writable. On Windows, --bin must point
under Program Files, which is enforced at generation. %ProgramData% itself lets standard
users create files, so have your MDM create %ProgramData%\Anyray\bootstrap.json as SYSTEM at
provisioning time, before first login. An invalid bin value is ignored.
Deploy it to the fixed system path: /etc/anyray/bootstrap.json (Linux),
/Library/Application Support/Anyray/bootstrap.json (macOS), or
%ProgramData%\Anyray\bootstrap.json (Windows). Three surfaces read it: the ACP launcher, the
prompt hook, and the VS Code extension. Each shows a sign-in message with a verification URL
and code on first unenrolled use. Prompts are never blocked or altered, sign-in completes in the
background, and the next use works. The file is configuration, not a credential, so world-readable
is fine.
Shells and arbitrary SDKs have no trigger surface that can safely pause a request for sign-in. The managed shell snippet routes them to the gateway, but each user must enroll once first. For a non-interactive fleet job, use that job's secret-management system instead.
Managed settings for Claude Code
Claude Code reads a system-path managed-settings.json that ranks above user and project
settings, so your MDM can pin gateway routing fleet-wide. anyray-connect managed emits or installs
that file with no credential.
This artifact is for API-key fleets. The file owns routing and headers, while per-user ark_
keys come from provisioning-token enrollment and stay in user scope. For a subscription fleet an env
file is rejected. --sso <link> emits a hooks-only file that configures Claude Code only, which is
the fix for a policy that sets allowManagedHooksOnly
(troubleshooting), not a fleet enrollment. Enroll seat fleets
through an MDM lane.
| OS | Path |
|---|---|
| macOS | /Library/Application Support/ClaudeCode/managed-settings.json |
| Linux / WSL | /etc/claude-code/managed-settings.json |
| Windows | C:\Program Files\ClaudeCode\managed-settings.json |
Claude Code before v2.1.75 uses the legacy Windows path
C:\ProgramData\ClaudeCode\managed-settings.json.
anyray-connect managed --print --gateway https://customer.example.com --team eng > managed-settings.json
managed --print writes JSON to stdout for a Jamf profile, Intune policy, or Ansible template,
with notes on stderr. On a single host, sudo anyray-connect managed --write … merges into any
existing file and backs up the original. managed --revert removes only the Anyray-owned keys. The
file loads at Claude Code start, so restart open sessions.
Packaging from PowerShell needs --out
Add --out <path> to write the file directly, UTF-8 with no BOM. Windows PowerShell 5.1 writes
UTF-16LE for >, and -Encoding utf8 adds a BOM, so no JSON reader accepts the redirect.
The connect.ps1 installer ends by propagating Connect's exit code, which unwinds out of the
scriptblock and closes the window. A form that captures the output and writes it on a second
statement never writes anything. --out writes within the run.
Prefer --team over --user, because a static header cannot vary per machine. --intent,
--intent-label, and --session are also accepted.
When an existing managed file conflicts
If the managed file already carries an ANTHROPIC_BASE_URL from other tooling, it outranks
whatever per-user enrollment writes. anyray-connect then reports success on every machine while
the traffic goes to the older endpoint.
anyray-connect doctor flags the Connect-owned keys that disagree with the managed file,
naming the key, the path, and the origin requests actually reach. Agreement is silent. No re-run
and no doctor --repair can outrank managed scope, so the fix is always to change the file.
A machine that never enrolled has nothing in user scope for the comparison, so a managed base URL there reads as a plain unrouted tool.
forceLoginMethod conflicts
Claude Code documents that forceLoginMethod and forceLoginOrgUUID cannot be combined with
gateway credentials from v2.1.146 onward. A fleet that pins Claude Code sign-in that way has to
resolve it first. The seat lane is the likely exception, because Claude Code keeps its own
sign-in there and the personal key rides a separate header. Verify on one machine.
Whether its env block overrides an exported shell variable such as ANTHROPIC_BASE_URL in
~/.zshrc is undocumented by Claude Code. Verify routing on one machine before a fleet rollout.
Offboard a machine-mode device
User revocation takes an email, so it covers email identity mode only. A device enrolled in
machine mode is bound to machine:…, which the revocation list cannot hold. Revoke its live key
(delete /admin/client-keys/:id) for the cutoff, and revoke the provisioning
token so it cannot re-enroll. Its certificate is not rolled forward, so it also lapses at its fixed
1-year expiry.