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:
- Claude Code hooks: installs the
agentkeeper-mcp-gatewaybinary (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); atype: commandSessionStart hook callsagentkeeper-mcp-gateway scan-inventoryto 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.jsonare inventoried automatically) - Cowork MCP gateway: re-uses the same gateway binary, runs
configure-ideto route Claude Desktop's MCP traffic through the gateway. Every MCP tool call the Cowork agent makes is evaluated against the sameclaude_code_policiesrow 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-gatewayGo binary. Step 3 (Cowork MCP gateway) reuses that same binary, no Node.js, nonpx. 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
- Log in to YOUR_AGENTKEEPER_URL
- Go to Settings → API Keys
- Click Create Key and name it "Kandji Fleet" or similar
- 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_HEREwith 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.jsonor 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-gatewaybinary 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_HEREwith 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-ideis 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
- In Kandji, go to Library and find both scripts
- Click each script → Blueprints tab
- 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 invokeinstall-gateway.shwith--force+AGENTKEEPER_SKIP_SETUP=1unconditionally, 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'sAGENTKEEPER_GATEWAY_VERSIONenv 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
| Issue | Cause | Fix |
|---|---|---|
| Script shows FAIL in Kandji | Script error | Check device logs in Kandji console → device → Activity |
| Script exits 0 but no config | No user logged in | Set to Run daily, catches the user on next check-in |
| Hooks not loading in Claude Code | File permissions or ownership | Verify: ls -la ~/.claude/settings.json, should be owner rw, mode 600 |
/usr/local/bin/agentkeeper-mcp-gateway missing | install-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 servers | scan-inventory can't read the target user's ~/.claude tree, or config JSON is invalid | As 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 logs | Invalid ~/.config/agentkeeper-mcp-gateway/config.json | python3 -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 terminal | Older gateway (≤ v0.5.0) blocks on a stdin read for the session envelope; the TTY never produces EOF | Upgrade to v0.5.1+, or work around by redirecting stdin: scan-inventory ... < /dev/null |
| Skills + MCP show up, but Plugins section is empty | Gateway 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 dashboard | Supabase 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 Cowork | Claude Desktop not restarted | Quit and reopen Claude Desktop after Step 3 runs |
| Events not in dashboard | Wrong API key | Verify key at YOUR_AGENTKEEPER_URL/settings, should start with ak_live_ |
| python3 not found | Xcode CLT not installed | Deploy 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_URLis 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
chownall files back to the logged-in user before exiting.