macOS Menu Bar App to Track Claude Code Usage

M
Matthew Diakonov

Running Claude Code for hours without tracking usage is like driving without a fuel gauge. You only notice when the tank is empty - or when the bill arrives. With Sonnet 4.5 costing $3/$15 per million tokens and Opus running $5/$25, a single afternoon of parallel agents can burn through $50-200 without you realizing it. A menu bar app that surfaces token counts, costs, and session duration solves this without breaking your flow.

This guide covers everything from understanding Claude Code's log format to building a native tracker, and compares the major open source options available today.

Why the Menu Bar Is the Right Place

Menu bar utilities live in your peripheral vision. You glance up, see the number, and keep working. No need to open a dashboard, switch tabs, or run a CLI command. For something you want to monitor passively - like API spend - the menu bar is the perfect interface.

This is not a new idea. Developers have used menu bar widgets for CPU load, network speed, and battery stats for years. Token spend is just another metric that belongs up there - especially when a runaway agent can cost you $30 in twenty minutes.

The core insight: Claude Code already writes all the data you need to disk. Every API call, every token count, every model selection gets logged as JSONL to ~/.claude/projects/. You do not need to intercept API calls, set up a proxy, or configure webhooks. You just need something to read and display those files.

Understanding Claude Code's JSONL Log Files

Before you can build a tracker, you need to understand the data source. Claude Code writes append-only JSONL (JSON Lines) files for every conversation. Each line is a self-contained JSON object representing one event.

File Location

Logs live in ~/.claude/projects/, organized by project directory. The path structure looks like this:

~/.claude/projects/
  ├── -Users-yourname-project-a/
  │   ├── abc123-def456.jsonl
  │   └── ghi789-jkl012.jsonl
  └── -Users-yourname-project-b/
      └── mno345-pqr678.jsonl

Each .jsonl file corresponds to a single conversation session. The filename is the session ID.

JSONL Record Structure

Every line in these files is a JSON object with these core fields:

{
  "type": "assistant",
  "timestamp": "2026-03-23T14:22:10.323Z",
  "uuid": "msg_01XYZ...",
  "parentUuid": "msg_01ABC...",
  "sessionId": "abc123-def456",
  "cwd": "/Users/yourname/project-a",
  "version": "1.0.23",
  "message": {
    "id": "msg_01XYZ...",
    "role": "assistant",
    "model": "claude-sonnet-4-20250514",
    "content": [
      { "type": "text", "text": "Here's the fix..." },
      { "type": "tool_use", "id": "toolu_01...", "name": "Edit", "input": {} }
    ],
    "stop_reason": "tool_use",
    "usage": {
      "input_tokens": 12847,
      "output_tokens": 1523,
      "cache_creation_input_tokens": 8192,
      "cache_read_input_tokens": 41000,
      "service_tier": "standard"
    }
  }
}

The four token fields in the usage object are what matter for cost tracking:

  • input_tokens - Fresh tokens sent to the model (not from cache)
  • output_tokens - Tokens generated by the model
  • cache_creation_input_tokens - Tokens written to the prompt cache (costs 1.25x the standard input price)
  • cache_read_input_tokens - Tokens served from cache (costs only 10% of the standard input price)

Understanding the cache fields is critical. In a typical Claude Code session, cache reads dominate. One GitHub issue reported that cache read tokens consumed 99.93% of their usage quota. If your tracker ignores cache tokens, the cost estimate will be wildly wrong.

User Messages vs. Assistant Messages

Not every line has a usage field. User messages ("type": "user") contain the prompts you typed but no token counts. Only assistant messages ("type": "assistant") include usage data, because that is when the API call happens and tokens are consumed.

Your parser needs to filter for assistant messages and skip everything else.

Cost Calculation: The Math

Token pricing varies by model and context length. Here are the current rates as of March 2026:

Standard Context (up to 200K input tokens)

Model Input Cache Write Cache Read Output
Claude Opus 4.5/4.6 $5.00/M $6.25/M $0.50/M $25.00/M
Claude Sonnet 4.5/4.6 $3.00/M $3.75/M $0.30/M $15.00/M
Claude Haiku 4.5 $1.00/M $1.25/M $0.10/M $5.00/M

