Skip to content

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

SourceData collected
Claude CodeSessions, input/output/cache tokens per project, tool calls, model used, date
OpenClawSessions, 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.

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.

  • 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.

Terminal window
curl -o ~/numbrs-claude-collector.sh https://raw.githubusercontent.com/satsdisco/numbrs/main/collectors/claude.sh
chmod +x ~/numbrs-claude-collector.sh

Or copy the full script from the section below and save it to ~/numbrs-claude-collector.sh.

Set your credentials and run once to test:

Terminal window
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.sh

You should see output like:

Claude Code: synced 12 sessions
OpenClaw: synced 5 sessions

If 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.

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 = 0

Run it once with this change, then revert it. This gives you historical charts from day one.

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>&1

Verify 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:

Terminal window
launchctl load ~/Library/LaunchAgents/lol.numbrs.claude-collector.plist

To verify it’s running: launchctl list | grep numbrs

numbrs-claude-collector.sh
#!/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 1
fi
python3 << PYEOF
import json, os, glob, urllib.request, urllib.error
from collections import defaultdict
from 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 = 0
for 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")
PYEOF

“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:

Terminal window
CLAUDE_USERNAME=yourusername bash ~/numbrs-claude-collector.sh