Claude AI Usage
If you use Claude Code or an OpenClaw AI assistant, this integration gives you visibility into where your tokens are going — by project, by session, by day. It reads the JSONL session logs that Claude writes locally, parses token usage and cost data, and syncs it to your numbrs dashboard.
Estimated setup time: 10 minutes
What it tracks
Section titled “What it tracks”| Source | Data collected |
|---|---|
| Claude Code | Sessions, input/output/cache tokens per project, tool calls, model used, date |
| OpenClaw | Sessions, tokens by model, API equivalent cost in USD, channel (Slack/Discord/Direct) |
Both are synced to separate Supabase tables (claude_usage and openclaw_usage) so you can query them independently or mix them on a single dashboard.
How it works
Section titled “How it works”Claude Code writes a JSONL file for every session at ~/.claude/projects/<project-name>/<session-id>.jsonl. Each line in the file is a message object containing token usage in the usage field.
OpenClaw writes session files to ~/.openclaw/agents/main/sessions/<session-id>.jsonl with a similar structure.
The collector script reads these files, aggregates token counts per session, and upserts the data to your Supabase tables. Sessions that haven’t changed since the last run are skipped. The script processes files modified in the last 2 hours — fast enough to run every 5 minutes.
Prerequisites
Section titled “Prerequisites”- Python 3 (comes with macOS — no additional installs needed)
- A numbrs account at numbrs.lol or self-hosted
- Your User ID — go to Settings → Profile
- Your Supabase service key — go to Settings → API
See Integrations Overview for details on finding these credentials.
Step-by-step setup
Section titled “Step-by-step setup”1. Download the collector script
Section titled “1. Download the collector script”curl -o ~/numbrs-claude-collector.sh https://raw.githubusercontent.com/satsdisco/numbrs/main/collectors/claude.shchmod +x ~/numbrs-claude-collector.shOr copy the full script from the section below and save it to ~/numbrs-claude-collector.sh.
2. Run it manually to verify
Section titled “2. Run it manually to verify”Set your credentials and run once to test:
export NUMBRS_SUPABASE_URL="https://YOUR_PROJECT.supabase.co"export NUMBRS_SUPABASE_SERVICE_KEY="your-service-role-key"export NUMBRS_OWNER_ID="your-user-id"
bash ~/numbrs-claude-collector.shYou should see output like:
Claude Code: synced 12 sessionsOpenClaw: synced 5 sessionsIf you see synced 0 sessions, the script ran successfully but found no recently-modified session files. That’s fine — it means no new sessions in the last 2 hours. See the backfill note below.
3. Backfill your history (optional)
Section titled “3. Backfill your history (optional)”By default, the script only processes files modified in the last 2 hours. To import your full history, edit the script and change:
cutoff = (datetime.now() - timedelta(hours=2)).timestamp()to:
cutoff = 0Run it once with this change, then revert it. This gives you historical charts from day one.
4. Set up cron (runs every 5 minutes)
Section titled “4. Set up cron (runs every 5 minutes)”Option A: crontab
Section titled “Option A: crontab”Run crontab -e and add this line (replace the ... with your actual values):
*/5 * * * * NUMBRS_SUPABASE_URL=https://YOUR_PROJECT.supabase.co NUMBRS_SUPABASE_SERVICE_KEY=your-service-key NUMBRS_OWNER_ID=your-user-id bash /Users/YOUR_USERNAME/numbrs-claude-collector.sh >> /tmp/numbrs-claude.log 2>&1Verify it’s set: crontab -l
Option B: macOS launchd (more reliable than cron)
Section titled “Option B: macOS launchd (more reliable than cron)”Create a plist file at ~/Library/LaunchAgents/lol.numbrs.claude-collector.plist:
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"><plist version="1.0"><dict> <key>Label</key> <string>lol.numbrs.claude-collector</string> <key>ProgramArguments</key> <array> <string>/bin/bash</string> <string>/Users/YOUR_USERNAME/numbrs-claude-collector.sh</string> </array> <key>EnvironmentVariables</key> <dict> <key>NUMBRS_SUPABASE_URL</key> <string>https://YOUR_PROJECT.supabase.co</string> <key>NUMBRS_SUPABASE_SERVICE_KEY</key> <string>your-service-role-key</string> <key>NUMBRS_OWNER_ID</key> <string>your-user-id</string> </dict> <key>StartInterval</key> <integer>300</integer> <key>RunAtLoad</key> <true/> <key>StandardOutPath</key> <string>/tmp/numbrs-claude.log</string> <key>StandardErrorPath</key> <string>/tmp/numbrs-claude.log</string></dict></plist>Replace YOUR_USERNAME and the credential placeholders, then load it:
launchctl load ~/Library/LaunchAgents/lol.numbrs.claude-collector.plistTo verify it’s running: launchctl list | grep numbrs
The collector script
Section titled “The collector script”#!/usr/bin/env bash# Syncs Claude Code + OpenClaw usage to numbrs# Cron: */5 * * * * NUMBRS_SUPABASE_URL=... NUMBRS_SUPABASE_SERVICE_KEY=... NUMBRS_OWNER_ID=... bash /path/to/this-script.sh
set -euo pipefail
NUMBRS_SUPABASE_URL="${NUMBRS_SUPABASE_URL:-}"NUMBRS_SUPABASE_SERVICE_KEY="${NUMBRS_SUPABASE_SERVICE_KEY:-}"NUMBRS_OWNER_ID="${NUMBRS_OWNER_ID:-}"CLAUDE_USERNAME="${CLAUDE_USERNAME:-$(whoami)}"
if [[ -z "$NUMBRS_SUPABASE_URL" || -z "$NUMBRS_SUPABASE_SERVICE_KEY" || -z "$NUMBRS_OWNER_ID" ]]; then echo "Error: NUMBRS_SUPABASE_URL, NUMBRS_SUPABASE_SERVICE_KEY, and NUMBRS_OWNER_ID must be set" exit 1fi
python3 << PYEOFimport json, os, glob, urllib.request, urllib.errorfrom collections import defaultdictfrom datetime import datetime, timedelta
SB_URL = os.environ["NUMBRS_SUPABASE_URL"]SB_KEY = os.environ["NUMBRS_SUPABASE_SERVICE_KEY"]OWNER_ID = os.environ["NUMBRS_OWNER_ID"]USERNAME = os.environ.get("CLAUDE_USERNAME", os.environ.get("USER", ""))cutoff = (datetime.now() - timedelta(hours=2)).timestamp()
# ── Claude Code ──────────────────────────────────────────────────────────────PROJECTS_DIR = os.path.expanduser("~/.claude/projects")
def project_name(dir_name): n = dir_name.replace(f"-Users-{USERNAME}-", "").replace(f"-home-{USERNAME}-", "") n = n.strip("-").replace("-", " ").replace("--", "/") return n or "workspace"
code_ok = 0for proj_dir in glob.glob(f"{PROJECTS_DIR}/*/"): if os.path.getmtime(proj_dir) < cutoff: continue proj = project_name(os.path.basename(proj_dir.rstrip("/"))) proj_path = proj_dir.rstrip("/") for jsonl in glob.glob(f"{proj_dir}*.jsonl"): if os.path.getmtime(jsonl) < cutoff: continue session_id = os.path.basename(jsonl).replace(".jsonl", "") stats = {"session_id": session_id, "owner_id": OWNER_ID, "project": proj, "project_path": proj_path, "messages": 0, "tool_calls": 0, "input_tokens": 0, "output_tokens": 0, "cache_read_tokens": 0, "cache_write_tokens": 0, "model": None, "date": None} with open(jsonl) as f: for line in f: try: d = json.loads(line) msg = d.get("message", {}) if msg.get("role") == "assistant": usage = msg.get("usage", {}) if not stats["date"] and d.get("timestamp"): stats["date"] = d["timestamp"][:10] if not stats["model"] and msg.get("model"): stats["model"] = msg["model"] stats["messages"] += 1 stats["input_tokens"] += usage.get("inputTokens", usage.get("input_tokens", 0)) stats["output_tokens"] += usage.get("outputTokens", usage.get("output_tokens", 0)) stats["cache_read_tokens"] += usage.get("cacheReadInputTokens", usage.get("cacheRead", 0)) stats["cache_write_tokens"] += usage.get("cacheCreationInputTokens", usage.get("cacheWrite", 0)) for block in msg.get("content", []) if isinstance(msg.get("content"), list) else []: if isinstance(block, dict) and block.get("type") == "tool_use": stats["tool_calls"] += 1 except: pass if stats["messages"] > 0 and stats["date"]: data = json.dumps(stats).encode() req = urllib.request.Request(f"{SB_URL}/rest/v1/claude_usage", data=data, headers={"apikey": SB_KEY, "Authorization": f"Bearer {SB_KEY}", "Content-Type": "application/json", "Prefer": "return=minimal,resolution=merge-duplicates"}) try: urllib.request.urlopen(req); code_ok += 1 except: pass
print(f"Claude Code: synced {code_ok} sessions")
# ── OpenClaw ────────────────────────────────────────────────────────────────SESSIONS_DIR = os.path.expanduser("~/.openclaw/agents/main/sessions")
def channel_map(): mapping = {} try: data = json.load(open(f"{SESSIONS_DIR}/sessions.json")) for key, sessions in data.items(): ch = key.replace("agent:main:", "") if isinstance(sessions, list): for s in sessions: if isinstance(s, dict) and s.get("sessionId"): mapping[s["sessionId"]] = ch except: pass return mapping
ch_map = channel_map()oc_ok = 0
for jsonl in glob.glob(f"{SESSIONS_DIR}/*.jsonl"): if os.path.getmtime(jsonl) < cutoff: continue session_id = os.path.basename(jsonl).replace(".jsonl", "") stats = {"session_id": session_id, "channel": ch_map.get(session_id, "main"), "messages": 0, "input_tokens": 0, "output_tokens": 0, "cache_read_tokens": 0, "cache_write_tokens": 0, "cost_usd": 0.0, "date": None, "owner_id": OWNER_ID} model_tokens = defaultdict(int)
with open(jsonl) as f: for line in f: try: d = json.loads(line) if d.get("type") != "message": continue msg = d.get("message", {}) if msg.get("role") != "assistant": continue usage = msg.get("usage", {}) if not usage: continue model = msg.get("model", "") if model == "delivery-mirror": continue cost = usage.get("cost", {}) if not stats["date"] and d.get("timestamp"): stats["date"] = d["timestamp"][:10] m = model.replace("-20250514","").replace("-20241022","") if model else "unknown" model_tokens[m] += usage.get("output", 0) stats["messages"] += 1 stats["input_tokens"] += usage.get("input", 0) stats["output_tokens"] += usage.get("output", 0) stats["cache_read_tokens"] += usage.get("cacheRead", 0) stats["cache_write_tokens"] += usage.get("cacheWrite", 0) stats["cost_usd"] += cost.get("total", 0) except: pass
if model_tokens: stats["model"] = max(model_tokens, key=model_tokens.get) else: stats["model"] = "unknown"
if stats["messages"] > 0 and stats["date"]: update = {k: v for k, v in stats.items() if k not in ("session_id","owner_id")} data = json.dumps(update).encode() url = f"{SB_URL}/rest/v1/openclaw_usage?session_id=eq.{session_id}&owner_id=eq.{OWNER_ID}" req = urllib.request.Request(url, data=data, method="PATCH", headers={"apikey": SB_KEY, "Authorization": f"Bearer {SB_KEY}", "Content-Type": "application/json", "Prefer": "return=minimal"}) try: urllib.request.urlopen(req); oc_ok += 1 except: insert_data = json.dumps(stats).encode() ireq = urllib.request.Request(f"{SB_URL}/rest/v1/openclaw_usage", data=insert_data, headers={"apikey": SB_KEY, "Authorization": f"Bearer {SB_KEY}", "Content-Type": "application/json", "Prefer": "return=minimal"}) try: urllib.request.urlopen(ireq); oc_ok += 1 except: pass
print(f"OpenClaw: synced {oc_ok} sessions")PYEOFTroubleshooting
Section titled “Troubleshooting”“synced 0 sessions” for Claude Code
The script filters to files modified in the last 2 hours. If you just set this up, no recent sessions means no data synced — that’s normal. To import historical data, set cutoff = 0 as described above. Also verify ~/.claude/projects/ exists and has .jsonl files.
“synced 0 sessions” for OpenClaw
Check that ~/.openclaw/agents/main/sessions/ exists and contains .jsonl files. This path is hardcoded to the main agent. If you use a different agent name, update SESSIONS_DIR in the script.
“delivery-mirror” model appearing in data
This is an internal routing model in older versions of the collector. Update to the latest script — the current version filters it out.
HTTP 401 or 403 errors from Supabase
You’re likely using the anon key instead of the service role key. The service key is longer and starts with eyJ.... Double-check Settings → API in numbrs or Project Settings → API → service_role in Supabase.
Script exits immediately
All three environment variables must be set. The script will print an error message if any are missing.
Cron not running
Verify with crontab -l. If the line is there but data isn’t updating, check the log: tail -f /tmp/numbrs-claude.log. Common issue: cron on macOS needs full paths to everything.
Username mismatch in project names
The script strips your username from directory names to create readable project labels. If your system username differs from what’s in the path (can happen in some setups), set CLAUDE_USERNAME explicitly:
CLAUDE_USERNAME=yourusername bash ~/numbrs-claude-collector.shNext steps
Section titled “Next steps”- Dashboard Builder — create charts for your token usage
- Alerts — get notified when daily costs exceed a threshold
- Custom Metrics — push additional data alongside Claude stats