Long Context (over 200K input tokens)

When input exceeds 200K tokens, pricing jumps:

Model Input Cache Write Cache Read Output
Claude Sonnet 4.5/4.6 $6.00/M $7.50/M $0.60/M $22.50/M

The cost formula for a single assistant message is:

cost = (input_tokens * input_rate
      + cache_creation_input_tokens * cache_write_rate
      + cache_read_input_tokens * cache_read_rate
      + output_tokens * output_rate) / 1_000_000

A practical example: a Sonnet 4.5 message with 12,847 input tokens, 8,192 cache write tokens, 41,000 cache read tokens, and 1,523 output tokens costs:

(12847 * 3.00 + 8192 * 3.75 + 41000 * 0.30 + 1523 * 15.00) / 1_000_000
= (38541 + 30720 + 12300 + 22845) / 1_000_000
= $0.1044

That is one message. A busy session can have hundreds.

Building a Native SwiftUI Menu Bar App

A native SwiftUI app is the best choice for this. It launches at login, uses minimal memory, and avoids the overhead of Electron or Python interpreters. Here is the architecture.

App Entry Point with MenuBarExtra

SwiftUI introduced MenuBarExtra in macOS 13 (Ventura), making menu bar apps trivial to set up:

import SwiftUI

@main
struct TokenTrackerApp: App {
    @StateObject private var usageManager = UsageManager()

    var body: some Scene {
        MenuBarExtra {
            UsageDashboardView(manager: usageManager)
        } label: {
            Text(usageManager.formattedCost)
                .monospacedDigit()
        }
    }
}

This puts your current cost right in the menu bar text. No dock icon, no window to manage. Click it and you get the detail view.

Watching for File Changes with DispatchSource

Instead of polling on a timer, use DispatchSource.makeFileSystemObjectSource to react to file changes. This is more efficient and gives you near-instant updates:

import Foundation

class FileWatcher {
    private var source: DispatchSourceFileSystemObject?
    private var fileDescriptor: Int32 = -1

    func watch(path: String, onChange: @escaping () -> Void) {
        fileDescriptor = open(path, O_EVTONLY)
        guard fileDescriptor >= 0 else { return }

        source = DispatchSource.makeFileSystemObjectSource(
            fileDescriptor: fileDescriptor,
            eventMask: [.write, .extend],
            queue: .main
        )

        source?.setEventHandler { onChange() }
        source?.setCancelHandler { [weak self] in
            if let fd = self?.fileDescriptor, fd >= 0 {
                close(fd)
            }
        }
        source?.resume()
    }

    func stop() {
        source?.cancel()
    }
}

The DispatchSource approach watches a single file descriptor. For monitoring the entire ~/.claude/projects/ directory tree, you have two options:

  1. FSEvents - Apple's file system events API that watches directory subtrees efficiently. It batches notifications and works well for recursive monitoring.
  2. Directory scanning on a timer - Simpler but less efficient. Scan every 30-60 seconds, check file modification dates, and only reparse changed files.

For a menu bar app, a hybrid approach works best: use FSEvents to detect when any file in the projects directory changes, then do a targeted reparse of only the modified .jsonl files.

Parsing JSONL in Swift

Here is a focused parser that extracts token usage from JSONL files:

import Foundation

struct TokenUsage {
    var inputTokens: Int = 0
    var outputTokens: Int = 0
    var cacheCreationTokens: Int = 0
    var cacheReadTokens: Int = 0
}

struct UsageParser {
    static func parse(fileURL: URL) -> TokenUsage {
        var total = TokenUsage()

        guard let data = try? Data(contentsOf: fileURL),
              let content = String(data: data, encoding: .utf8) else {
            return total
        }

        for line in content.components(separatedBy: .newlines) {
            guard !line.isEmpty,
                  let jsonData = line.data(using: .utf8),
                  let json = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any],
                  let message = json["message"] as? [String: Any],
                  let usage = message["usage"] as? [String: Any] else {
                continue
            }

            total.inputTokens += usage["input_tokens"] as? Int ?? 0
            total.outputTokens += usage["output_tokens"] as? Int ?? 0
            total.cacheCreationTokens += usage["cache_creation_input_tokens"] as? Int ?? 0
            total.cacheReadTokens += usage["cache_read_input_tokens"] as? Int ?? 0
        }

