Skip to main content

Overview

The Velatir endpoint detection agent scans managed devices for installed AI applications and reports findings to your Velatir dashboard under App Insights. It runs on Windows, macOS, and Linux, and is designed for scheduled execution via MDM platforms such as Microsoft Intune and Jamf Pro.

Prerequisites

  • A Velatir project API key (from Dashboard > Settings > API Keys)
  • An MDM platform (Microsoft Intune, Jamf Pro, or similar)
  • Python 3 on macOS/Linux devices (pre-installed on macOS and most Linux distributions)

Configuration

The agent reads its API key from a configuration file at a well-known path:
PlatformConfig File Path
WindowsC:\ProgramData\Velatir\agent-config.json
macOS / Linux/etc/velatir/agent-config.json
Config file format:
{
  "apiKey": "vltr_your_api_key_here"
}
Deploy the config file once with your API key, then deploy the agent script separately. This keeps the API key in one place and makes rotation straightforward.
The macOS/Linux script also accepts --api-key as a command-line argument, which takes precedence over the config file.

Microsoft Intune (Windows)

Deploy the agent using two Intune Remediation packages: one for the configuration file and one for the scan script.

Step 1: Deploy the Configuration File

  1. Go to Devices > Remediations > Create script package
  2. Name it “Velatir Agent - Configuration”
Detection script (Detect-VelatirConfig.ps1):
$ConfigPath = "C:\ProgramData\Velatir\agent-config.json"

if (-not (Test-Path $ConfigPath)) {
    Write-Output "Config file not found"
    exit 1
}

try {
    $config = Get-Content $ConfigPath -Raw | ConvertFrom-Json
    if ([string]::IsNullOrWhiteSpace($config.apiKey) -or $config.apiKey -eq "vltr_your_api_key_here") {
        Write-Output "API key not configured"
        exit 1
    }
    Write-Output "Config file valid"
    exit 0
}
catch {
    Write-Output "Invalid config file"
    exit 1
}
Remediation script (Remediate-VelatirConfig.ps1):
# Configuration - UPDATE THIS VALUE
$ApiKey = "vltr_your_api_key_here"

$ConfigDir = "C:\ProgramData\Velatir"
$ConfigPath = "$ConfigDir\agent-config.json"

if (-not (Test-Path $ConfigDir)) {
    New-Item -Path $ConfigDir -ItemType Directory -Force | Out-Null
}

$config = @{ apiKey = $ApiKey } | ConvertTo-Json
Set-Content -Path $ConfigPath -Value $config -Encoding UTF8

Write-Output "Configuration deployed"
  1. Configure the script package:
    • Run this script using the logged-on credentials: No
    • Enforce script signature check: No
    • Run script in 64-bit PowerShell: Yes
  2. Assign to your device groups
  3. Set the schedule (e.g., once per day)

Step 2: Deploy the Detection Script

  1. Go to Devices > Remediations > Create script package
  2. Name it “Velatir Agent - Endpoint Scan”
Detection script (Detect-VelatirScan.ps1):
$TimestampPath = "C:\ProgramData\Velatir\last-scan.txt"

if (-not (Test-Path $TimestampPath)) {
    Write-Output "No previous scan found"
    exit 1
}

try {
    $lastScan = [DateTime]::Parse((Get-Content $TimestampPath -Raw).Trim())
    $hoursSince = (Get-Date).Subtract($lastScan).TotalHours

    if ($hoursSince -lt 24) {
        Write-Output "Last scan $([math]::Round($hoursSince, 1))h ago"
        exit 0
    } else {
        Write-Output "Scan overdue ($([math]::Round($hoursSince, 1))h)"
        exit 1
    }
}
catch {
    Write-Output "Invalid timestamp file"
    exit 1
}
Remediation script (Remediate-VelatirScan.ps1):
#Requires -Version 5.1
$ErrorActionPreference = "Stop"
$AgentVersion = "1.0.0"
$ConfigPath = "C:\ProgramData\Velatir\agent-config.json"
$TimestampPath = "C:\ProgramData\Velatir\last-scan.txt"
$ApiBaseUrl = "https://api.velatir.com"

