auspex: IT Admin User Guide for Workspace ONE (macOS)

This guide is for IT administrators deploying the Span agent (auspex) to macOS devices via VMware Workspace ONE (UEM).


What This Package Does

auspex captures AI coding activity (prompts, file edits, tool use) from supported IDEs (Cursor, Claude Code, Codex, and more) and sends telemetry to Span's analytics backend. This data appears in your Span dashboard under AI Effectiveness.

The macOS installer is a single universal .pkg. When it installs elevated (as Workspace ONE does), it places the binary at /usr/local/bin/auspex, drops a root-owned managed configuration at /Library/Application Support/auspex/ (the managed.yaml marker = managed mode), and converges automatically (postinstall runs auspex install --system, wires machine-wide hooks, provisions each engineer's identity, and starts the per-user agent at login).

No separate "install hooks" step is needed — the package converges itself. Your job is to (a) pre-authorize file access so users are never prompted, and (b) deliver each device's identity (token + work email) before the package installs.


Prerequisites

  1. A Span account with access to the integrations settings page.
  2. A Workspace ONE UEM tenant managing the target macOS devices (macOS 13+, supervised), with the Workspace ONE Intelligent Hub installed and each device's user email populated (from your directory service).
  3. The macOS package: https://auspex.span.app/releases/latest/darwin/auspex_darwin_universal.pkg (download and upload as an Internal app).

Deployment order (important)

  1. Custom Settings profile (file access + background items) — first.
  2. Identity script (writes identity.json) — before the package.
  3. The package (Internal application, Auto deployment).

Step 1: Enable the Integration and Get Your Token (one-time)

This step is the same regardless of your MDM.

Head to the AI tool settings dashboard (https://span.app/_/settings/integrations) and under IDE & CLI AI Tool Integrations, enable the tools your engineers use:

Tool What's captured
Claude Code OTEL metrics + hooks
Cursor Hooks
Codex CLI OTEL metrics + hooks
Copilot CLI Hooks
VSCode chat Hooks

If a tool has no dashboard toggle, enable Claude Code or Codex CLI to generate the (org-wide) token.


Step 2: Deploy the File-Access + Background-Items Profile (before the package)

auspex reads the edited file from the git working tree the moment an AI edit fires. For users with repos under a TCC-protected folder (Documents, Desktop, Downloads), that would prompt:

"auspex" would like to access files in your Documents folder.

Deploy the combined profile before the package so no one is prompted. It grants Full Disk Access (PPPC), pre-approves auspex's background items, and silences the background-activity notification.

  1. In the WS1 console: Resources → Profiles & Baselines → Profiles → Add → Add Profile → macOS → Device Profile.
  2. Add a Custom Settings payload and paste the contents of auspex-background-items.mobileconfig (the inner payload dictionaries), or use Upload where your console version supports uploading a raw .mobileconfig.
  3. Assign to the smart group that will receive the package. Save & Publish.

The profile ships with Span's Developer ID team (KNH44SBYN4) and binary path pre-filled. Confirm against a deployed Mac if needed: codesign -dr - /usr/local/bin/auspex.

Requires a supervised device from a trusted MDM (macOS 13+). Set the profile to be non-removable by the user for tamper-proofing.


Step 3: Deploy the Identity Script

Deliver the identity file with a macOS script (Resources → Scripts → Add → macOS), run in the System context, that writes identity.json using a Workspace ONE lookup value for the assigned user's email. Replace <YOUR_TOKEN> with the token from Step 1, and confirm the correct email lookup value for your tenant (commonly {EmailAddress}):

#!/bin/bash
set -euo pipefail

TOKEN="<YOUR_TOKEN>"
EMAIL="{EmailAddress}"     # Workspace ONE lookup value — resolves per-device

if [ -z "$EMAIL" ] || [[ "$EMAIL" != *"@"* ]]; then
  echo "ERROR: could not resolve work email from the lookup value." >&2
  exit 1
fi

DIR="/Library/Application Support/auspex"
mkdir -p "$DIR"
printf '{\n  "token": "%s",\n  "work_email": "%s"\n}\n' "$TOKEN" "$EMAIL" > "$DIR/identity.json"
chown root:wheel "$DIR/identity.json"
chmod 644 "$DIR/identity.json"
echo "Success: identity.json written for $EMAIL"

Assign the script to the same smart group, set to run before the package install (or on enrollment / a short recurring cadence). The identity.json is a delivery file the package's converge reads at install time, migrating it into each user's own private (0600) identity and removing it.

If your tenant's device records don't carry a per-user email, use a Custom Attribute synced from your directory and reference it in the script instead of {EmailAddress}.


Step 4: Deploy the Package

In the WS1 console: Resources → Apps → Native → Internal → Add → Application File → upload the universal .pkg.

  • Set the deployment type to Auto and assign to the same smart group as Steps 2–3.
  • Ensure the identity script (Step 3) is ordered to run before the app installs.

On install, the package converges the device to managed mode and starts the daemon.


Step 5: Smoke Test (before fleet rollout)

Validate on 1–2 pilot devices first (a pilot smart group). After the profile, script, and app deploy:

pkgutil --pkg-info app.span.auspex
auspex status        # stderr: install mode: managed
auspex auth show     # work email + MASKED token

Then confirm System Settings → Privacy & Security → Full Disk Access shows auspex enabled and "managed by your organization," and that the pilot user's traces appear in the dashboard after using a coding tool. Review script and app status in the WS1 console (Scripts / Apps sections).


Optional: Replace legacy Span coding-hooks in the same rollout

If you're migrating from the older coding-hooks agent, you can retire it on each device as part of this rollout. This step is optional and best-effort: it removes coding-hooks only after auspex is confirmed installed and converged — the per-user agent is running and an identity (work email + org token) is provisioned for every engineer on the device — and only if the coding-hooks uninstaller is present. It's safe to assign fleet-wide, including to devices that never had coding-hooks (there it simply no-ops).

Why bundle it: until coding-hooks is removed, a device running both agents will capture and send each event twice (duplicate traces). Gating removal on a confirmed and converged auspex install lets you cut over cleanly, one device at a time — with no window where both agents are running, and none where coding-hooks is gone but auspex hasn't finished provisioning (an unprovisioned device would otherwise lose coverage silently).

In Workspace ONE: add this as a macOS Script (Resources → Scripts), System context, assigned to the same smart group, with a periodic (not run-once) schedule. The self-gate makes ordering non-critical — it no-ops until auspex is installed and the assigned engineer is enrolled and capturing, then removes coding-hooks.

Run it on a repeating cadence, not once immediately after the package. Gate 3 reads per-user state — it needs the engineer to have logged in at least once, their identity provisioned by the converge, and their agent running. Seconds after the package lands that is usually not yet true, so an install-time run will no-op and print what it's waiting for. Let it repeat and it removes coding-hooks on the first run after the device has converged.

#!/bin/bash
# OPTIONAL post-install: retire legacy Span coding-hooks AFTER a confirmed, CONVERGED auspex install.
# Runs entirely as root: no login session, no launchctl, no per-user daemon. Always exits 0.
set -uo pipefail

CH_UNINSTALL="/Library/Application Support/app.span.coding-hooks/uninstall.sh"
HEARTBEAT_MAX_AGE=300   # seconds; a healthy agent beats every 15s

# Gate 1 — auspex package receipt present (install confirmed by macOS).
if ! /usr/sbin/pkgutil --pkg-info app.span.auspex >/dev/null 2>&1; then
  echo "auspex receipt not found — install not confirmed; leaving coding-hooks in place."
  exit 0
fi

# Gate 2 — auspex binary AND managed marker both in place.
if [ ! -x "/usr/local/bin/auspex" ] || [ ! -f "/Library/Application Support/auspex/managed.yaml" ]; then
  echo "auspex not fully in place (binary or managed marker missing) — leaving coding-hooks in place."
  exit 0
fi

# Gate 3 — EVERY user with an auspex runspace must be converged, running, and provisioned.
# Root reads the 0600 per-user identity directly, so no user context is needed. A shared Mac must
# not lose coding-hooks while any engineer on it is still unprovisioned.
found=0
for f in /Users/*/.auspex/run/identity.json; do
  [ -f "$f" ] || continue
  found=$((found + 1))
  home="${f%/.auspex/run/identity.json}"
  user=$(basename "$home")

  # 3a — the per-user agent is alive.
  hb="$home/.auspex/run/heartbeat"
  if [ ! -f "$hb" ]; then
    echo "$user: no heartbeat — agent not running; leaving coding-hooks in place."
    exit 0
  fi
  age=$(( $(date +%s) - $(/usr/bin/stat -f%m "$hb") ))
  if [ "$age" -gt "$HEARTBEAT_MAX_AGE" ]; then
    echo "$user: heartbeat stale (${age}s) — agent not running; leaving coding-hooks in place."
    exit 0
  fi

  # 3b — a work email is provisioned.
  if ! grep -q '"work_email"[[:space:]]*:[[:space:]]*"[^"]*@[^"]*"' "$f"; then
    echo "$user: no work email — not provisioned; leaving coding-hooks in place."
    exit 0
  fi

  # 3c — an org token is provisioned. Presence only: this script makes NO network call, so it
  # cannot tell you the control plane ACCEPTS the token (see the note below).
  if ! grep -q '"token"[[:space:]]*:[[:space:]]*"[^"]\{8,\}"' "$f"; then
    echo "$user: no org token — not provisioned; leaving coding-hooks in place."
    exit 0
  fi

  echo "$user: converged, agent live, identity provisioned."
done

if [ "$found" -eq 0 ]; then
  echo "no auspex user runspace found — converge has not run; leaving coding-hooks in place."
  exit 0
fi

# All gates passed — best-effort remove coding-hooks.
if [ -f "$CH_UNINSTALL" ]; then
  echo "auspex confirmed for all $found user(s) — removing legacy coding-hooks…"
  bash "$CH_UNINSTALL" || echo "coding-hooks uninstall returned non-zero (best-effort) — continuing."
  echo "Done: coding-hooks removal attempted."
else
  echo "No coding-hooks uninstaller found — nothing to remove."
fi
exit 0

Notes:

  • The script exits 0 on every path, so a coding-hooks removal failure never fails the MDM step or disturbs auspex.
  • It removes coding-hooks only when every gate passes and the uninstaller exists at /Library/Application Support/app.span.coding-hooks/uninstall.sh. On any other device it reports which gate it stopped at and exits cleanly.
  • It runs entirely as root and makes no network call. Root reads each 0600 per-user identity directly, so there is no launchctl asuser hop, no sudo -u, and no dependency on an active login session or a reachable control socket — the parts that behave differently from one MDM to the next.
  • What it does not prove: that the control plane accepts the token. The gates confirm auspex converged, is running, and has an identity — not that its traces are being accepted. A device whose org token is rejected keeps beating: the agent's liveness heartbeat is deliberately refreshed throughout a route-terminal auth hold, so an auth failure still looks alive locally. Confirm the pilot devices are actually landing traces in the dashboard before you assign this fleet-wide.
  • On a shared Mac every user with an auspex runspace must pass; the script leaves coding-hooks in place if any engineer on the device is still unprovisioned.
  • User-defined hooks in Cursor and Claude Code are preserved by the coding-hooks uninstaller.

Troubleshooting

Symptom Remediation
Config holds a placeholder token Re-run Step 3; confirm the real token replaced <YOUR_TOKEN>
Missing / wrong work email Confirm the lookup value / custom attribute resolves for the assigned user
Managed, but capture unauthenticated Package converged before identity existed — confirm auspex auth show, then re-run sudo auspex install --system
User prompted for Documents access Step 2 profile wasn't in place first — deploy it, then sudo tccutil reset SystemPolicyAllFiles
No data in dashboard Check identity, have the user run a coding tool, confirm the feature is enabled for your org

Upgrading

Add the new package version to the existing Internal app (or a new app version) and re-publish; assigned devices reinstall on next sample. managed.yaml and edited policy are preserved.

Uninstalling

auspex ships two removers, one per tier. auspex uninstall removes a user-tier (self-installed) agent — its per-user service, binary, and hooks. auspex hooks uninstall --system un-wires the managed (machine-wide) capture hooks. Neither removes the machine-wide binary, launchd units, or root-owned managed config the package placed — a macOS .pkg has no built-in uninstaller and there is no auspex uninstall --system. So a clean managed removal runs both commands, then removes those machine-wide artifacts, then removes the MDM-delivered profiles.

Step 1 — Uninstall auspex

Run it elevated on the device, or deliver it through Workspace ONE (as a macOS Script (System context)). It un-wires the hooks first (while the binary still exists), then removes the daemon units, the managed config, the binary, and the package receipt:

#!/bin/bash
set -uo pipefail
BIN="/usr/local/bin/auspex"   # full path: root/MDM shells often omit /usr/local/bin from PATH

# 1a. Un-wire the MANAGED (machine-wide) capture hooks — the tier the MDM package wired.
"$BIN" hooks uninstall --system || true
# 1b. Clean up any lingering USER-tier install (e.g. a self-install from before the device
#     was enrolled). `auspex uninstall` ONLY touches the per-user tier — it does not remove
#     the managed tier, which is why 1a and the machine-wide removals below are still needed.
"$BIN" uninstall || true

# 2. Stop and remove the launchd units. Boot out the converge LaunchDaemon (system domain) AND
#    the per-user daemon LaunchAgent for every logged-in user (gui/<uid>), then remove the plists.
launchctl bootout system "/Library/LaunchDaemons/app.span.auspex.converge.plist" 2>/dev/null || true
for u in /Users/*; do uid=$(id -u "$(basename "$u")" 2>/dev/null) || continue
  launchctl bootout "gui/$uid" "/Library/LaunchAgents/app.span.auspex.plist" 2>/dev/null || true
done
rm -f "/Library/LaunchDaemons/app.span.auspex.converge.plist" "/Library/LaunchAgents/app.span.auspex.plist"

# 3. Remove the root-owned managed config tree (clear its tamper-proof ACL first).
chmod -N "/Library/Application Support/auspex" 2>/dev/null || true
rm -rf "/Library/Application Support/auspex"

# 4. Remove the binary and forget the package receipt.
rm -f "$BIN"
pkgutil --forget app.span.auspex 2>/dev/null || true

echo "auspex uninstalled"

On a self-installed (non-managed) Mac, auspex uninstall on its own is the complete uninstaller — see the self-install guide. On a managed device it only clears a user-tier remnant, so the managed steps above are still required.

Step 2 — Remove the MDM-delivered profiles and assignment

In Workspace ONE, remove the app assignment (or push the app's uninstall) and unassign the Step 2 profile, and remove any non-removable/tamper lock first. Do this so the MDM does not re-deliver auspex on the next check-in.

Verify the removal is complete

Run on the device — every line should print ok: (no receipt, binary, config, or daemon left):

pkgutil --pkg-info app.span.auspex >/dev/null 2>&1 && echo "STILL PRESENT: package receipt" || echo "ok: no package receipt"
command -v auspex >/dev/null 2>&1 && echo "STILL PRESENT: auspex on PATH ($(command -v auspex))" || echo "ok: auspex not on PATH"
[ -e /usr/local/bin/auspex ] && echo "STILL PRESENT: /usr/local/bin/auspex" || echo "ok: binary gone"
[ -e "/Library/Application Support/auspex" ] && echo "STILL PRESENT: managed config tree" || echo "ok: managed config gone"
launchctl print system/app.span.auspex.converge >/dev/null 2>&1 && echo "STILL LOADED: converge daemon" || echo "ok: converge daemon not loaded"
CU=$(stat -f%u /dev/console 2>/dev/null || echo 0); launchctl print "gui/$CU/app.span.auspex" >/dev/null 2>&1 && echo "STILL LOADED: per-user daemon (gui/$CU)" || echo "ok: per-user daemon not loaded"
ls /Library/LaunchDaemons/app.span.auspex.converge.plist /Library/LaunchAgents/app.span.auspex.plist >/dev/null 2>&1 && echo "STILL PRESENT: launchd units" || echo "ok: launchd units gone"

Fallback — only if the checks above still show remnants

A leftover usually means the tamper-proof ACL blocked removal, a launchd unit was still loaded, or an MDM profile re-pushed the managed config before Step 2 completed. First confirm Step 2 removed the MDM assignment (so nothing re-delivers), then force the removal:

#!/bin/bash
launchctl bootout system "/Library/LaunchDaemons/app.span.auspex.converge.plist" 2>/dev/null || true
# Boot out the per-user daemon agent for every user, best-effort.
for u in /Users/*; do uid=$(id -u "$(basename "$u")" 2>/dev/null) || continue
  launchctl bootout "gui/$uid" "/Library/LaunchAgents/app.span.auspex.plist" 2>/dev/null || true
done
rm -f "/Library/LaunchDaemons/app.span.auspex.converge.plist" "/Library/LaunchAgents/app.span.auspex.plist"
chmod -R -N "/Library/Application Support/auspex" 2>/dev/null || true
rm -rf "/Library/Application Support/auspex"
rm -f /usr/local/bin/auspex
pkgutil --forget app.span.auspex 2>/dev/null || true
echo "auspex force-removed — re-run the verify checks above"

User-defined hooks in Cursor and Claude Code are preserved throughout.


Support

For issues not covered here, contact the Span team with:

auspex status
auspex support        # redacted support bundle