        return total
    }
}

Turn Grouping to Avoid Double-Counting

One subtlety that most naive parsers get wrong: Claude Code often makes multiple API calls within a single conversational turn. When Claude calls a tool, it gets the result and immediately makes another API call. If you count every assistant message independently, you overcount.

The ClaudeUsageTracker project handles this by grouping consecutive assistant messages within a 10-second window as a single turn. Tool calls within the same turn are counted as one billable event. This prevents inflated cost estimates from multi-step tool use chains.

struct Turn {
    var messages: [AssistantMessage]
    var startTime: Date

    var totalUsage: TokenUsage {
        messages.reduce(TokenUsage()) { result, msg in
            TokenUsage(
                inputTokens: result.inputTokens + msg.usage.inputTokens,
                outputTokens: result.outputTokens + msg.usage.outputTokens,
                cacheCreationTokens: result.cacheCreationTokens + msg.usage.cacheCreationTokens,
                cacheReadTokens: result.cacheReadTokens + msg.usage.cacheReadTokens
            )
        }
    }
}

func groupIntoTurns(_ messages: [AssistantMessage]) -> [Turn] {
    var turns: [Turn] = []
    var currentTurn: Turn?

    for msg in messages.sorted(by: { $0.timestamp < $1.timestamp }) {
        if let turn = currentTurn,
           msg.timestamp.timeIntervalSince(turn.messages.last!.timestamp) < 10 {
            currentTurn?.messages.append(msg)
        } else {
            if let turn = currentTurn { turns.append(turn) }
            currentTurn = Turn(messages: [msg], startTime: msg.timestamp)
        }
    }
    if let turn = currentTurn { turns.append(turn) }

    return turns
}

Detecting the Model for Accurate Pricing

Each assistant message includes a model field like "claude-sonnet-4-20250514" or "claude-opus-4-20250514". Your cost calculator needs to switch pricing tiers based on this:

enum ClaudeModel {
    case opus, sonnet, haiku, unknown

    var pricing: (input: Double, cacheWrite: Double, cacheRead: Double, output: Double) {
        switch self {
        case .opus:   return (5.00, 6.25, 0.50, 25.00)
        case .sonnet: return (3.00, 3.75, 0.30, 15.00)
        case .haiku:  return (1.00, 1.25, 0.10, 5.00)
        case .unknown: return (3.00, 3.75, 0.30, 15.00) // default to Sonnet
        }
    }

    static func from(modelString: String) -> ClaudeModel {
        if modelString.contains("opus") { return .opus }
        if modelString.contains("haiku") { return .haiku }
        return .sonnet
    }
}

The Quick Path: xbar + ccusage

If you want a working solution in five minutes without writing Swift, the xbar + ccusage combination is the fastest path.

xbar is a Go-based menu bar framework that runs scripts on a schedule and displays their output. ccusage is a Node.js CLI tool that parses Claude Code's JSONL files and outputs structured usage data.

Setup

  1. Install xbar from xbarapp.com
  2. Create a plugin script at ~/Library/Application Support/xbar/plugins/claude_tokens.5m.py:
#!/usr/bin/env python3

import subprocess
import json
import sys
from datetime import date
from pathlib import Path

# Find npx across common install locations
NPX_PATHS = [
    "/opt/homebrew/bin/npx",
    "/usr/local/bin/npx",
    str(Path.home() / ".nvm/versions/node" / "*/bin/npx"),
]

def find_npx():
    for p in NPX_PATHS:
        if Path(p).exists():
            return p
    # Fallback: search PATH
    result = subprocess.run(["which", "npx"], capture_output=True, text=True)
    return result.stdout.strip() if result.returncode == 0 else None

