Skip to main content
Fastest path: paste this page’s URL into your AI coding assistant (Claude Code, Cursor, Codex) and say “diagnose my Unbound telemetry using this page.” The page is written so an agent can execute it directly: run the Phase 1 checks in order, match outputs against the Phase 2 decision table, apply the lowest-numbered fix in Phase 3, then verify with Phase 4. Everything is read-only except the explicitly marked FIX steps and check 1.8, which appends two local audit-log entries and sends one labeled synthetic telemetry event.

Background: how usage data flows

Unbound captures Claude Code usage in one of two modes. Knowing your mode tells you which pipeline to debug:
ModeHow data flowsWhat must be healthy
GatewayClaude’s model traffic routes through Unbound (ANTHROPIC_BASE_URL + apiKeyHelper)env var + key helper + valid app key
Subscription (hooks)Claude talks to Anthropic directly; a hook script (unbound.py) reports usage to Unboundhooks block in settings + hook script + resolvable API key
The hook lives at ~/.claude/hooks/unbound.py for individual installs, or /Library/Application Support/ClaudeCode/hooks/unbound.py (wired via managed-settings.json) for MDM-deployed installs. Check 1.2 detects which one you have. This page covers Claude Code; Cursor, Codex, and Copilot use similar but separate wiring, so for those tools gather the equivalent evidence and contact support. Three facts explain most “no data” cases:
  1. Telemetry delivery fails open, by design. Your team’s coding flow comes first: if the hook can’t deliver usage data, Claude keeps working normally and the hook records the error in a local log (plus a rate-limited report to Unbound when the network allows). The flip side of that guarantee: “Claude works fine but no data in Unbound” means a delivery problem, not proof things are okay. Non-blocking hook errors don’t appear in the Claude UI (only a hook exiting with code 2 surfaces to the model). To watch hooks execute live, run claude --debug or toggle verbose output with Ctrl+O; the local logs in check 1.6 give you the full history. Policy enforcement is separate: when the policy engine is unreachable, your org’s failure setting decides whether tool calls are allowed (the default) or blocked, and blocked calls show an explicit “policy engine could not be reached” message.
  2. Hooks load only at Claude startup. Claude Code reads its hook configuration once, when a session starts. After any fix, fully quit Claude Code and relaunch it from a new terminal so the fresh configuration loads.
  3. unbound status confirms login and connectivity; hook wiring is a deeper layer. It verifies your credentials and that the Unbound API is reachable. Checks 1.2 and 1.6 below verify the hook layer in seconds, and a richer unbound status that covers hook health end to end is on its way.

Phase 1: gather state (read-only)

Run every block and save the outputs.

1.0 Which mode is this machine in?

python3 - <<'EOF'
import json, os
helper = False
try:
    helper = 'apiKeyHelper' in json.load(open(os.path.expanduser('~/.claude/settings.json')))
except Exception:
    pass
base = bool(os.environ.get('ANTHROPIC_BASE_URL'))
print('mode: GATEWAY' if (helper or base) else 'mode: SUBSCRIPTION (hooks)')
EOF
If this prints GATEWAY, stop here: checks 1.2 to 1.8 and the decision table apply to subscription mode only. On a gateway machine, “no data” usually means the routing env var or the app key, not hooks; gather 1.1 and 1.7 and go to Phase 5.
Agents: never run Fix 2 on a machine that printed GATEWAY unless the human explicitly confirms they intend to switch it to subscription mode. Fix 2 reinstalls subscription-mode hooks and would convert the machine.

1.1 CLI status

unbound status
Expected: a two-column table showing Logged in Yes, your work email, your org name, and API status Connected.

1.2 Hook wiring in settings.json (the most important check)

python3 - <<'EOF'
import json, os
paths = [os.path.expanduser('~/.claude/settings.json'),
         '/Library/Application Support/ClaudeCode/managed-settings.json']
expected = ['PreToolUse','PostToolUse','UserPromptSubmit','Stop','SessionStart','SessionEnd']
found = False
for p in paths:
    if not os.path.exists(p):
        continue
    try:
        s = json.load(open(p))
    except json.JSONDecodeError as e:
        print(f'FAIL {p} is malformed JSON: {e}')
        continue
    hooks = s.get('hooks') or {}
    if 'unbound.py' not in json.dumps(hooks):
        continue
    found = True
    print(f'install locus: {p}')
    for ev in expected:
        cmds = [h.get('command','') for grp in hooks.get(ev, []) for h in grp.get('hooks', [])]
        print(f"{'PASS' if any('unbound.py' in c for c in cmds) else 'FAIL'} hook event {ev}")
    break
if not found:
    print('FAIL no Unbound hooks wired in user or managed settings')