# Read configuration
if (-not (Test-Path $ConfigPath)) {
    Write-Error "Config file not found at $ConfigPath. Deploy the configuration first."
    exit 1
}

$config = Get-Content $ConfigPath -Raw | ConvertFrom-Json
$ApiKey = $config.apiKey

if ([string]::IsNullOrWhiteSpace($ApiKey)) {
    Write-Error "API key is empty in $ConfigPath"
    exit 1
}

function Get-MachineId {
    try {
        $cs = Get-CimInstance -ClassName Win32_ComputerSystemProduct
        return $cs.UUID
    }
    catch {
        $os = Get-CimInstance -ClassName Win32_OperatingSystem
        return "$env:COMPUTERNAME-$($os.InstallDate.ToString('yyyyMMdd'))"
    }
}

function Get-Manifest {
    $headers = @{
        "X-API-Key" = $ApiKey
        "Accept"    = "application/json"
    }
    return Invoke-RestMethod -Uri "$ApiBaseUrl/api/v1/application-manifest" `
        -Headers $headers -Method Get
}

function Test-ProcessRunning {
    param([string]$ProcessName)
    $proc = Get-Process -Name ($ProcessName -replace '\.exe$', '') -ErrorAction SilentlyContinue
    return ($null -ne $proc)
}

function Test-PathExists {
    param([string]$Path)
    $expandedPath = [Environment]::ExpandEnvironmentVariables($Path)
    return (Test-Path $expandedPath)
}

function Test-RegistryKey {
    param([string]$RegistryPath)
    return (Test-Path $RegistryPath)
}

function Test-DockerImage {
    param([string]$ImageName)
    try {
        $images = & docker ps --format "{{.Image}}" --no-trunc 2>$null
        if (-not $images) { return $false }
        foreach ($image in $images) {
            if ($image -like "$ImageName*") { return $true }
        }
        return $false
    }
    catch { return $false }
}

function Scan-Tool {
    param($Tool)
    $findings = @()
    $detections = $Tool.detections.windows
    if (-not $detections) { return $findings }

    foreach ($detection in $detections) {
        $found = $false
        switch ($detection.type) {
            "process"  { $found = Test-ProcessRunning -ProcessName $detection.value }
            "path"     { $found = Test-PathExists -Path $detection.value }
            "registry" { $found = Test-RegistryKey -RegistryPath $detection.value }
            "docker"   { $found = Test-DockerImage -ImageName $detection.value }
        }
        if ($found) {
            $findings += @{
                toolId          = $Tool.id
                toolCategory    = $Tool.category
                detectionSource = $detection.type
                detectedValue   = $detection.value
            }
        }
    }
    return $findings
}

function Submit-Findings {
    param($Findings, $MachineId, $Hostname)
    $body = @{
        machineId    = $MachineId
        hostname     = $Hostname
        osType       = "windows"
        osVersion    = [System.Environment]::OSVersion.VersionString
        agentVersion = $AgentVersion
        findings     = $Findings
    } | ConvertTo-Json -Depth 10

    $headers = @{
        "X-API-Key"    = $ApiKey
        "Content-Type" = "application/json"
        "Accept"       = "application/json"
    }
    Invoke-RestMethod -Uri "$ApiBaseUrl/api/v1/application-findings" `
        -Headers $headers -Method Post -Body $body | Out-Null
}

# --- Main ---
Write-Host "Velatir Endpoint Detection Agent v$AgentVersion (Windows)"
Write-Host "Downloading manifest..."

$manifest = Get-Manifest
$toolCount = $manifest.tools.Count
Write-Host "Manifest loaded: $toolCount tools to scan"

$allFindings = @()
foreach ($tool in $manifest.tools) {
    $allFindings += Scan-Tool -Tool $tool
}

$machineId = Get-MachineId
$hostname = $env:COMPUTERNAME

