auspex: IT Admin User Guide for Hexnode UEM (Windows)
Last updated: November 12, 2026
This guide covers deploying the Span agent (auspex) to Windows devices with Hexnode UEM. For Macs, see the macOS guide.
What This Package Does
auspex captures AI coding activity (prompts, file edits, tool use) from supported IDEs — Claude Code, Cursor, Codex CLI, GitHub Copilot, and VS Code chat — and sends telemetry to Span's analytics backend. This data appears in your Span dashboard under AI Effectiveness.
On Windows the .msi installs machine-wide to C:\Program Files\auspex, registers a logon task so each engineer's agent starts when they sign in, and converges the device to the managed tier — capture is centrally governed and a user cannot install a competing copy over it.
It installs two programs in C:\Program Files\auspex\bin: auspex.exe, the agent and the auspex command, and auspexw.exe, the small program that the coding tools run on every event. It opens no console window and, from v0.13.0, contains only the capture code, which keeps each hook fast. Its name, path and signature stay the same across that change, so existing path and publisher rules keep working. Both are code-signed, like the .msi. If your fleet allowlists software by path, hash or publisher — AppLocker, WDAC, or your EDR or antivirus — allow both: a blocked auspexw.exe stops capture from every coding tool (Claude Code, Cursor, Codex, Copilot and VS Code). A hash rule needs updating for every release.
Prerequisites
A Span account with access to integration settings.
A Hexnode UEM portal with target Windows devices enrolled through the Hexnode Installer, so the Hexnode agent is present — Hexnode needs it for MSI installs (except on Windows 11) and for custom scripts.
Windows 10 (build 1809+) or Windows 11, Pro, Enterprise or Education edition. Hexnode does not support app deployment or custom scripts on Windows Home.
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) — it identifies the individual on their device. Hexnode's
%email%wildcard resolves from that user.The Windows packages (download the build matching your devices' architecture, then upload it):
amd64 — Intel/AMD, almost every PC:
https://auspex.span.app/releases/latest/windows/auspex_windows_amd64.msi— click here to downloadarm64 — Windows on Arm devices only:
https://auspex.span.app/releases/latest/windows/auspex_windows_arm64.msi— click here to download
Which architecture — amd64 or arm64?
Almost certainly amd64. Every Intel and AMD PC uses it, which is the overwhelming majority of corporate fleets. arm64 is needed only for Windows on Arm devices: Snapdragon-based Copilot+ PCs (Surface Pro 11, Surface Laptop 7, Dell XPS 13 9345, Lenovo ThinkPad T14s Gen 6 and similar), or Windows VMs on Apple Silicon Macs.
An amd64 .msi will not install on an Arm device, so don't assume a single build covers the fleet.
Checking architecture across a fleet
Run this one-liner against your Windows devices with Manage → Devices → Actions → Others → Execute Custom Script (save it as a .ps1 first), then read each device's result in Action History → Show Output — or tick Store output in custom attribute to see it as a column in the device list:
"$env:COMPUTERNAME,$env:PROCESSOR_ARCHITECTURE"
AMD64 → the amd64 build. ARM64 → the arm64 build. If any Arm devices appear, add the arm64 .msi as a second Enterprise App and target it at a device group containing just those devices.
Deployment order (important)
Identity script that writes
identity.json— before or with the package.The package (Enterprise App, pushed with a Required Apps policy).
The package converges the device on install; if identity is already present, the agent starts reporting immediately rather than waiting for the next script cycle.
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. The token is shared org-wide and is required regardless of which IDEs you deploy for.
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: Deliver the Identity File
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 in the background with system privileges.
Save this as auspex-identity.ps1, replacing <YOUR_TOKEN> with the token from Step 1:
$ErrorActionPreference = 'Stop'
# REQUIRED: paste your org token from Step 1
$Token = '<YOUR_TOKEN>'
# Passed by Hexnode: set the script's Arguments field to %email%
$Email = [string]$args[0]
if ([string]::IsNullOrWhiteSpace($Email) -or $Email -notmatch '^[^@\s%]+@[^@\s%]+\.[^@\s%]+$') {
Write-Error "Could not resolve a valid work email (got '$Email')."
exit 1
}
$Dir = 'C:\ProgramData\auspex'
New-Item -ItemType Directory -Force $Dir | Out-Null
# UTF-8 WITHOUT a BOM. PowerShell's default encodings (UTF-16 from Out-File, or a BOM from
# Set-Content) would put stray bytes in the token, which auspex rejects as a malformed credential.
$json = '{{"token":"{0}","work_email":"{1}"}}' -f $Token, $Email
[IO.File]::WriteAllText((Join-Path $Dir 'identity.json'), $json, (New-Object System.Text.UTF8Encoding $false))
Write-Output "Success: identity.json written for $Email"
To deploy:
Upload the script to Content → My Files (so you can reuse it from actions, policies and automations).
Manage → Devices, select the target devices, then Actions → Others → Execute Custom Script.
Choose the script from the Hexnode repository and enter
%email%in Arguments. Keep the default timeout (15 minutes or more).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 → Windows → Execute Custom Scripts, trigger On Device Enrollment, with the same %email% argument.
C:\ProgramData\auspex is the managed configuration tree. The .msi applies a restrictive ACL to it (SYSTEM and Administrators full control, Users read-only), so the token is not writable by the signed-in engineer.
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 — with nothing in
statusto say why. 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 3: Deploy the Package
Apps → + Add Apps → Enterprise App, platform Windows, and upload the
.msi. Hexnode reads the app version and installer identifier from the package itself.Under Success Criteria, choose Install Path and enter
C:\Program Files\auspex\bin\auspex.exe.Policies → New Policy → New Blank Policy, then Windows → App Management → Required Apps → Configure, and add the auspex app.
Policy Targets: the same devices/groups as Step 2, then Save.
Hexnode always installs MSIs with /i … /quiet, so the install is silent, and Required Apps periodically checks the device and reinstalls auspex if it goes missing. No extra command-line arguments are needed.
Download the installer — pick the build matching the target devices' architecture (see Which architecture above):
amd64 — Intel/AMD, almost every PC:
https://auspex.span.app/releases/latest/windows/auspex_windows_amd64.msi— click here to downloadarm64 — Windows on Arm devices only:
https://auspex.span.app/releases/latest/windows/auspex_windows_arm64.msi— click here to download
The .msi is signed with a Windows code-signing certificate (Azure Trusted Signing), so policies requiring signed installers accept it.
On install it places the binary, writes the managed configuration marker, registers the machine-wide logon task, and converges the device to the managed tier.
Step 4: Smoke Test (before fleet rollout)
Assign Steps 2–3 to one or two pilot devices first. Confirm both report success in each device's Action History, then verify on-device from an elevated PowerShell:
& 'C:\Program Files\auspex\bin\auspex.exe' status
& 'C:\Program Files\auspex\bin\auspex.exe' auth show
schtasks /Query /TN "app.span.auspex"
Expect install mode: managed, daemon: ok, a token and work email both sourced from managed, and a registered logon task. Then have the pilot user sign in, use Claude Code or Cursor briefly, and confirm their traces reach the Span dashboard.
The engineer's own agent starts at sign-in. On a device where nobody has signed in since the install,
statusrun as an admin will show the managed tier in place but no per-user daemon yet — that is expected.
Optional: Replace legacy Span coding-hooks in the same rollout
If your fleet still runs Span's earlier coding-hooks agent, retire it once auspex is confirmed healthy on each device — running both captures every event twice. See Retiring coding-hooks for a check-then-uninstall script that only acts on devices where auspex has genuinely taken over. Add it as a Hexnode Scripts policy (Policies → New Policy → Windows → Configurations → Scripts) that repeats on User logon or Device startup, so it re-evaluates as the rollout lands.
Upgrading
Upload the new .msi to Hexnode (Apps → + Add Apps → Enterprise App; Hexnode keeps multiple versions of an MSI app), then select the new version in the Required Apps policy. Targeted devices install it on their next check-in. The managed configuration and identity.json are preserved across the upgrade, and each engineer's captured data and hook wiring are left intact.
Uninstalling
Remove the auspex app from the Required Apps policy first (otherwise Hexnode reinstalls it), then use Actions → Applications → Uninstall Application, or push msiexec /x with the auspex ProductCode through Execute Custom Script. The .msi deliberately leaves C:\ProgramData\auspex behind — the managed configuration and organization identity belong to you, not the installer, so a reinstall or version change never loses your fleet policy. Remove it explicitly when decommissioning, together with each user's auspex data: undelivered events, the local copy, logs and the device id. Run this script elevated, as Administrator or as SYSTEM from your MDM, after the uninstall finishes. It refuses to run while auspex is still installed. The uninstall stops each signed-in user's daemon and waits for it to exit, so the folders aren't in use:
# Removes what the auspex .msi leaves behind, and every user's auspex data; run elevated after msiexec /x.
# The MDM guides copy this file verbatim and Release E2E runs it, so keep it Windows PowerShell 5.1-compatible.
$ErrorActionPreference = 'Stop'
$principal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
[Console]::Error.WriteLine('uninstall-managed.ps1: run elevated (as Administrator or SYSTEM)')
exit 1
}
if (Test-Path -LiteralPath 'C:\Program Files\auspex\bin\auspex.exe') {
[Console]::Error.WriteLine('uninstall-managed.ps1: auspex is still installed; uninstall the .msi first')
exit 1
}
# The managed configuration and organization identity, which the .msi keeps across reinstalls and upgrades.
if (Test-Path -LiteralPath 'C:\ProgramData\auspex') {
Remove-Item -LiteralPath 'C:\ProgramData\auspex' -Recurse -Force
}
# Each user's .auspex is owner-only, so it's deleted with backup and restore rights, through handles that never follow
# a link and hold each folder until it's empty: the user can still change it while this runs.
Add-Type -TypeDefinition @'
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Runtime.InteropServices;
using Microsoft.Win32.SafeHandles;
public static class AuspexUserData {
const uint Delete = 0x10000, Synchronize = 0x100000, ListDirectory = 0x1, ReadAttributes = 0x80;
const uint ShareReadWrite = 0x3, OpenExisting = 3, BackupSemantics = 0x02000000, OpenReparsePoint = 0x00200000;
const uint AttrDirectory = 0x10, AttrReparsePoint = 0x400;
const int AttributeTagInfo = 9, FullDirectoryInfo = 14, FullDirectoryRestartInfo = 15, DispositionInfo = 4, DispositionInfoEx = 21;
const int FileNotFound = 2, PathNotFound = 3, NoMoreFiles = 18, DirNotEmpty = 145, NotAllAssigned = 1300;
[StructLayout(LayoutKind.Sequential, Pack = 4)]
struct TokenPrivileges { public uint Count; public long Luid; public uint Attributes; }
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
static extern SafeFileHandle CreateFileW(string name, uint access, uint share, IntPtr security, uint disposition, uint flags, IntPtr template);
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool GetFileInformationByHandleEx(SafeFileHandle file, int infoClass, IntPtr buffer, uint size);
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool SetFileInformationByHandle(SafeFileHandle file, int infoClass, ref uint info, uint size);
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool SetFileInformationByHandle(SafeFileHandle file, int infoClass, ref byte info, uint size);
[DllImport("kernel32.dll")]
static extern IntPtr GetCurrentProcess();
[DllImport("kernel32.dll")]
static extern bool CloseHandle(IntPtr handle);
[DllImport("advapi32.dll", SetLastError = true)]
static extern bool OpenProcessToken(IntPtr process, uint access, out IntPtr token);
[DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
static extern bool LookupPrivilegeValueW(string system, string name, out long luid);
[DllImport("advapi32.dll", SetLastError = true)]
static extern bool AdjustTokenPrivileges(IntPtr token, bool disableAll, ref TokenPrivileges state, uint length, IntPtr previous, IntPtr returned);
public static void Remove(string path) {
Enable("SeBackupPrivilege");
Enable("SeRestorePrivilege");
RemoveEntry(path);
}
static void Enable(string privilege) {
IntPtr token;
if (!OpenProcessToken(GetCurrentProcess(), 0x28, out token)) throw new Win32Exception(Marshal.GetLastWin32Error());
try {
long luid;
if (!LookupPrivilegeValueW(null, privilege, out luid)) throw new Win32Exception(Marshal.GetLastWin32Error());
TokenPrivileges state = new TokenPrivileges { Count = 1, Luid = luid, Attributes = 2 };
if (!AdjustTokenPrivileges(token, false, ref state, 0, IntPtr.Zero, IntPtr.Zero) || Marshal.GetLastWin32Error() == NotAllAssigned) {
throw new InvalidOperationException("this account doesn't hold " + privilege + "; run elevated, as Administrator or SYSTEM");
}
} finally {
CloseHandle(token);
}
}
// Backup semantics open a folder regardless of its permissions; OpenReparsePoint opens a link itself, not its
// target. No FILE_SHARE_DELETE, so nothing can rename or delete the entry while the handle is open.
static void RemoveEntry(string path) {
SafeFileHandle handle = CreateFileW(path, Delete | ListDirectory | ReadAttributes | Synchronize, ShareReadWrite,
IntPtr.Zero, OpenExisting, BackupSemantics | OpenReparsePoint, IntPtr.Zero);
if (handle.IsInvalid) {
int error = Marshal.GetLastWin32Error();
// The user can delete an entry after it was listed; one that's already gone needs nothing more.
if (error == FileNotFound || error == PathNotFound) return;
throw new Win32Exception(error, "couldn't open " + path);
}
using (handle) {
uint attributes = Attributes(handle, path);
bool folder = (attributes & AttrDirectory) != 0 && (attributes & AttrReparsePoint) == 0;
for (int attempt = 1; ; attempt++) {
if (folder) {
foreach (string name in Children(handle, path)) RemoveEntry(path + "\\" + name);
}
int error = MarkDeleted(handle);
if (error == 0) return;
// The user can still add an entry to a folder before it's deleted; empty it again.
if (!(folder && error == DirNotEmpty && attempt < 5)) throw new Win32Exception(error, "couldn't delete " + path);
}
}
}
static uint Attributes(SafeFileHandle handle, string path) {
IntPtr buffer = Marshal.AllocHGlobal(8);
try {
if (!GetFileInformationByHandleEx(handle, AttributeTagInfo, buffer, 8)) throw new Win32Exception(Marshal.GetLastWin32Error(), "couldn't read " + path);
return (uint)Marshal.ReadInt32(buffer);
} finally {
Marshal.FreeHGlobal(buffer);
}
}
// Children lists a folder through its open handle, so the listing is of the folder that was checked.
static List<string> Children(SafeFileHandle handle, string path) {
List<string> names = new List<string>();
const int size = 64 * 1024;
IntPtr buffer = Marshal.AllocHGlobal(size);
try {
// Restart lists from the top, so a retry sees entries added since; the plain class would continue an
// exhausted listing and return nothing.
int infoClass = FullDirectoryRestartInfo;
while (GetFileInformationByHandleEx(handle, infoClass, buffer, size)) {
infoClass = FullDirectoryInfo;
int offset = 0;
while (true) {
IntPtr entry = IntPtr.Add(buffer, offset);
int next = Marshal.ReadInt32(entry, 0);
int length = Marshal.ReadInt32(entry, 60);
string name = Marshal.PtrToStringUni(IntPtr.Add(entry, 68), length / 2);
if (name != "." && name != "..") names.Add(name);
if (next == 0) break;
offset += next;
}
}
int error = Marshal.GetLastWin32Error();
if (error != NoMoreFiles) throw new Win32Exception(error, "couldn't list " + path);
} finally {
Marshal.FreeHGlobal(buffer);
}
return names;
}
// POSIX semantics unlink the name at once, even while someone holds the entry open, so it can't keep its folder
// non-empty; the extended form also removes a read-only file. Older Windows lacks it.
static int MarkDeleted(SafeFileHandle handle) {
uint flags = 0x1 | 0x2 | 0x10;
if (SetFileInformationByHandle(handle, DispositionInfoEx, ref flags, 4)) return 0;
byte delete = 1;
if (SetFileInformationByHandle(handle, DispositionInfo, ref delete, 1)) return 0;
return Marshal.GetLastWin32Error();
}
}
'@
$link = [IO.FileAttributes]::ReparsePoint
foreach ($userDir in Get-ChildItem -LiteralPath 'C:\Users' -Directory -Force | Where-Object { -not ($_.Attributes -band $link) }) {
$data = Get-Item -LiteralPath (Join-Path $userDir.FullName '.auspex') -Force -ErrorAction SilentlyContinue
if ($data) { [AuspexUserData]::Remove($data.FullName) }
}
Write-Output 'auspex data removed'
Troubleshooting
install mode: userinstead ofmanaged— the managed configuration tree is missing. ConfirmC:\ProgramData\auspex\managed.yamlexists; if the.msiinstalled but the marker is absent, the install did not complete.token: not setafter the identity script ran — check the file's encoding. A UTF-16 or BOM-prefixedidentity.jsonis rejected; use theWriteAllTextform in Step 2 exactly as given.auspex auth shownames the problem when the file is present but unreadable.work_email: (unset)with a token present — the address failed validation (it must look likename@domain.tld). A truncated or malformed value is dropped rather than sent, so check what Hexnode's%email%wildcard actually resolved to on that device (the Step 2 script prints it on success, in Action History → Show Output).No traces for a specific engineer — confirm they have signed in since the install (their agent starts at sign-in), then check
auspex statusin their session showscapture_wiringpassing for all tools.Traces stop from every coding tool while the device still reports in — security software is most likely blocking or has quarantined
C:\Program Files\auspex\bin\auspexw.exe. Check your EDR or AppLocker logs, allow the file, and reinstall the.msiif it is missing.An Arm device shows the install as failed — you assigned the amd64
.msi. See Which architecture above.The identity script fails with Could not resolve a valid work email (got '') — the device isn't assigned to a Hexnode user with an email, or the Arguments field isn't exactly
%email%.The app or script never runs on a device — confirm it was enrolled through the Hexnode Installer (the Hexnode agent is required for MSI installs and custom scripts) and isn't running Windows Home.
MSI error 1618 — another installation was in progress; Required Apps retries on its next check. 1603 usually means a conflicting existing install — check
auspex statusand the device's Applications list.