auspex: IT Admin User Guide for Hexnode UEM (macOS)

Last updated: November 12, 2026

This guide is for IT administrators deploying the Span agent (auspex) to macOS devices via Hexnode UEM. For Windows PCs, see the Windows guide.


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 Hexnode does), it places the auspex binary at /usr/local/bin/auspex and, from v0.13.0, /usr/local/bin/auspex-capture, the small program every coding-tool hook runs, 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

  • A Span account with access to integration settings.

  • A Hexnode UEM portal with target macOS devices (macOS 13+, supervised via ADE/ABM) enrolled, with the Hexnode agent app installed and up to date (Hexnode's custom scripts require it).

  • Each device assigned to a Hexnode user whose email is the engineer's work email (a local Hexnode user, or one synced from Entra ID, Google Workspace, Okta or Active Directory). Hexnode's %email% wildcard resolves from that user.

  • The macOS package (download and upload it as an Enterprise App): https://auspex.span.app/releases/latest/darwin/auspex_darwin_universal.pkg — click here to download


Deployment order (important)

  1. Custom configuration (file access + background items) — first.

  2. Identity script (Execute Custom Script, with the %email% wildcard) that writes identity.json — before the package.

  3. The package (Enterprise App, pushed with a Required Apps policy).


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.

The token itself is on the Traces settings page: open the Trace collection card and click View token. It's shown there regardless of which tools are toggled on above, and it's the same span_… org-wide value every device uses. That page is limited to Span admins and users whose permission group can manage integrations — if it doesn't load for you, ask one of them to fetch the 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 Hexnode: Policies → New Policy → New Blank Policy, name it (for example auspex — file access), then macOS → Configurations → Deploy Custom Configuration → Configure.

  2. Choose File and upload auspex-background-items.mobileconfig.

  3. Policy Targets → + Add Devices (or Device Groups / Users / User Groups) — the same devices that will receive the package — and Save.

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

Upgrading to v0.13.0 or later? From v0.13.0, /usr/local/bin/auspex-capture does the file read, so it needs the same file access as auspex. If you deploy this profile, upload it again from the link above and deploy it before the new package. If you deploy your own profile instead, review it and give auspex-capture the same rules as auspex. Full Disk Access isn't required; see Upgrading auspex for the entry to add. Until a device has the updated profile, macOS may prompt for or block reads of edited files under Documents, Desktop or Downloads.

Upload the profile as-is, not rebuilt from Hexnode's native Security → Privacy Preferences policy: the file also carries the managed login-items (background items) payload, which Hexnode has no native policy for, and the notification settings.

Requires a supervised device from a trusted MDM (macOS 13+).


Step 3: Deliver Identity (Execute Custom Script, before the package)

The token is org-wide; the work email is per-device. Hexnode passes the device user's email into the script as an argument via its %email% wildcard, so one script serves the whole fleet. Hexnode runs custom scripts with root privileges.

Save this as auspex-identity.sh, replacing <YOUR_TOKEN> with the token from Step 1:

#!/bin/bash
set -euo pipefail

# REQUIRED: paste your org token from Step 1
TOKEN="<YOUR_TOKEN>"
# Passed by Hexnode: set the script's Arguments field to %email%
EMAIL="${1:-}"

if [ -z "$EMAIL" ] || [[ "$EMAIL" != *"@"* ]] || [[ "$EMAIL" == *"%"* ]]; then
  echo "ERROR: could not resolve work email (got '$EMAIL'). Assign the device to a Hexnode user with an email, and pass %email% as the argument." >&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"

To deploy:

  1. Upload the script to Content → My Files (so you can reuse it from actions and automations).

  2. Manage → Devices, select the target devices, then Actions → Execute Custom Script.

  3. Choose the script from the Hexnode repository, set the Binary path to /bin/bash (Hexnode may pre-fill /bin/sh), and enter %email% in Arguments.

  4. Run it, then check each device's Action History → Show Output for Success: identity.json written for ….

For devices that enroll later, add the same script to an automation: Automate → New Automation → macOS → Execute Custom Scripts, trigger On Device Enrollment, with the same binary path and %email% argument.

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.

Fail loud, not quiet. The email check is deliberate. auspex treats a malformed address as absent, so a device with an unresolved wildcard would install, capture, and upload attributed to nobody. Failing the script surfaces it in Hexnode's Action History instead. Confirm the resolved value on a pilot device before assigning to the fleet.


Step 4: Deploy the Package

  1. Apps → + Add Apps → Enterprise App, platform macOS, and upload the .pkg. Give it an App Name (for example auspex) and a Category. The .pkg is a Developer ID–signed, notarized distribution package, which is what Hexnode requires of enterprise packages.

  2. Policies → New Policy → New Blank Policy (or add to the Step 2 policy), then macOS → App Management → Required Apps → Configure → + Add and select the auspex app.

  3. If the app's installation settings offer a Success Criteria check, set it to File Exists with /usr/local/bin/auspex — the package installs no .app, so an "App Exists" bundle check won't match.

  4. Policy Targets: the same devices/groups as Steps 2–3, then Save.

Hexnode installs it silently, with no user interaction. On install, the package converges the device to managed mode and starts the daemon.


Step 5: Smoke Test (before fleet rollout)

Assign Steps 2–4 to 1–2 pilot devices first. Confirm the custom configuration, the identity script, and the app install all report success in each device's Action History (and Policies tab), then on-device:

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.


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 Hexnode: add it as a Scripts policy (Policies → New Policy → macOS → Configurations → Scripts), with binary path /bin/bash, assigned to the same devices, on a repeating trigger — Custom → Daily at root level, or Device startup. Don't run it once alongside the package: an install-time run would no-op; as a repeating script it waits until auspex is confirmed 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 and their agent running, with an identity provisioned either device-wide (the managed identity.json) or per-user by the converge. 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, running, provisioned 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"
MANAGED_ID="/Library/Application Support/auspex/identity.json"
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 have a live agent and a provisioned identity.
# A runspace is counted by its heartbeat (the daemon writes it whether or not the per-user converge
# provisioned an identity). Identity follows the daemon's own resolution order (managed > user): the
# root-owned managed identity, when present, serves every user on the device; otherwise the user's
# 0600 runspace identity (root reads it directly). A shared Mac must not lose coding-hooks while any
# engineer on it is still unprovisioned.
found=0
for hb in /Users/*/.auspex/run/heartbeat; do
  [ -f "$hb" ] || continue
  found=$((found + 1))
  user=$(echo "$hb" | cut -d/ -f3)

  # 3a — the per-user agent is alive.
  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 — resolve the identity the daemon actually uses.
  if [ -f "$MANAGED_ID" ]; then
    idf="$MANAGED_ID"
  else
    idf="/Users/$user/.auspex/run/identity.json"
  fi
  if [ ! -f "$idf" ]; then
    echo "$user: no identity (managed or user tier) — not provisioned; leaving coding-hooks in place."
    exit 0
  fi

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

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

  echo "$user: agent live, identity provisioned ($idf)."
done

if [ "$found" -eq 0 ]; then
  echo "no running auspex user runspace found — agent has not started for any user; 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>

Step 3 fails with could not resolve work email

Assign the device to a Hexnode user with an email, and confirm the script's Arguments field is exactly %email%

Step 3 fails with a syntax error

The binary path was left at /bin/sh — set it to /bin/bash

Package upload rejected

Upload the unmodified .pkg from the link above; Hexnode accepts only signed distribution packages, and a re-packaged copy loses that

Package never installs

Confirm the device is enrolled, has the Hexnode agent, and is in the Required Apps policy's Policy Targets; check Action History

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

In Apps, open the auspex Enterprise App → Edit, click Change next to the package, upload the new .pkg, and Save. Devices targeted by the Required Apps policy update automatically; managed.yaml and edited policy are preserved.

Upgrading to v0.13.0? Before you deploy it, give auspex-capture the same file access as auspex in whichever profile you use (see the note in Step 2). Going back to a release before v0.13.0? First run sudo /usr/local/bin/auspex hooks uninstall --system on v0.13.0 or later, then deploy the older package. Releases before v0.13.0 don't recognize the newer hook entries, so without this step every event is captured twice.

Uninstalling

A macOS .pkg has no built-in uninstaller, so removal is a script. auspex hooks uninstall --system un-wires the managed (machine-wide) capture hooks, but it doesn't remove the binary, launchd units, or root-owned managed config the package placed. So a clean managed removal runs that command, removes those machine-wide files and each user's auspex data, then removes the MDM-delivered profiles.

Step 1 — Uninstall auspex

Run it elevated on the device, or deliver it through Hexnode (Actions → Execute Custom Script, binary path /bin/bash; Hexnode runs it as root). It un-wires the hooks first (while the binary still exists), then removes the daemon units, the managed config, the binaries, the package receipt, and each user's auspex data. It exits non-zero and names anything it couldn't remove:

#!/bin/bash
# Removes a managed (.pkg) auspex install and every user's auspex data. Run as root, from an MDM script or
# `sudo`. The MDM guides on help.span.app copy this file verbatim, and the Release E2E workflow runs it.
set -uo pipefail
BIN="/usr/local/bin/auspex"   # full path: root/MDM shells often omit /usr/local/bin from PATH
CAPTURE="/usr/local/bin/auspex-capture" # the capture binary every hook runs, or its placeholder link

[ "$(id -u)" -eq 0 ] || { echo "uninstall-managed.sh: run as root (sudo)" >&2; exit 1; }

# 1. Un-wire the MANAGED (machine-wide) capture hooks — the tier the MDM package wired.
"$BIN" hooks uninstall --system || true

# 2. Stop and remove the launchd units. Boot out the converge LaunchDaemon (system domain) AND
#    each user's daemon agent by label, which also stops a leftover self-install agent of the same label.
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/app.span.auspex" 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. Its tamper-proof ACL denies deletion even to root, so
#    clear it first.
chmod -R -N "/Library/Application Support/auspex" 2>/dev/null || true
rm -rf "/Library/Application Support/auspex"

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

# 5. Delete each user's auspex data: undelivered events, the local copy, logs and device id. A linked
#    .auspex is unlinked, not followed. Also remove what a self-install from before enrollment left.
for u in /Users/*; do
  rm -rf "$u/.auspex"
  rm -f "$u/Library/LaunchAgents/app.span.auspex.plist"
  if [ -L "$u/.local/bin/auspex" ]; then rm -f "$u/.local/bin/auspex"; fi
done

# Report failure, so an MDM shows it, if anything survived (for example an ACL that blocked a delete).
left=()
for path in "$BIN" "$CAPTURE" "/Library/Application Support/auspex" "/Library/LaunchAgents/app.span.auspex.plist" \
  "/Library/LaunchDaemons/app.span.auspex.converge.plist" /Users/*/.auspex; do
  if [ -e "$path" ] || [ -L "$path" ]; then left+=("$path"); fi
done
if [ "${#left[@]}" -gt 0 ]; then
  echo "uninstall-managed.sh: could not remove: ${left[*]}" >&2
  exit 1
fi
echo "auspex uninstalled"

On a self-installed (non-managed) Mac, use auspex uninstall instead — see the self-install guide.

Step 2 — Remove the MDM-delivered profiles and assignment

In Hexnode, remove the auspex app from the Required Apps policy (or remove the devices from its Policy Targets) and remove the Step 2 custom configuration the same way — archiving the policy works too. Do this before, or together with, Step 1, so Hexnode does not re-deliver auspex. Don't rely on Hexnode's Uninstall Application action alone — the package installs no .app bundle, so use the script above.

Verify the removal is complete

Run on the device — every line should print ok: (no receipt, binary, config, daemon, or per-user data 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 /usr/local/bin/auspex-capture ] || [ -L /usr/local/bin/auspex-capture ]; } && echo "STILL PRESENT: /usr/local/bin/auspex-capture" || echo "ok: capture 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"
ls -d /Users/*/.auspex >/dev/null 2>&1 && echo "STILL PRESENT: per-user auspex data ($(ls -d /Users/*/.auspex | tr '\n' ' '))" || echo "ok: no per-user auspex data"

Fallback — only if the checks above still show remnants

A leftover usually means an MDM profile re-pushed the managed config before Step 2 completed, or a delete was blocked. Confirm Step 2 removed the MDM assignment, so nothing re-delivers, then run the Step 1 script again. It clears the managed config's tamper-proof ACL itself, and names anything it still can't remove.

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