Write-Host "Scan complete: $($allFindings.Count) applications detected"
Write-Host "Submitting findings..."

Submit-Findings -Findings $allFindings -MachineId $machineId -Hostname $hostname

# Record scan timestamp
Set-Content -Path $TimestampPath -Value (Get-Date -Format "o") -Encoding UTF8
Write-Host "Done."
  1. Configure the script package:
    • Run this script using the logged-on credentials: No
    • Enforce script signature check: No
    • Run script in 64-bit PowerShell: Yes
  2. Assign to your device groups
  3. Set the schedule (e.g., once per day)
Why two separate Remediation packages?Separating the configuration and scan into two packages allows you to update the API key independently, and ensures the config file is in place before the scan runs. Intune Remediations cannot guarantee execution order within a single package.

Jamf Pro (macOS)

Step 1: Deploy the Configuration File

Create a Jamf Pro policy to deploy the configuration file.
  1. Go to Computers > Policies > New
  2. Name it “Velatir Agent - Configuration”
  3. Add a Scripts payload with the following script:
#!/bin/bash
# Configuration - UPDATE THIS VALUE
API_KEY="vltr_your_api_key_here"

CONFIG_DIR="/etc/velatir"
CONFIG_PATH="$CONFIG_DIR/agent-config.json"

mkdir -p "$CONFIG_DIR"
cat > "$CONFIG_PATH" << EOF
{
  "apiKey": "$API_KEY"
}
EOF

chmod 644 "$CONFIG_PATH"
echo "Configuration deployed to $CONFIG_PATH"
  1. Set Trigger to Enrollment Complete and Recurring Check-in
  2. Set Execution Frequency to Once per computer
  3. Scope to your target computers

Step 2: Deploy the Detection Script

  1. Go to Computers > Policies > New
  2. Name it “Velatir Agent - Endpoint Scan”
  3. Add a Scripts payload with the following script:
#!/usr/bin/env bash
# Velatir Endpoint Detection Agent for macOS/Linux
set -euo pipefail

AGENT_VERSION="1.0.0"
API_BASE_URL="https://api.velatir.com"
API_KEY=""
CONFIG_PATH="/etc/velatir/agent-config.json"

# Parse arguments
while [[ $# -gt 0 ]]; do
    case "$1" in
        --api-key)
            API_KEY="$2"
            shift 2
            ;;
        --api-base-url)
            API_BASE_URL="$2"
            shift 2
            ;;
        *)
            shift
            ;;
    esac
done

# Read from config file if no CLI argument provided
if [[ -z "$API_KEY" ]] && [[ -f "$CONFIG_PATH" ]]; then
    API_KEY=$(python3 -c "import json; print(json.load(open('$CONFIG_PATH')).get('apiKey', ''))" 2>/dev/null || true)
fi

if [[ -z "$API_KEY" ]]; then
    echo "Error: No API key provided." >&2
    echo "Either use --api-key <key> or create a config file at $CONFIG_PATH" >&2
    echo "Config format: {\"apiKey\": \"vltr_your_api_key_here\"}" >&2
    exit 1
fi

# Detect OS
detect_os() {
    case "$(uname -s)" in
        Darwin) echo "mac" ;;
        Linux)  echo "linux" ;;
        *)      echo "unknown" ;;
    esac
}

OS_TYPE="$(detect_os)"

# Get machine ID
get_machine_id() {
    if [[ "$OS_TYPE" == "mac" ]]; then
        ioreg -rd1 -c IOPlatformExpertDevice 2>/dev/null \
            | awk '/IOPlatformUUID/{gsub(/"/, "", $3); print $3}' \
            || echo "$(hostname)-$(date +%s)"
    else
        if [[ -f /etc/machine-id ]]; then
            cat /etc/machine-id
        elif [[ -f /var/lib/dbus/machine-id ]]; then
            cat /var/lib/dbus/machine-id
        else
            echo "$(hostname)-$(date +%s)"
        fi
    fi
}