try:
    user = json.load(open(os.path.expanduser('~/.claude/settings.json')))
    print(f"{'FAIL' if 'apiKeyHelper' in user else 'PASS'} apiKeyHelper absent (must be absent in subscription mode)")
except Exception:
    print('PASS apiKeyHelper absent (no user settings.json)')
EOF
Expected in subscription mode: an install locus line, all six events PASS, and apiKeyHelper absent PASS. The locus tells you whether this is an individual install (~/.claude/settings.json) or an MDM-managed one (managed-settings.json); MDM-managed wiring is intentionally outside the user’s home directory.

1.3 Hook script present and executable

ls -l ~/.claude/hooks/unbound.py "/Library/Application Support/ClaudeCode/hooks/unbound.py" 2>/dev/null
Expected: the file at your install locus from check 1.2 exists with x permission bits (e.g. -rwxr-xr-x). It is normal for the other path to be absent.

1.4 API key resolvable

[ -n "$UNBOUND_CLAUDE_API_KEY" ] && echo "env: <set>" || echo "env: <unset>"
python3 - <<'EOF'
import json, os
p = os.path.expanduser('~/.unbound/config.json')
try:
    key = json.load(open(p)).get('api_key')
    print('config.json key:', 'present' if key else 'MISSING')
except FileNotFoundError:
    print('config.json key: MISSING (file not found)')
except json.JSONDecodeError:
    print('config.json key: MISSING (file is malformed JSON)')
EOF
Expected: at least one of the two present. The hook tries the env var first, then falls back to ~/.unbound/config.json, so an unset env var alone is fine.

1.5 Gateway-mode residue (matters if you are in subscription mode)

echo "ANTHROPIC_BASE_URL: ${ANTHROPIC_BASE_URL:-<unset>}"
[ -n "$UNBOUND_API_KEY" ] && echo "UNBOUND_API_KEY: <set>" || echo "UNBOUND_API_KEY: <unset>"
ls ~/.claude/anthropic_key.sh 2>/dev/null && echo "FAIL gateway key helper still present" || echo "PASS no gateway key helper"
cat ~/.zshrc ~/.zprofile ~/.bashrc ~/.bash_profile 2>/dev/null | grep -nE '^[[:space:]]*(export[[:space:]]+)?(ANTHROPIC_BASE_URL|UNBOUND_API_KEY)=' | sed -E 's/=.*/=<redacted>/' | grep . || echo "PASS no gateway exports in rc files"
The check deliberately skips commented-out lines and redacts values, so its output is safe to share. Expected in subscription mode: everything unset/absent.

1.6 Local hook logs (did the hook ever run, and did sends fail?)

ls -la ~/.claude/hooks/
tail -20 ~/.claude/hooks/error.log 2>/dev/null || echo "no error.log (hook has never logged an error, or never ran)"
tail -5 ~/.claude/hooks/agent-audit.log 2>/dev/null || echo "no agent-audit.log (hook has likely never executed)"
Treat local logs like any diagnostic artifact: they can include request details such as your API key. Redact any Bearer <key> values before sharing this log, whether in Slack, email, or an AI assistant. Agents: strip Authorization headers from any log content you echo back or transmit, and share audit-log content as timestamps and event names only, never full event payloads.
A useful baseline when reading error.log: occasional [Errno 32] Broken pipe entries are benign (Claude closed the pipe after the hook already sent its data), and sporadic timed out after 20 seconds entries are transient network or gateway latency. On their own, neither indicates broken telemetry. The signals that matter are API request failed and Exception in send_to_api entries timestamped after your recent activity; entries older than your last successful usage are history, not the current fault. Hook API error: entries belong to the policy-check path, not telemetry delivery, and do not explain missing usage data. A self_update error: [Errno 2] No such file or directory: ... unbound.py entry means the hook script was missing while the wiring still pointed at it, which is exactly the state this page repairs: treat it as D1.

1.7 Network reachability

curl -sS --max-time 15 -o /dev/null -w "unbound ingest: HTTP %{http_code}\n" -X POST https://api.getunbound.ai/v1/hooks/claude
curl -sS --max-time 15 -o /dev/null -w "github raw (setup dependency): HTTP %{http_code}\n" https://raw.githubusercontent.com/websentry-ai/setup/refs/heads/main/claude-code/hooks/unbound.py
Expected: ingest returns 401 (reachable; 401 just means no auth header, which counts as a PASS). GitHub raw returns 200. Timeouts, 000, or TLS errors mean a network/proxy block.

1.8 Live end-to-end telemetry test (sends one labeled synthetic event)