def format_tokens(n):
    if n >= 1_000_000:
        return f"{n/1_000_000:.1f}M"
    if n >= 1_000:
        return f"{n/1_000:.1f}K"
    return str(n)

def main():
    npx = find_npx()
    if not npx:
        print("npx not found")
        return

    try:
        result = subprocess.run(
            [npx, "ccusage@latest", "report", "daily", "-j"],
            capture_output=True, text=True, timeout=30
        )
        data = json.loads(result.stdout)
    except Exception as e:
        print(f"Error: {e}")
        return

    today = date.today().isoformat()
    today_usage = None

    for entry in data.get("daily", []):
        if entry.get("date") == today:
            today_usage = entry
            break

    if today_usage:
        cost = today_usage.get("totalCost", 0)
        tokens = today_usage.get("totalTokens", 0)
        print(f"${cost:.2f} | {format_tokens(tokens)} tokens")
    else:
        print("$0.00 today")

    print("---")
    print("Refresh | refresh=true")

if __name__ == "__main__":
    main()
  1. Make it executable: chmod +x ~/Library/Application\ Support/xbar/plugins/claude_tokens.5m.py

The 5m in the filename tells xbar to run the script every 5 minutes. Change it to 1m for more frequent updates, though ccusage takes a few seconds to parse large log directories.

Limitations of xbar + ccusage

  • Not real-time - updates on a polling interval, not on file change
  • Subprocess overhead - spawns a Node.js process every refresh
  • No session breakdown - shows daily totals but not per-session detail by default
  • Cold start - first run downloads ccusage if not cached

For casual monitoring, this is fine. For teams running 10+ parallel agents, the native approach is worth the investment.

Existing Open Source Options

The ecosystem has matured significantly. Here are the standout tools:

ClaudeUsageTracker (by masorange)

A native SwiftUI app that shows your current month's cost in the menu bar. It groups consecutive assistant messages into turns with a 10-second window, supports both local JSONL parsing and LiteLLM API integration, and breaks down costs by month, project, and model. It also handles long-context pricing automatically when input exceeds 200K tokens.

GitHub: masorange/ClaudeUsageTracker

SessionWatcher

A polished commercial option at $1.99 (one-time). Tracks token counts, costs, rate limits, and the 5-hour billing window reset. The rate limit tracking is the differentiator - it reads Claude Code's OAuth token from Keychain to fetch real rate limit data from Anthropic's API, rather than estimating.

sessionwatcher.com

Claude God

Free and open source. Monitors both Claude AI (web) and Claude Code (API) usage. Tracks rate limits, quotas, session history, and token usage. Installable via Homebrew for easy updates.

claudegod.app

TokenMeter

Combines local JSONL parsing for cost analytics with Keychain-based OAuth for real rate limit data. Built with SwiftUI, focused specifically on Claude Code rather than general Claude usage.

GitHub: Priyans-hu/tokenmeter

ccusage (CLI)

Not a menu bar app, but the most flexible analysis tool. Supports daily, weekly, monthly, session, and 5-hour block reports. JSON output makes it easy to pipe into other tools. Works with both Claude Code and OpenAI Codex CLI logs.

GitHub: ryoppippi/ccusage

Detecting Runaway Agents

One of the most valuable features a menu bar tracker can provide is runaway agent detection. Claude Code agents can get stuck in loops - repeatedly calling the same tool, regenerating the same code, or fighting with a build error that never resolves.

What a Stuck Loop Looks Like in the Data

A healthy session shows varied tool calls with decreasing token counts as the context stabilizes. A stuck agent shows a repeating pattern:

  • Same stop_reason: "tool_use" over and over
  • Token counts that stay flat or grow linearly
  • Timestamps with consistent spacing (the agent is making calls at a fixed cadence)
  • The same tool name appearing in consecutive content blocks

Implementing an Alert

Your menu bar app can detect this by tracking the rate of token consumption:

func checkForRunaway(session: Session) -> Bool {
    let recentMessages = session.messages.suffix(20)
    let timespan = recentMessages.last!.timestamp
        .timeIntervalSince(recentMessages.first!.timestamp)

    // More than 20 messages in under 2 minutes is suspicious
    if timespan < 120 && recentMessages.count >= 20 {
        return true
    }

    // Check for repeated tool calls
    let toolNames = recentMessages.compactMap { msg in
        msg.content.first(where: { $0.type == "tool_use" })?.name
    }
    let uniqueTools = Set(toolNames)
    if toolNames.count > 10 && uniqueTools.count <= 2 {
        return true
    }

    return false
}

When detected, flash the menu bar icon red or send a macOS notification. The Claude Code --max-turns and --max-budget-usd flags can also limit damage, but a visual alert lets you intervene before those limits are hit.

What to Display: Designing the Dashboard

Based on what the open source tools have converged on, here are the metrics that matter most:

Menu Bar Text

Keep it minimal. Show one number: today's total cost. Something like $14.23. If you want to add a second data point, show active session count: $14.23 (3).

Dropdown Panel

When clicked, show a richer view:

  • Today's spend with a breakdown by model (Sonnet vs. Opus)
  • This month's total for budget tracking
  • Active sessions with per-session cost and duration
  • Token breakdown - input, output, cache write, cache read
  • Rate limit status - if you have OAuth access, show remaining tokens in the current 5-hour window
  • Cost trend - a sparkline or mini chart showing daily spend for the past 7 days

Visual Indicators

  • Green: under 50% of your daily budget
  • Yellow: 50-80% of budget
  • Red: over budget or runaway agent detected
  • Pulsing dot: an agent is actively generating tokens right now

Performance Considerations

A menu bar app needs to be invisible in terms of resource usage. Here are the numbers to aim for:

  • Memory: under 30 MB resident. A SwiftUI menu bar app with no window typically sits around 15-20 MB.
  • CPU: effectively zero when idle. Spike briefly on file change, then back to zero.
  • Disk I/O: read-only. Never write to the Claude logs directory.
  • Startup time: under 1 second. Native Swift achieves this easily. Electron would not.

For the initial parse of a large log directory (hundreds of JSONL files from months of usage), do the work on a background thread and show a placeholder in the menu bar. Cache the results so subsequent launches only need to parse new files.

actor UsageCache {
    private var cache: [String: (modDate: Date, usage: TokenUsage)] = [:]

    func getUsage(for file: URL) async -> TokenUsage {
        let attrs = try? FileManager.default.attributesOfItem(atPath: file.path)
        let modDate = attrs?[.modificationDate] as? Date ?? .distantPast

        if let cached = cache[file.path], cached.modDate == modDate {
            return cached.usage
        }

        let usage = UsageParser.parse(fileURL: file)
        cache[file.path] = (modDate, usage)
        return usage
    }
}

Privacy and Security

One advantage of building this locally: your token data never leaves your machine. The JSONL files contain your actual conversations with Claude, including code snippets, file contents, and tool call results. A menu bar app that reads these locally and displays aggregate numbers is far safer than uploading logs to a third-party analytics service.

If you are evaluating an open source tracker, verify that:

  1. It does not make any network calls (check with Little Snitch or nettop)
  2. It reads from ~/.claude/projects/ and nowhere else
  3. It does not write to the Claude directory
  4. It does not store or cache conversation content - only token counts

Putting It All Together

The ideal Claude Code usage tracker is:

  1. Native Swift/SwiftUI for minimal overhead and instant startup
  2. File-watch based using FSEvents or DispatchSource, not polling
  3. Cache-aware in its cost calculations, handling all four token types
  4. Model-aware, switching pricing tiers based on the model field
  5. Turn-grouped to avoid double-counting multi-tool interactions
  6. Alert-capable for runaway agent detection

Whether you build your own or grab one of the open source options, the key point is the same: make your AI spend visible. The menu bar is where it belongs - always there, never in the way.

Fazm's architecture uses a SwiftUI menu bar design for the same reasons - always visible, never intrusive.

This post was inspired by a discussion on r/ClaudeAI by u/Downtown-Response-27.

Fazm is an open source macOS AI agent. Open source on GitHub.

More on This Topic

Related Posts