Docs

Kandji (Iru) Deployment Guide

Deploy Agent Keeper to your Mac fleet using Kandji. This covers Claude Code security hooks and Claude Cowork (Claude Desktop) MCP gateway policies, two custom scripts, one blueprint, full coverage with a single shared policy engine.

Overview

Agent Keeper deploys as two Kandji custom scripts:

  1. Claude Code hooks: installs the agentkeeper-mcp-gateway binary (kept current on every run), drops ~/.config/agentkeeper-mcp-gateway/config.json (user-owned, so the SessionStart hook can read it), and writes hooks to ~/.claude/settings.json. HTTP hooks cover PreToolUse, PostToolUse, and UserPromptSubmit (Runtime Shield evaluation); a type: command SessionStart hook calls agentkeeper-mcp-gateway scan-inventory to capture installed skills, MCP servers, AND plugins from disk, including connectors that plugins bundle (Gmail, Slack, Google Calendar, etc. registered via a plugin's .mcp.json are inventoried automatically)
  2. Cowork MCP gateway: re-uses the same gateway binary, runs configure-ide to route Claude Desktop's MCP traffic through the gateway. Every MCP tool call the Cowork agent makes is evaluated against the same claude_code_policies row from the dashboard, path reads/writes, tool blocks, PII/PHI detection, bash-command blocks, so Cowork inherits Claude Code's policy enforcement without a second product setup

Both scripts run as root via the Kandji agent, detect the logged-in user, and write config to that user's home directory. Deploy one or both depending on which Claude products your team uses.

Requirements:

  • Kandji (Iru) with custom script support
  • Macs with Claude Code and/or Claude Desktop installed
  • A Agent Keeper account (Team plan for fleet management)
  • An API key from the Agent Keeper dashboard

Node.js is NOT required for either step. Step 2 (Claude Code hooks) is pure bash + python3 + the agentkeeper-mcp-gateway Go binary. Step 3 (Cowork MCP gateway) reuses that same binary, no Node.js, no npx. Most customers start with Step 2 only and add Step 3 if their team uses Claude Desktop with Cowork.

Step 1: Get your API key

  1. Log in to YOUR_AGENTKEEPER_URL
  2. Go to Settings → API Keys
  3. Click Create Key and name it "Kandji Fleet" or similar
  4. Copy the key (starts with ak_live_)

This key is write-only, it can send events and check policies but cannot read org data or modify settings.

Step 2: Deploy Claude Code hooks

This script installs (and on subsequent runs, auto-upgrades) the agentkeeper-mcp-gateway binary and writes hooks into ~/.claude/settings.json. Agent Keeper then evaluates every tool call + prompt submission in real time, and at the start of each session captures the full agent inventory: installed skills, MCP servers, and plugins, including the connectors plugins register via their .mcp.json manifests.

In Kandji, go to Library → Custom Scripts → Add New. Paste the following script:

#!/bin/bash
set -euo pipefail

# ============================================================
# Agent Keeper, Install Claude Code security hooks
# Runs as root. Writes config for the logged-in user.
# ============================================================

AGENTKEEPER_API_KEY="ak_live_YOUR_API_KEY_HERE"
AGENTKEEPER_API_URL="https://YOUR_AGENTKEEPER_URL/api/v1"

# Resolve logged-in user (Kandji scripts run as root)
CURRENT_USER=$(/usr/bin/stat -f%Su /dev/console)
if [[ "$CURRENT_USER" == "loginwindow" ]] || [[ "$CURRENT_USER" == "_mbsetupuser" ]] || [[ "$CURRENT_USER" == "root" ]]; then
    echo "No regular user logged in (got: $CURRENT_USER), skipping"
    exit 0
fi

USER_HOME=$(/usr/bin/dscl . -read /Users/"$CURRENT_USER" NFSHomeDirectory | awk '{print $2}')
if [[ -z "$USER_HOME" ]] || [[ ! -d "$USER_HOME" ]]; then
    echo "Could not resolve home directory for $CURRENT_USER"
    exit 1
fi

CLAUDE_DIR="${USER_HOME}/.claude"
SETTINGS_FILE="${CLAUDE_DIR}/settings.json"
GATEWAY_BIN="/usr/local/bin/agentkeeper-mcp-gateway"
GATEWAY_USER_CONFIG_DIR="${USER_HOME}/.config/agentkeeper-mcp-gateway"
GATEWAY_USER_CONFIG="${GATEWAY_USER_CONFIG_DIR}/config.json"

mkdir -p "$CLAUDE_DIR"

# ------------------------------------------------------------------
# Install / upgrade the agentkeeper-mcp-gateway binary
# (signed, cosign-verified via install-gateway.sh).
#
# The SessionStart hook calls `agentkeeper-mcp-gateway scan-inventory`
# to capture installed skills, MCP servers, and plugins from disk,
# something an HTTP-type hook cannot do, so this install unblocks the
# agent-inventory dashboard at YOUR_AGENTKEEPER_URL/claude-code/agent-inventory.
#
# --force + AGENTKEEPER_SKIP_SETUP=1 makes this idempotent: every Kandji
# run pulls the latest release. Pair this script with a Kandji daily
# execution frequency and the whole fleet tracks the current version
# automatically, important for a security tool where new detection
# logic and new inventory coverage land in gateway releases.
# ------------------------------------------------------------------
echo "Installing / upgrading agentkeeper-mcp-gateway..."
/usr/bin/curl -fsSL https://YOUR_AGENTKEEPER_URL/install-gateway.sh | \
    AGENTKEEPER_SKIP_SETUP=1 bash -s -- --force

# Write the gateway config to the logged-in user's home directory.
# The SessionStart hook runs scan-inventory as that user (not root), so
# the config must be user-readable. Using ~/.config/ keeps the API key
# out of a world-readable /etc path and matches the gateway's native
# config-resolution order (XDG_CONFIG_HOME → $HOME/.config → /etc).
mkdir -p "$GATEWAY_USER_CONFIG_DIR"
python3 -c "
import json
with open('${GATEWAY_USER_CONFIG}', 'w') as f:
    json.dump({'api_key': '${AGENTKEEPER_API_KEY}', 'api_url': 'https://YOUR_AGENTKEEPER_URL'}, f)
"
chmod 600 "$GATEWAY_USER_CONFIG"

# Backup existing settings
if [ -f "$SETTINGS_FILE" ]; then
    cp "$SETTINGS_FILE" "${SETTINGS_FILE}.backup.$(date +%Y%m%d%H%M%S)"
fi

# Merge hooks into existing settings (preserves user customizations)
python3 -c "
import json, os

settings_path = '${SETTINGS_FILE}'
existing = {}
if os.path.exists(settings_path):
    try:
        with open(settings_path) as f:
            existing = json.load(f)
    except (json.JSONDecodeError, IOError):
        existing = {}

api_key = '${AGENTKEEPER_API_KEY}'
api_url = '${AGENTKEEPER_API_URL}'

hooks = {
    'UserPromptSubmit': [{
        'matcher': '*',
        'hooks': [{
            'type': 'http',
            'url': f'{api_url}/claude-code/evaluate',
            'headers': {'Authorization': f'Bearer {api_key}'},
            'timeout': 10
        }]
    }],
    'PreToolUse': [{
        'matcher': 'Bash|Edit|Write|Read|Glob|Grep|WebFetch|WebSearch',
        'hooks': [{
            'type': 'http',
            'url': f'{api_url}/claude-code/evaluate',
            'headers': {'Authorization': f'Bearer {api_key}'},
            'timeout': 10
        }]
    }],
    'PostToolUse': [{
        'matcher': 'Bash|Edit|Write|Read|Glob|Grep|WebFetch|WebSearch',
        'hooks': [{
            'type': 'http',
            'url': f'{api_url}/claude-code/audit',
            'headers': {'Authorization': f'Bearer {api_key}'},
            'timeout': 10
        }]
    }],
    # SessionStart uses type:command so the gateway binary can scan the
    # filesystem for installed skills + MCP servers, HTTP hooks only
    # receive Claude Code's event envelope with no filesystem access.
    'SessionStart': [{
        'matcher': '*',
        'hooks': [{
            'type': 'command',
            'command': '/usr/local/bin/agentkeeper-mcp-gateway scan-inventory',
            'timeout': 30
        }]
    }]
}

existing['hooks'] = hooks

with open(settings_path, 'w') as f:
    json.dump(existing, f, indent=2)
print('Hooks written successfully')
"

# Set ownership and permissions (600 because files contain the API key)
chown -R "$CURRENT_USER:staff" "$CLAUDE_DIR"
chown -R "$CURRENT_USER:staff" "$GATEWAY_USER_CONFIG_DIR"
chmod 600 "$SETTINGS_FILE"
chmod 600 "$GATEWAY_USER_CONFIG"

echo "Agent Keeper hooks + scan-inventory installed for $CURRENT_USER"
exit 0

Important: Replace ak_live_YOUR_API_KEY_HERE with your actual API key from Step 1.

Script settings:

  • Name: Agent Keeper, Claude Code Hooks
  • Execution frequency: Run daily (recommended), the script is idempotent, auto-upgrades the gateway binary to the latest release on each run, and self-heals any drift on ~/.claude/settings.json or the gateway config file. Run once is acceptable only for pinned-version deployments
  • Show in Self Service: No

Step 3: Deploy Cowork MCP gateway (optional, policy parity with Claude Code)

Skip this step if you only use Claude Code. This step is for teams that also use Claude Desktop with Cowork. Reuses the agentkeeper-mcp-gateway binary from Step 2, no Node.js, no separate MCP server to install.

This script points Claude Desktop at the agentkeeper-mcp-gateway binary as a single MCP proxy entry. Every MCP tool call the Cowork agent makes, reading Google Drive files, writing Gmail drafts, running filesystem operations, invoking third-party MCP servers, is proxied through the gateway, evaluated against the same claude_code_policies row you already configured in the dashboard, and blocked / warned / allowed before reaching the upstream MCP server. Path read/write policies, blocked tools, blocked MCP skills, PII/PHI detection, Cowork inherits all of it. See MCP Gateway policies for the enforcement model.

In Kandji, go to Library → Custom Scripts → Add New. Paste the following script:

#!/bin/bash
set -euo pipefail

# ============================================================
# Agent Keeper, Wire Claude Desktop through the MCP gateway
# Runs as root. Reuses the gateway binary from Step 2; falls
# back to installing it if this is a Cowork-only deployment.
# ============================================================

AGENTKEEPER_API_KEY="ak_live_YOUR_API_KEY_HERE"
GATEWAY_BIN="/usr/local/bin/agentkeeper-mcp-gateway"

# Resolve logged-in user (Kandji scripts run as root)
CURRENT_USER=$(/usr/bin/stat -f%Su /dev/console)
if [[ "$CURRENT_USER" == "loginwindow" ]] || [[ "$CURRENT_USER" == "_mbsetupuser" ]] || [[ "$CURRENT_USER" == "root" ]]; then
    echo "No regular user logged in (got: $CURRENT_USER), skipping"
    exit 0
fi

USER_HOME=$(/usr/bin/dscl . -read /Users/"$CURRENT_USER" NFSHomeDirectory | awk '{print $2}')
if [[ -z "$USER_HOME" ]] || [[ ! -d "$USER_HOME" ]]; then
    echo "Could not resolve home directory for $CURRENT_USER"
    exit 1
fi

GATEWAY_USER_CONFIG_DIR="${USER_HOME}/.config/agentkeeper-mcp-gateway"
GATEWAY_USER_CONFIG="${GATEWAY_USER_CONFIG_DIR}/config.json"

# Install / upgrade the gateway binary. Idempotent, safe to run daily.
# If Step 2 already ran today, this is a no-op rebuild of the same version.
echo "Installing / upgrading agentkeeper-mcp-gateway..."
/usr/bin/curl -fsSL https://YOUR_AGENTKEEPER_URL/install-gateway.sh | \
    AGENTKEEPER_SKIP_SETUP=1 bash -s -- --force

# Ensure the user-local config exists (Step 2 writes this; create if Cowork-only).
if [ ! -f "$GATEWAY_USER_CONFIG" ]; then
    mkdir -p "$GATEWAY_USER_CONFIG_DIR"
    python3 -c "
import json
with open('${GATEWAY_USER_CONFIG}', 'w') as f:
    json.dump({'api_key': '${AGENTKEEPER_API_KEY}', 'api_url': 'https://YOUR_AGENTKEEPER_URL'}, f)
"
    chown -R "$CURRENT_USER:staff" "$GATEWAY_USER_CONFIG_DIR"
    chmod 600 "$GATEWAY_USER_CONFIG"
fi

# Invoke configure-ide as the logged-in user. This rewrites
# ~/Library/Application Support/Claude/claude_desktop_config.json so
# Claude Desktop routes ALL its MCP traffic through the gateway. Any
# MCP servers Cowork was already using (Google Drive, Gmail, custom
# MCPs) are migrated into the gateway's own servers[] list and routed
# through its policy engine transparently. Idempotent, safe to run
# daily for drift remediation.
/usr/bin/sudo -u "$CURRENT_USER" "$GATEWAY_BIN" configure-ide

echo "Agent Keeper Cowork MCP gateway wired for $CURRENT_USER"
exit 0

Important: Replace ak_live_YOUR_API_KEY_HERE with the same API key from Step 1.

Script settings:

  • Name: Agent Keeper, Cowork MCP Gateway
  • Execution frequency: Kandji's Custom Script picker offers Run once per device / Run every 15 minutes / Run daily / Run on-demand from Self Service. Pick Run daily. configure-ide is idempotent, so daily re-runs are free and self-heal any user or MDM-induced drift. Pick Run every 15 minutes instead only if your fleet sees frequent config tampering and you want fast remediation.
  • Show in Self Service: No

Step 4: Assign to a Blueprint

  1. In Kandji, go to Library and find both scripts
  2. Click each script → Blueprints tab
  3. Assign to the Blueprint containing your target Macs

Scripts execute on the next Kandji agent check-in (typically within 15 minutes).

Step 5: Verify deployment

After the scripts run, verify on a test machine:

Claude Code:

# 1. settings.json exists with correct permissions
ls -la ~/.claude/settings.json
# Expected: -rw-------  youruser  staff

# 2. Gateway binary installed and executable
ls -la /usr/local/bin/agentkeeper-mcp-gateway
# Expected: -rwxr-xr-x  root  wheel  (any non-zero size)

# 3. Gateway config is valid JSON with the correct api_url
python3 -c "
import json, os
c = json.load(open(os.path.expanduser('~/.config/agentkeeper-mcp-gateway/config.json')))
assert c.get('api_url','').startswith('https://YOUR_AGENTKEEPER_URL'), 'bad api_url: %r' % c.get('api_url')
assert c.get('api_key','').startswith('ak_live_'), 'bad api_key prefix'
print('config.json: OK')
"
# Validates user-readable config path (no sudo needed, that's the point).

# 4. Hooks are present
python3 -c "
import json
s = json.load(open('$HOME/.claude/settings.json'))
print('Hooks:', list(s.get('hooks',{}).keys()))
"
# Expected: Hooks: ['UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'SessionStart']

# 5. scan-inventory runs end-to-end and returns a non-empty payload.
#    The `< /dev/null` is required when running manually from a terminal:
#    scan-inventory reads its session envelope from stdin, and an interactive
#    TTY never EOFs, so without the redirect the command would hang.
/usr/local/bin/agentkeeper-mcp-gateway scan-inventory --dry-run < /dev/null \
  | python3 -c "import json,sys;d=json.load(sys.stdin);print('skills:',len(d.get('installed_skills',[])),'mcp:',len(d.get('installed_mcp_servers',[])),'plugins:',len(d.get('installed_plugins',[])))"
# Expected: non-zero counts on any developer workstation with skills/MCP/plugins installed.
# If all three are 0 on a known-configured Mac, the scan is misfiring, see Troubleshooting.
# If plugins: 0 but the machine has Claude Code plugins installed, verify the gateway
# is on v0.6.0 or later, older releases don't populate `installed_plugins`.

In Claude Code, run /hooks, you should see 3 Agent Keeper HTTP hooks (UserPromptSubmit, PreToolUse, PostToolUse) plus a type: command SessionStart hook calling agentkeeper-mcp-gateway scan-inventory.

Cowork:

# Check Claude Desktop config points at the gateway binary
python3 -c "
import json
c = json.load(open('$HOME/Library/Application Support/Claude/claude_desktop_config.json'))
for name, cfg in c.get('mcpServers', {}).items():
    print(name, '->', cfg.get('command', ''), ' '.join(cfg.get('args', [])))
"
# Expected: one entry whose command is /usr/local/bin/agentkeeper-mcp-gateway

# Spot-check the policy round-trip:
/usr/local/bin/agentkeeper-mcp-gateway scan-inventory --dry-run < /dev/null | python3 -c "import json,sys;d=json.load(sys.stdin);print('skills:',len(d['installed_skills']),'mcp:',len(d['installed_mcp_servers']),'plugins:',len(d.get('installed_plugins',[])))"

Restart Claude Desktop. All MCP tool calls Cowork makes now proxy through the gateway and are evaluated server-side against your dashboard policies. Blocked calls return a deny verdict that Cowork surfaces in its conversation; warned calls proceed with an advisory flag. Events show up in the MCP Gateway dashboard.

Dashboard: Open YOUR_AGENTKEEPER_URL/claude-code/agent-inventory, workstations appear as users start sessions, along with the skills, MCP servers, and plugins installed on each.

Drift remediation (optional)

To ensure configs aren't removed by users, use Kandji's audit/remediation model. Set execution frequency to Run daily, or create a separate audit script:

#!/bin/bash
# Audit: exits 0 if configs are correct, 1 if missing (triggers remediation)

CURRENT_USER=$(/usr/bin/stat -f%Su /dev/console)
if [[ "$CURRENT_USER" == "loginwindow" ]] || [[ "$CURRENT_USER" == "_mbsetupuser" ]] || [[ "$CURRENT_USER" == "root" ]]; then
    exit 0  # No user = don't trigger remediation
fi

USER_HOME=$(/usr/bin/dscl . -read /Users/"$CURRENT_USER" NFSHomeDirectory | awk '{print $2}')
if [[ -z "$USER_HOME" ]] || [[ ! -d "$USER_HOME" ]]; then
    exit 0
fi

FAILED=0

# Check Claude Code hooks
if ! python3 -c "
import json
s = json.load(open('${USER_HOME}/.claude/settings.json'))
assert 'hooks' in s
assert 'PreToolUse' in s['hooks']
assert 'YOUR_AGENTKEEPER_URL' in json.dumps(s['hooks'])
" 2>/dev/null; then
    echo "FAIL: Claude Code hooks missing"
    FAILED=1
fi

# Check Cowork MCP gateway (configure-ide's signature: a single
# mcpServers entry whose command is agentkeeper-mcp-gateway + "server")
if ! python3 -c "
import json
c = json.load(open('${USER_HOME}/Library/Application Support/Claude/claude_desktop_config.json'))
servers = c.get('mcpServers', {})
found = any(
    isinstance(cfg, dict) and 'agentkeeper-mcp-gateway' in (cfg.get('command') or '')
    for cfg in servers.values()
)
assert found, 'agentkeeper-mcp-gateway not wired into mcpServers'
" 2>/dev/null; then
    echo "FAIL: Cowork MCP gateway not wired"
    FAILED=1
fi

if [[ $FAILED -eq 1 ]]; then
    exit 1  # Triggers remediation
fi

echo "Agent Keeper configs verified"
exit 0

Use the install scripts from Steps 2-3 as the remediation scripts.

Shared Macs

Scripts detect the console user at execution time. On shared Macs with multiple accounts:

  • Only the user logged in when the Kandji agent runs gets the config
  • Set execution frequency to Run daily so each user gets the config on their next login
  • Other users get configured on their next session

Updating

  • Evaluation logic: No update needed. HTTP hooks call YOUR_AGENTKEEPER_URL/api/v1, which always serves the latest detection + policy logic server-side.
  • Gateway binary (agentkeeper-mcp-gateway): Auto-upgrades on every Kandji run. The Step 2 and Step 3 scripts invoke install-gateway.sh with --force + AGENTKEEPER_SKIP_SETUP=1 unconditionally, so pairing this with a Kandji Run daily frequency keeps the fleet on the current release. New detection logic, new inventory coverage, and security fixes ship in releases, an out-of-date binary means missed visibility. The install path is cosign-verified against a signed checksums file, so auto-upgrade is safe. Pin a specific version in-house by mirroring the tarball and pointing the install script's AGENTKEEPER_GATEWAY_VERSION env var at it (see MCP Gateway enterprise install).
  • Config file (~/.config/agentkeeper-mcp-gateway/config.json): Re-run the Kandji script after rotating the API key. The file is rewritten idempotently with the current value each run.

Troubleshooting

IssueCauseFix
Script shows FAIL in KandjiScript errorCheck device logs in Kandji console → device → Activity
Script exits 0 but no configNo user logged inSet to Run daily, catches the user on next check-in
Hooks not loading in Claude CodeFile permissions or ownershipVerify: ls -la ~/.claude/settings.json, should be owner rw, mode 600
/usr/local/bin/agentkeeper-mcp-gateway missinginstall-gateway.sh failed (network, TLS interception, cosign verification)Re-run /usr/bin/curl -fsSL https://YOUR_AGENTKEEPER_URL/install-gateway.sh | bash manually on the device and read the output. Corporate proxies that strip code-signing headers will fail cosign verification
SessionStart hook runs but dashboard shows 0 skills / 0 MCP serversscan-inventory can't read the target user's ~/.claude tree, or config JSON is invalidAs the logged-in user: /usr/local/bin/agentkeeper-mcp-gateway scan-inventory --dry-run < /dev/null. The stdin redirect is required, scan-inventory reads its session envelope from stdin and a terminal TTY never EOFs. If the command errors on config parse, compare ~/.config/agentkeeper-mcp-gateway/config.json against the expected shape
scan-inventory exits non-zero in session logsInvalid ~/.config/agentkeeper-mcp-gateway/config.jsonpython3 -m json.tool < ~/.config/agentkeeper-mcp-gateway/config.json to validate. Re-run the Kandji script to rewrite the file
scan-inventory hangs when run manually from a terminalOlder gateway (≤ v0.5.0) blocks on a stdin read for the session envelope; the TTY never produces EOFUpgrade to v0.5.1+, or work around by redirecting stdin: scan-inventory ... < /dev/null
Skills + MCP show up, but Plugins section is emptyGateway is older than v0.6.0 (no installed_plugins payload)Verify with /usr/local/bin/agentkeeper-mcp-gateway version. If < v0.6.0, re-run the Kandji script, the install step now runs --force on every execution and will pull the latest release. Scan output should show plugins: N with N > 0 on any Mac with installed plugins
Plugin counts correct in scan output but absent from dashboardSupabase migration host_plugin_inventory not applied yet (brief window right after a deploy)Wait 2-3 minutes and retry. The checkin endpoint is fail-open: skill + MCP rows persist correctly regardless; only plugin rows are affected until the table exists
MCP gateway not wired into CoworkClaude Desktop not restartedQuit and reopen Claude Desktop after Step 3 runs
Events not in dashboardWrong API keyVerify key at YOUR_AGENTKEEPER_URL/settings, should start with ak_live_
python3 not foundXcode CLT not installedDeploy Xcode Command Line Tools via Kandji

Security considerations

  • The API key is write-only. It can send events and fetch policies, but cannot read data or modify settings.
  • Config files are set to mode 600 (owner read/write only) because they contain the API key.
  • No file contents are sent to Agent Keeper, only file paths, hostnames, tool names, and detection metadata.
  • Hook timeout is 10 seconds. If YOUR_AGENTKEEPER_URL is unreachable, Claude Code continues normally (fail-open).
  • Cowork MCP tools run locally in under 10ms. Dashboard reporting is async and non-blocking.
  • Scripts run as root but chown all files back to the logged-in user before exiting.