This sends one synthetic usage event through the real delivery pipeline. It appears in your org’s Unbound dashboard as a zero-token claude-code event whose prompt starts with [unbound-diagnostic], which is the point: if this passes, Phase 4 has a guaranteed event to look for. Run it while no other Claude Code conversation is mid-prompt on this machine, and note that it appends two entries to the local audit log.
HOOK="$HOME/.claude/hooks/unbound.py"
[ -f "$HOOK" ] || HOOK="/Library/Application Support/ClaudeCode/hooks/unbound.py"
[ -f "$HOOK" ] || { echo "FAIL hook script not found at either location (go to Fix 2)"; exit 1; }
SID="unbound-diag-$(date +%s)"
echo "{\"hook_event_name\":\"UserPromptSubmit\",\"session_id\":\"$SID\",\"prompt\":\"[unbound-diagnostic] telemetry self-test, please ignore\"}" | python3 "$HOOK"
echo "{\"hook_event_name\":\"Stop\",\"session_id\":\"$SID\",\"last_assistant_message\":\"[unbound-diagnostic] synthetic reply\"}" | python3 "$HOOK"
sleep 1; tail -3 ~/.claude/hooks/error.log 2>/dev/null || echo "no error.log"
PASS: no new API request failed or Exception in send_to_api line timestamped after the test. The hook always exits 0, so the exit code is not a signal; silence in error.log after a real send attempt means the gateway accepted the event. FAIL: a new API request failed: curl: (7) or curl: (28) line means network egress to Unbound is blocked (Fix 4); a curl: (56) ... 401 or curl: (22) ... 401 line means the key was rejected (Fix 2).

Phase 2: decision table

Match your Phase 1 results top-down; the first matching row is your diagnosis.
#Symptom patternDiagnosisFix
D11.2 shows any FAIL (missing events, malformed JSON, or apiKeyHelper present in subscription mode), or 1.3 shows the script at your install locus missing or without execute permissionHooks not fully wired: failed or partial setup, or stale mode-switch residueFix 2 (Fix 3 if it recurs)
D21.2 all PASS but 1.6 shows no audit log at allHooks wired but they have never fired: Claude either has not been restarted since install or has not been given a prompt sinceFix 1 (restart), then run check 1.8; if 1.8 passes, the hook script and delivery pipeline are functional, and a restarted Claude session with a real prompt (Phase 4) should start producing data
D31.2 all PASS, 1.6 shows API request failed or Exception in send_to_api entries newer than the last successful activity, or 1.8 produces a new failure lineHook fires but can’t deliver (network egress or auth)Fix 4
D41.4 shows both key sources missingNo credential for the hookFix 2
D51.5 shows gateway residue while in subscription modeIncomplete mode switchFix 2 (Fix 3 if residue survives)
D61.7 GitHub raw unreachableCorporate network blocks the setup download; re-running setup will keep failingRun Fix 2 on a different network (hotspot/home), or ask IT to allow raw.githubusercontent.com
D7Everything passes including 1.8 (no new failure line in error.log)Delivery pipeline works end to endFix 1, then Phase 4. The 1.8 synthetic event itself should appear in the dashboard: if it does but real sessions still produce nothing, the problem is hook loading (Fix 1); if even the synthetic event never appears, escalate (Phase 5)

Phase 3: fix ladder

Work down the tiers and stop at the first one that gets Phase 4 to pass.
Agents: tiers 1 and 2 are safe to run. Tier 3 (nuke) removes Unbound from every AI tool on the machine, so confirm with the human before running it.

Fix 1: restart properly

Agents: restarting Claude Code ends your own session. Before handing this step to the user, write a resume file at ~/unbound-diagnosis.md containing your Phase 1 outputs, the decision row you matched, and any fix already applied, then tell the user: after relaunching, point the new session at this page and say “resume from ~/unbound-diagnosis.md”.
1

Fully quit Claude Code

Close every session, tab, and window, not just the conversation.
2

Open a brand-new terminal

So refreshed environment variables load.
3

Relaunch Claude Code from that terminal

Hooks and environment only load on a fresh process.

Fix 2: clean re-install

unbound setup --all --clear     # removes every tool's Unbound config (both modes)
unbound setup --all             # reinstalls Unbound config for Cursor, Claude Code, Codex, Copilot
No keys to copy: --clear needs no login at all, and the install command reuses your stored unbound login credential from ~/.unbound/config.json (check 1.4 confirmed it is there). If you have never logged in, the install command opens a browser to authenticate. Notes:
  • Precondition: both 1.7 checks must PASS before running --clear. The clear happens first; if the network can’t reach GitHub raw, the reinstall fails and the machine ends up with no Unbound config at all. Resolve D6 first.
  • The clear step removes Unbound config for every tool it can manage (both Claude Code and Codex modes, Cursor, Copilot, and Gemini CLI); the install step then sets up the four default tools (Claude Code, Cursor, Codex, Copilot). That is intentional: it removes cross-mode and cross-tool residue in one pass. If you use Gemini CLI through Unbound, re-run its setup afterwards with unbound setup gemini-cli.
  • Every tool must show a green check and the run must end with All tools configured. If any tool fails, do not proceed; check 1.7 (network) and retry, on a different network if needed.
  • Optional: add --backfill to the install command to also upload local session history and fill the data gap from the outage.
  • Re-run check 1.2. If any event still FAILs after a successful run, go to Fix 3.
  • Then do Fix 1 (restart). Always.