get_os_version() {
    if [[ "$OS_TYPE" == "mac" ]]; then
        sw_vers -productVersion 2>/dev/null || echo "unknown"
    else
        if [[ -f /etc/os-release ]]; then
            . /etc/os-release
            echo "${PRETTY_NAME:-$ID $VERSION_ID}"
        else
            uname -r
        fi
    fi
}

MACHINE_ID="$(get_machine_id)"
HOSTNAME="$(hostname)"
OS_VERSION="$(get_os_version)"

echo "Velatir Endpoint Detection Agent v${AGENT_VERSION} (${OS_TYPE})"
echo "Downloading manifest..."

# Download manifest
MANIFEST=$(curl -sf \
    -H "X-API-Key: ${API_KEY}" \
    -H "Accept: application/json" \
    "${API_BASE_URL}/api/v1/application-manifest")

TOOL_COUNT=$(echo "$MANIFEST" | python3 -c \
    "import sys,json; print(len(json.load(sys.stdin).get('tools',[])))" 2>/dev/null || echo "?")
echo "Manifest loaded: ${TOOL_COUNT} tools to scan"

# Scan and build findings using Python
FINDINGS=$(python3 -c "
import json, subprocess, os

manifest = json.loads('''${MANIFEST}''')
os_type = '${OS_TYPE}'
findings = []

# Build list of real user home directories (important when running as root)
home_base = '/Users' if os_type == 'mac' else '/home'
user_homes = []
if os.path.isdir(home_base):
    for entry in os.listdir(home_base):
        home = os.path.join(home_base, entry)
        if os.path.isdir(home) and entry not in ('Shared', 'Guest'):
            user_homes.append(home)

def check_path(path):
    if path.startswith('~/') or path == '~':
        rel = path[2:] if path.startswith('~/') else ''
        for home in user_homes:
            if os.path.exists(os.path.join(home, rel)):
                return True
        # Fallback: also check current user's home
        expanded = os.path.expanduser(path)
        return os.path.exists(expanded)
    else:
        return os.path.exists(os.path.expandvars(path))

for tool in manifest.get('tools', []):
    detections = tool.get('detections', {}).get(os_type, [])
    if not detections:
        continue

    for detection in detections:
        det_type = detection.get('type', '')
        det_value = detection.get('value', '')
        found = False

        try:
            if det_type == 'process':
                result = subprocess.run(['pgrep', '-x', det_value],
                    capture_output=True, timeout=5)
                found = result.returncode == 0
            elif det_type == 'path':
                found = check_path(det_value)
            elif det_type == 'bundle' and os_type == 'mac':
                app_dirs = ['/Applications', '/System/Applications']
                for home in user_homes:
                    app_dirs.append(os.path.join(home, 'Applications'))
                for app_dir in app_dirs:
                    if not os.path.isdir(app_dir):
                        continue
                    for entry in os.listdir(app_dir):
                        plist = os.path.join(app_dir, entry, 'Contents', 'Info.plist')
                        if os.path.exists(plist):
                            try:
                                result = subprocess.run(
                                    ['/usr/libexec/PlistBuddy', '-c',
                                     'Print :CFBundleIdentifier', plist],
                                    capture_output=True, text=True, timeout=5)
                                if result.stdout.strip() == det_value:
                                    found = True
                                    break
                            except:
                                pass
                    if found:
                        break
            elif det_type == 'vscode_extension':
                ext_id = det_value.lower()
                # Check extensions directories on disk (works when running as root)
                ext_dirs = ['.vscode/extensions', '.vscode-insiders/extensions',
                            '.cursor/extensions', '.void/extensions']
                for home in user_homes + [os.path.expanduser('~')]:
                    for ext_dir in ext_dirs:
                        full_dir = os.path.join(home, ext_dir)
                        if not os.path.isdir(full_dir):
                            continue
                        for entry in os.listdir(full_dir):
                            if entry.lower().startswith(ext_id + '-') or entry.lower() == ext_id:
                                found = True
                                break
                        if found:
                            break
                    if found:
                        break
            elif det_type == 'docker':
                try:
                    result = subprocess.run(
                        ['docker', 'ps', '--format', '{{.Image}}', '--no-trunc'],
                        capture_output=True, text=True, timeout=10)
                    if result.returncode == 0:
                        for image in result.stdout.strip().split('\n'):
                            if image.startswith(det_value):
                                found = True
                                break
                except:
                    pass
        except Exception:
            pass

        if found:
            findings.append({
                'toolId': tool['id'],
                'toolCategory': tool.get('category'),
                'detectionSource': det_type,
                'detectedValue': det_value
            })

print(json.dumps(findings))
" 2>/dev/null)

FINDING_COUNT=$(echo "$FINDINGS" | python3 -c \
    "import sys,json; print(len(json.load(sys.stdin)))" 2>/dev/null || echo "0")
echo "Scan complete: ${FINDING_COUNT} applications detected"
echo "Submitting findings..."

# Build and submit payload
PAYLOAD=$(python3 -c "
import json
findings = json.loads('''${FINDINGS}''')
payload = {
    'machineId': '${MACHINE_ID}',
    'hostname': '${HOSTNAME}',
    'osType': '${OS_TYPE}',
    'osVersion': '''${OS_VERSION}''',
    'agentVersion': '${AGENT_VERSION}',
    'findings': findings
}
print(json.dumps(payload))
")

curl -sf -X POST \
    -H "X-API-Key: ${API_KEY}" \
    -H "Content-Type: application/json" \
    -H "Accept: application/json" \
    -d "${PAYLOAD}" \
    "${API_BASE_URL}/api/v1/application-findings" >/dev/null

echo "Done."
  1. Set Trigger to Recurring Check-in
  2. Set Execution Frequency to Once every day
  3. Scope to your target computers

Linux

The same macOS/Linux script works on Linux. Deploy it via your configuration management tool (Ansible, Puppet, Chef, etc.) or schedule it with cron: 1. Deploy the configuration file:
sudo mkdir -p /etc/velatir
echo '{"apiKey": "vltr_your_api_key_here"}' | sudo tee /etc/velatir/agent-config.json
sudo chmod 644 /etc/velatir/agent-config.json
2. Schedule the agent with cron:
# Save the script to /opt/velatir/detect.sh and make it executable
sudo crontab -e
# Add: run daily at 9 AM
0 9 * * * /opt/velatir/detect.sh >> /var/log/velatir-agent.log 2>&1

Verification

After deployment, verify the agent is working:

Windows

  1. Check the config file exists:
    Get-Content "C:\ProgramData\Velatir\agent-config.json"
    
  2. Check for a recent scan timestamp:
    Get-Content "C:\ProgramData\Velatir\last-scan.txt"
    

macOS

  1. Check the config file exists:
    cat /etc/velatir/agent-config.json
    

Dashboard

Go to App Insights in your Velatir dashboard to see detected applications across your fleet.

Troubleshooting

Python not found (macOS/Linux)

The agent requires Python 3. On macOS it is pre-installed. On Linux, install it via your package manager:
# Debian/Ubuntu
sudo apt-get install python3

# RHEL/CentOS
sudo yum install python3

Network or firewall issues

The agent needs outbound HTTPS access to api.velatir.com. Verify connectivity:
curl -sf https://api.velatir.com/healthz
Invoke-RestMethod -Uri "https://api.velatir.com/healthz"

Config file permissions

  • Windows: The config file in C:\ProgramData\Velatir\ is accessible to SYSTEM and administrators by default. Intune Remediations run as SYSTEM.
  • macOS/Linux: Ensure the config file is readable by the user running the script (chmod 644).

32-bit vs 64-bit context (Windows)

Ensure Run script in 64-bit PowerShell is enabled in your Intune Remediation settings. Running in 32-bit context may cause Get-CimInstance or registry checks to behave differently.