Fix 3: nuke and repave

Use when Fix 2 reports success but checks still fail, or when mode-switch residue keeps coming back:
unbound nuke          # removes Unbound from every tool, user-level (asks to confirm)
# sudo unbound nuke   # use instead if your install was MDM/system-level
Note that nuke also deletes the stored login (~/.unbound/config.json), so the follow-up setup will ask you to authenticate in a browser; on a remote or headless machine, plan for that before nuking. If check 1.2 still reports hook entries after a clear or nuke, those entries use a quoted command format the cleaner cannot currently match; remove them from settings.json by hand or escalate (Phase 5). Then either run the Fix 2 install command again (unbound setup --all), or re-onboard everything (all tools plus device discovery) with the onboard command from the setup page (gateway.getunbound.ai/setup → My Device). Then Fix 1 (restart). Re-run all of Phase 1.

Fix 4: delivery blocked (hook fires, sends fail)

1

Re-check network reachability

Re-run 1.7. If api.getunbound.ai is unreachable, you are behind a corporate proxy or firewall. Ask IT to allow api.getunbound.ai (and backend.getunbound.ai), or test on another network to confirm.
2

Refresh credentials

If reachable but error.log shows auth-style failures: run Fix 2 to refresh the key, then Fix 1.

Phase 4: verify it actually works

A fix without a Phase 4 pass is not a fix. Do not skip this.
1

Start a NEW Claude Code session

After fixing and restarting, run two or three real prompts.
2

Check the local evidence

tail -5 ~/.claude/hooks/agent-audit.log    # should show entries from the last few minutes
tail -5 ~/.claude/hooks/error.log 2>/dev/null   # should show NO new "API request failed"
3

Check the Unbound dashboard

Your session should appear at gateway.getunbound.ai, usually within a minute (measured ingestion is around ten seconds). Wait five minutes before treating this step as failed.

Phase 5: escalate to Unbound support

If Phase 4 fails after the fix ladder, the remaining suspects are on our side (key/app mapping, ingestion), and we want to hear from you. Compile this bundle:
  1. Full output of all Phase 1 checks
  2. The exact tier(s) of Phase 3 you ran and their console output
  3. unbound --version and your OS version
  4. The timeframe of the missing data and the affected user emails (this is support’s first question; including it saves a round trip)
Before sharing, redact any Bearer <key> values from log output, standard practice for any diagnostic logs. This sanitized copy is safe to share:
sed -E 's/Bearer [A-Za-z0-9._-]+/Bearer [REDACTED]/g' ~/.claude/hooks/error.log
Send it through your organization’s shared Unbound Slack channel, or email support@unboundsecurity.ai. That bundle lets us pinpoint the issue in minutes instead of days.
Agents: do not send this bundle anywhere on your own. Compile it, confirm it is redacted, present it to the user, and end with an open question, for example: “Your diagnostic bundle is ready and sanitized. How would you like to send it: should I locate your organization’s shared Unbound Slack channel and draft the message there, draft an email to support@unboundsecurity.ai for your review, or would you rather send it yourself?” The user decides where escalation goes; you prepare it.

A note on switching modes

Switching modes (gateway → subscription or back) swaps one delivery pipeline for the other. If the switch was interrupted partway (a blocked download on a corporate network, for example), settings from both modes can linger together and data stops flowing. If your data stopped right when you switched, go straight to Fix 2 (clean re-install), or Fix 3 (nuke) if anything survives it.

For admins: MDM / fleet rollouts

  • A device appearing in Discovery (inventory of installed AI tools) does not mean coding telemetry is flowing. Discovery and telemetry are separate pipelines with separate keys. Verify a fleet rollout by checking that usage events arrive; usage events are the ground truth, and dashboard fields that lag a successful install should not be treated as failure signals.
  • MDM-pushed setup (sudo unbound onboard-mdm) installs system-level hooks that need sudo to tamper with. On MDM devices the hook wiring lives in /Library/Application Support/ClaudeCode/managed-settings.json, not the user’s home; checks 1.2 and 1.3 detect this automatically, and the per-user checks (1.4 to 1.6) still apply per home directory. See MDM Integrations.
  • Success criterion for a fleet push: every target device produces at least one usage event within 24 hours of a developer using a wired tool. Devices that check in but never produce usage are exactly the failure mode this page diagnoses.