Getting Started
What is ccflux?
ccflux is a Claude Code plugin that collects opt-in, per-turn token usage telemetry from Claude Code sessions and ships it to a self-hosted receiver. It is built for organisations on seat-based Enterprise Claude Code plans where Anthropic's Analytics dashboard does not expose per-user token counts.
What it enables
- Per-user token consumption visible to IT admins
- Usage mapped against Claude Code's 5-hour reset windows — your primary tool for assessing seat pressure
- Model distribution across the organisation
- Evidence base for moving power users to higher-tier seats
What it never collects
No message content, prompts, file paths, code, or anything identifying project content. Only usage metadata: token counts, model name, session/turn identifiers, and timestamps.
How it works
A small Rust binary is invoked by Claude Code hook events (SessionStart, Stop, SessionEnd). After each assistant turn, it reads the new usage data from the session transcript, aggregates token counts by model, and POSTs a signed JSON payload to your organisation's receiver. The receiver stores events in SQLite for querying.
User's machine Your server
────────────── ───────────
Claude Code session
└─ hook fires (Stop)
└─ ccflux binary
├─ reads transcript
├─ signs payload (Ed25519)
└─ POST /report ──────────► receiver
└─ verify signature
└─ INSERT usage_events
The receiver exposes an admin dashboard, Prometheus metrics, and a SQLite database you can query directly.
Requirements
Server
- Linux x86_64 or aarch64
- A TLS-terminating reverse proxy (nginx, Caddy, etc.) — the receiver speaks plain HTTP
- Persistent storage for the SQLite database file (~1 KB per user-turn, very small)
User machines
- Claude Code installed and configured
- One of: Linux x86_64, Linux aarch64, macOS x86_64, macOS Apple Silicon, Windows x86_64
- Network access to your receiver endpoint over HTTPS
Quick start (for the impatient)
- Deploy the receiver on your server — see Server & IT Setup
- Provision refresh tokens — one per user, inserted into SQLite
- Distribute the plugin — users drop it into their Claude Code plugins directory and enter the endpoint and token in plugin settings
- Query usage — via the admin dashboard or SQL directly
The full flow from first deploy to first data takes about 15 minutes for IT, plus a minute per user to install the plugin.
Server & IT Setup
This guide walks through deploying the receiver, provisioning user tokens, and verifying the installation.
Step 1: Download the receiver binary
Download the latest release from GitHub Releases:
# Linux x86_64
wget https://github.com/Psy-Fer/ccflux/releases/latest/download/ccflux-receiver-linux-x86_64
chmod +x ccflux-receiver-linux-x86_64
Or build from source:
cd receiver
cargo build --release
# binary is at target/release/ccflux-receiver
Step 2: Configure the receiver
All configuration is via environment variables. Minimum required:
DATABASE_PATH=/var/lib/ccflux/ccflux.db \
LISTEN_ADDR=127.0.0.1:8080 \
ADMIN_TOKEN="$(openssl rand -hex 32)" \
./ccflux-receiver
The receiver creates the SQLite database and schema on first start. On startup it prints all resolved configuration values:
ccflux-receiver config:
DATABASE_PATH = /var/lib/ccflux/ccflux.db
LISTEN_ADDR = 127.0.0.1:8080
ACCESS_TOKEN_EXPIRY_SECS = 28800
REFRESH_TOKEN_ROLLING_DAYS = 90
RATE_LIMIT_PER_MINUTE = 30
BODY_LIMIT_KB = 64
REQUIRE_SIGNATURES = false
ADMIN_TOKEN = set
COOKIE_SECURE = false
ccflux-receiver listening on 127.0.0.1:8080
See Configuration Reference for all available variables.
Step 3: Put it behind a reverse proxy
The receiver speaks plain HTTP. Always put it behind a TLS-terminating reverse proxy in production.
nginx example
server {
listen 443 ssl;
server_name ccflux.example.org;
ssl_certificate /etc/letsencrypt/live/ccflux.example.org/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/ccflux.example.org/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
Caddy example
ccflux.example.org {
reverse_proxy 127.0.0.1:8080
}
Caddy handles certificate provisioning automatically.
Step 4: Run as a systemd service
# /etc/systemd/system/ccflux-receiver.service
[Unit]
Description=ccflux receiver
After=network.target
[Service]
Type=simple
User=ccflux
WorkingDirectory=/var/lib/ccflux
Environment=DATABASE_PATH=/var/lib/ccflux/ccflux.db
Environment=LISTEN_ADDR=127.0.0.1:8080
Environment=ADMIN_TOKEN=your-strong-admin-token-here
Environment=COOKIE_SECURE=1
Environment=REQUIRE_SIGNATURES=false
ExecStart=/usr/local/bin/ccflux-receiver
Restart=on-failure
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now ccflux-receiver
Step 5: Verify the deployment
curl https://ccflux.example.org/health
# {"status":"ok","db":"ok"}
If the database is unreachable the response will be 503 with {"status":"degraded","db":"error"}.
Step 6: Provision user refresh tokens
Each user needs a personal long-lived refresh token. Insert one row per user:
sqlite3 /var/lib/ccflux/ccflux.db
INSERT INTO refresh_tokens (token, email, division, expires_at)
VALUES (
'rtok_abc123...', -- generate with: openssl rand -hex 32
'jsmith@example.org',
'engineering', -- optional group label
datetime('now', '+365 days')
);
Generate tokens securely:
openssl rand -hex 32
Important: use one token per person. Sharing tokens prevents per-user attribution.
Token lifecycle
- Tokens are long-lived (default: 1 year at issuance)
- Each successful use automatically extends expiry by
REFRESH_TOKEN_ROLLING_DAYS(default 90) - Active users never need a new token issued — they roll automatically
- Inactive users (no activity for > 90 days) will need a new token from IT
Step 7: Send tokens to users
Give each user:
- Their personal refresh token
- Your receiver URL (e.g.
https://ccflux.example.org/report) - A link to the User Setup guide
Direct them to configure both values in the plugin settings. That's all users need to do.
Revoking access
Revoke a user immediately
UPDATE refresh_tokens SET revoked = 1 WHERE email = 'jsmith@example.org';
The user's next turn will receive a 401 and the binary will log the error silently. No CC interruption.
Revoke a specific device (e.g. lost laptop)
Find the device's public key in the admin dashboard under Device Keys, then:
UPDATE device_keys SET revoked = 1 WHERE public_key = '<base64-public-key>';
Or use the Revoke button in the admin dashboard directly. The device goes silent on its next turn. The user's other devices and their refresh token are unaffected.
Re-provision a user after revocation
Delete the revoked refresh token and insert a new one:
DELETE FROM refresh_tokens WHERE email = 'jsmith@example.org' AND revoked = 1;
INSERT INTO refresh_tokens (token, email, expires_at)
VALUES ('rtok_newtoken...', 'jsmith@example.org', datetime('now', '+365 days'));
Send the new token to the user. They update it in the plugin settings. The binary will pick it up on the next turn.
Enabling signature enforcement
Once your initial users have set up the plugin, you can enforce signatures permanently:
REQUIRE_SIGNATURES=1
You do not need to toggle this flag for new users. REQUIRE_SIGNATURES only applies to /report. The key registration endpoint (/register-key) has no signature requirement — it only needs a valid access token. A new user's first turn registers their key via /register-key, and only then do signed reports flow to /report. If registration takes more than one turn (e.g. a temporary network failure), reports queue locally and are sent signed once registration succeeds.
The only case where enabling this flag causes a hard failure is a binary version that predates signing support (before v0.1.0). Check the admin dashboard's Device Keys table to confirm existing devices have registered before enabling, then leave it on permanently.
User Setup
This guide is for Claude Code users whose IT team has deployed ccflux. You will need two things from IT before you start:
- Your receiver endpoint URL (e.g.
https://ccflux.example.org) - Your personal refresh token (a long string like
rtok_abc123...)
Step 1: Install the plugin
Download the latest release from GitHub Releases and extract it. You will find a plugin/ directory inside.
Option A: Claude Code plugin marketplace
If your organisation's fork is registered in the CC plugin marketplace, search for ccflux in the Claude Code plugin settings and install from there.
Option B: Manual install
Copy the plugin/ directory into your Claude Code plugins directory:
# Default CC installation
cp -r plugin/ ~/.claude/plugins/ccflux/
# If you use a custom CC config dir (e.g. alias claude-work)
cp -r plugin/ ~/.claude-work/plugins/ccflux/
On Windows (PowerShell):
Copy-Item -Recurse plugin\ "$env:APPDATA\Claude\plugins\ccflux\"
Step 2: Configure the endpoint and token
Open Claude Code and navigate to Settings → Plugins → ccflux.
Fill in:
| Field | Value |
|---|---|
| Receiver endpoint | The URL your IT team gave you, e.g. https://ccflux.example.org |
| API token | Your personal refresh token |
The token is stored in your system keychain (or ~/.claude/.credentials.json on systems without a keychain). It is never written to disk in plaintext.
Alternative: config file
If plugin settings aren't available in your CC version, create a config file instead:
mkdir -p ~/.claude/ccflux
cat > ~/.claude/ccflux/config.json << 'EOF'
{
"endpoint": "https://ccflux.example.org",
"token": "rtok_abc123..."
}
EOF
chmod 600 ~/.claude/ccflux/config.json
For a custom CC config dir, replace ~/.claude with your config dir (e.g. ~/.claude-work).
Step 3: Reload plugins and start a fresh session
Plugin hooks only apply to sessions started after the plugin is loaded. After installing and configuring ccflux you must:
- In your current CC session, run:
/plugins reload - Exit that session completely.
- Start a new CC session — hooks are now active.
Why this matters: The session you run
/plugins reloadin was already started without ccflux hooks. Only sessions begun after the reload will report usage. Skipping this step is the most common reason no data appears in the dashboard.
Step 4: Verify the first report
Complete a turn in the new session (send a message and get a response). After the turn, the plugin will have:
- Generated a device signing key (
~/.claude/ccflux/signing_key, readable only by you) - Registered the public key with the receiver
- Sent the first usage report
To confirm data is flowing, ask your IT admin to check the admin dashboard for your email address. The first report typically appears within a few seconds of a turn completing.
Check the activity log locally
All significant events — token refresh, key registration, each report sent — are logged to:
~/.claude/ccflux/activity.log
Errors also appear here (prefixed with ERROR) as well as in errors.log. The activity log is the first place to look when troubleshooting. Common entries and what they mean are covered in the Troubleshooting guide.
Multiple Claude Code aliases
If you use multiple CC instances (e.g. claude for personal, claude-work for work), install the plugin in each one separately. Each instance only reports for sessions that use its own config directory — they don't interfere with each other.
An unconfigured installation (no endpoint/token set) does nothing silently. You can safely have the plugin installed in a CC instance without configuring it.
Opting out
To stop reporting:
- Remove the endpoint and token from plugin settings (or delete
config.json) - Or uninstall the plugin entirely
The binary exits silently with no reporting when no endpoint is configured. Your existing data in the receiver is not affected.
Privacy notes
The plugin collects only token counts, model name, session/turn identifiers, and timestamps. It reads your email address from ~/.claude/.claude.json (the file CC maintains for your logged-in account) — you never need to enter it manually.
No message content, prompts, file names, code, or project paths are ever collected or transmitted.
Admin Dashboard
The admin dashboard is served at /admin/ on your receiver. It is disabled by default and enabled by setting the ADMIN_TOKEN environment variable to a non-empty string.
Accessing the dashboard
Navigate to https://ccflux.example.org/admin/ in your browser.
You will see a login form. Enter the value of your ADMIN_TOKEN environment variable. On success, an HttpOnly; SameSite=Strict session cookie is set — you stay logged in until the cookie expires or you clear it.
You can also authenticate via bearer token for programmatic access:
curl -H "Authorization: Bearer $ADMIN_TOKEN" https://ccflux.example.org/admin/
Exporting a static snapshot
Append ?export to the dashboard URL to download a self-contained HTML file. The export is read-only — all mutating forms (Revoke, Reissue, Add user) are stripped. Everything else works offline: charts render, search filters work, panels expand and collapse.
curl -H "Authorization: Bearer $ADMIN_TOKEN" \
"https://ccflux.example.org/admin/?export" \
-o dashboard-snapshot.html
The file has no external dependencies and can be shared with stakeholders, attached to reports, or hosted as a static page. See the Live Demo for an example.
Note: The export contains user email addresses, device hostnames, and usage data for your whole organisation. No credentials are included — admin token, refresh tokens, and access tokens are all stripped. Treat the file the same way you would treat a spreadsheet export: don't commit it to a public repository or share it beyond the intended recipients.
Summary cards
The top of the dashboard shows org-wide totals across all time:
| Card | Description |
|---|---|
| Users | Distinct user emails with at least one recorded event |
| Sessions | Distinct session IDs |
| Turns | Total usage events recorded |
| Input tokens | Sum of input_tokens across all events |
| Output tokens | Sum of output_tokens across all events |
| Cache hit rate | cache_read_tokens / (input_tokens + cache_read_tokens + cache_write_tokens), expressed as a percentage |
A high cache hit rate (>70%) indicates users are working within long sessions and Sonnet/Opus prompt caching is active. A low rate suggests frequent cold-start sessions or short prompts.
Daily billed tokens chart
A 30-day line chart of daily token consumption (input + output, excluding cache). This is the metric that drives billing for seat-based plans.
Each data point is midnight-to-midnight UTC. Use this chart to spot usage spikes or trends before the billing cycle closes.
Billed tokens by user
A horizontal bar chart of total billed tokens per user over the last 30 days. The primary tool for identifying power users who may need a higher-tier seat.
Billed tokens by model
A horizontal bar chart of total billed tokens grouped by model (e.g. claude-sonnet-4-6, claude-opus-4-7). Use this to understand your model distribution and estimate cost.
Usage by user table
A table with one row per active user (last 30 days):
| Column | Description |
|---|---|
| User | Email address |
| Input tokens | Sum of input_tokens |
| Output tokens | Sum of output_tokens |
| Cache reads | Sum of cache_read_tokens |
| Cache writes | Sum of cache_write_tokens |
| Sessions | Distinct session count |
| Turns | Total turns |
| Last active | Most recent event timestamp |
| Tier | Inferred seat tier — see Tier classification below |
Model breakdown table
Token consumption and cache hit rate per model across all time:
| Column | Description |
|---|---|
| Model | Model identifier |
| Users | Distinct users who have used this model |
| Turns | Number of usage events |
| Input tokens | Sum of input_tokens |
| Output tokens | Sum of output_tokens |
| Cache reads | Sum of cache_read_tokens |
| Cache writes | Sum of cache_write_tokens |
| Cache hit % | cache_read / (input + cache_read + cache_write) |
5-hour billing windows
The 5-hour window panel shows usage bucketed into Claude Code's rolling 5-hour billing reset windows. This is the key indicator for seat pressure.
Peak window bar chart — maximum token consumption in any single 5-hour window per user, with the average window size shown alongside the peak. Users with peaks approaching their seat limit are candidates for upgrades.
Window detail table — per-window breakdown showing start time, end time, status (open/closed), total tokens, turn count, and contributing session count.
Tier classification
Each user's row in the usage table includes a Tier badge — an automated estimate of which Claude Code seat tier that user is on.
Tiers are inferred from the distribution of completed 5-hour billing window peaks across the organisation. Users whose peak windows cluster together get the same tier label. The algorithm uses a 1.8× gap ratio to split tiers: if one group's peaks are consistently 1.8× higher than another group's, they are classified as a different tier.
Confidence levels:
| Badge colour | Confidence | Meaning |
|---|---|---|
| Green | High | Confirmed via a 429 rate-limit event (exact tier known) |
| Blue | Medium | Inferred from 10+ completed windows |
| Yellow | Low | Inferred from 3–9 completed windows |
| Grey | Unknown | Fewer than 3 completed windows — not enough data |
Tier inference runs every TIER_INFERENCE_INTERVAL_SECS (default 600 seconds) in the background. Labels are persisted across restarts.
Note: Tier labels are estimates. They reflect usage patterns, not Anthropic's internal account configuration. A user on a Max 5× seat who has never approached their limit may show as a lower tier until enough window data accumulates.
Device keys table
Lists all registered Ed25519 device keys:
| Column | Description |
|---|---|
| User | Email of the user who registered the key |
| Device ID | Hostname reported at registration time |
| Registered | When the key was first registered |
| Last seen | Most recent signed report from this device |
| Status | Active or Revoked |
| Action | Revoke button |
Revoking a device
Click Revoke next to a device to revoke it immediately. The next report from that device receives a 403 key-revoked response. The binary logs the error, clears its local pending queue, and goes silent until re-provisioned.
To re-provision a revoked device, the user deletes two files and restarts a CC session:
rm ~/.claude/ccflux/signing_key
rm ~/.claude/ccflux/key_revoked
rm ~/.claude/ccflux/key_registered # if present
A new keypair is generated and registered automatically on the next turn.
Revoking a device does not revoke the user's refresh token — their other devices continue reporting normally.
Recent events table
The last 50 usage events across all users, showing received timestamp, user, device, session ID, turn index, model, and token counts. Useful for confirming that a newly installed plugin is reporting correctly.
User provisioning
The User provisioning panel is the primary interface for managing refresh tokens. It is visible at the bottom of the dashboard.
Adding a user
Fill in the form at the top of the panel:
| Field | Description |
|---|---|
| The user's email address (must match their Claude Code account) | |
| Division | Optional organisational label (team, department) — for your records only |
| Days valid | How many days before the token expires from the last use (default 365; rolling — resets on each use) |
Click Add user. The next page shows the generated refresh token alongside the endpoint URL, ready to copy and send to the user.
The token is only shown once. If you lose it before sending it to the user, use Reissue to replace it.
Revoking a user
Click Revoke next to an active token to revoke it immediately. The user's binary will receive a 401 response on the next token exchange and stop reporting. Use this when a user leaves the organisation or their token is compromised.
Reissuing a token
Click Reissue to atomically revoke the old token and generate a new one. The new token page shows the replacement token ready to copy. Use this for periodic rotation or when a user reports their token was exposed.
Reissuing preserves the user's usage history — all past events remain associated with their email address.
All timestamps
All timestamps in the dashboard are displayed in your browser's local timezone via inline JavaScript. Raw values in the database are stored as UTC.
Live Demo
Read-only snapshot of the admin dashboard, populated with 15 sample users across four seat tiers and 45 days of usage history. All charts, search filters, and panel expand/collapse work — action buttons are removed.
To export a snapshot from your own instance:
curl -H "Authorization: Bearer $ADMIN_TOKEN" \
"https://ccflux.example.org/admin/?export" \
-o dashboard-snapshot.html
Security Guide
This page covers production hardening requirements and things to avoid.
DOs
Run the receiver behind a TLS-terminating reverse proxy
Bearer tokens are transmitted in plain HTTP headers. Without TLS, any network observer between the user and your server can extract a valid access token and use it to submit forged reports.
Always put the receiver behind nginx, Caddy, or another TLS-terminating proxy in production. Never expose the receiver's plain HTTP port directly.
Set COOKIE_SECURE=1 when serving the admin dashboard over HTTPS
The admin session cookie is HttpOnly; SameSite=Strict by default. When you set COOKIE_SECURE=1, the receiver also adds ; Secure, which prevents the browser from sending the cookie over unencrypted connections.
COOKIE_SECURE=1
This must be set if your admin dashboard is served over HTTPS (i.e. always in production).
Enable REQUIRE_SIGNATURES=1 once all devices have registered
Ed25519 device signing provides replay protection and non-repudiation: each report is signed with a per-device private key that never leaves the user's machine. Once your initial users have set up the plugin, set this permanently:
REQUIRE_SIGNATURES=1
This flag does not need to be toggled for new users. Key registration (POST /register-key) has no signature requirement — it only checks an access token. A new user's binary registers its key on the first turn, then sends all reports signed. If registration is temporarily delayed, reports queue locally and are drained signed once registration succeeds. IT never needs to touch this setting after enabling it.
The only hard failure case is a binary older than v0.1.0, which predates signing support. Confirm existing devices have registered (admin dashboard → Device Keys) before enabling, then leave it on.
Use a strong, unique ADMIN_TOKEN
The admin dashboard token is the only credential protecting access to all usage data. Generate it with:
openssl rand -hex 32
Store it in a secrets manager or environment file with restricted permissions (chmod 600). Rotate it periodically. Do not reuse it for any other purpose.
Use one refresh token per user
Issuing a shared token to a team means all usage is attributed to one identity. You lose per-user visibility, which defeats the purpose of the tool. IT should issue exactly one token per person.
DON'Ts
Don't commit config.json or any file containing tokens
The file at ~/.claude/ccflux/config.json contains a long-lived refresh token. If this file is committed to version control, anyone with repo access can use it to submit usage reports as that user until the token is revoked.
Add it to .gitignore in any project where users might run Claude Code from the project root:
ccflux/config.json
Don't expose /admin/ to the public internet without additional network controls
The admin dashboard is protected by a single bearer token. Consider also restricting it at the network level: firewall the /admin/ path to your office IP range, VPN, or internal network. Defence in depth.
Don't disable TLS (CCFLUX_ALLOW_HTTP=1) in production
The binary has a CCFLUX_ALLOW_HTTP=1 escape hatch for local development. If this variable is set in a production wrapper script, bearer tokens are transmitted in plaintext. Remove it before distributing wrapper scripts to users.
Check your plugin/scripts/ directory before tagging a release:
grep -r CCFLUX_ALLOW_HTTP plugin/scripts/
This should produce no output in a release build.
Don't share the admin token with end users
End users have no reason to access the admin dashboard. The admin token grants full read access to all usage data for all users. Treat it like a root password.
Security architecture notes
Request signing
Every report is signed with the device's Ed25519 private key (~/.claude/ccflux/signing_key, mode 0600). The signing message is:
<body bytes>\n<X-CCFLUX-Timestamp value>
The receiver verifies the signature against the registered public key for the user's email. The X-CCFLUX-Timestamp header must be within 5 minutes of the server clock (replay protection).
Signature errors return 403 with an X-CCFLUX-Error header. See Configuration Reference — 403 error codes for the full list.
Token model
Users hold a long-lived refresh token (issued by IT). The binary exchanges it for a short-lived access token (default 8-hour lifetime) via POST /token. The access token is cached in ~/.claude/ccflux/token_cache.json (mode 0600) and refreshed automatically when within 5 minutes of expiry.
Access tokens are what reach /report. The refresh token never leaves the user's machine.
Rate limiting
The receiver applies a per-token rate limit (default 30 requests per minute) across /report, /token, and /register-key endpoints. This prevents a leaked token from being used to flood the database.
SQL injection prevention
All database queries use sqlx parameterised bindings. There is no string interpolation in SQL queries.
XSS prevention
All user-supplied values rendered in the admin dashboard HTML are escaped through an esc() helper that HTML-encodes &, <, >, ", and '. Stored values like device_id and user_email cannot inject scripts into the dashboard.
Constant-time comparisons
Token comparisons in the receiver use subtle::ConstantTimeEq to prevent timing side-channel attacks. This applies to both the access token verification and the admin token check.
CSRF protection
All admin mutating endpoints require a hidden csrf_token form field verified server-side with a constant-time comparison. This covers device revoke, user provision, user revoke, and token reissue. Cross-origin form submissions cannot forge a valid CSRF token without knowing the ADMIN_TOKEN.
Input length limits
The receiver enforces field length limits at the HTTP boundary before any database or cryptographic work:
| Endpoint | Field | Limit |
|---|---|---|
POST /register-key | public_key | 64 characters |
POST /register-key | device_id | 255 characters |
POST /report | session_id | 64 characters |
POST /report | timestamp_utc, session_start_utc, plugin_version | 64 characters each |
POST /report | model names in models map | 128 characters each |
POST /report | number of models in models map | 20 maximum |
Requests exceeding any limit are rejected with 400 Bad Request before signature verification runs.
Configuration Reference
Receiver environment variables
All receiver configuration is via environment variables. Unrecognised variables are ignored. On startup the receiver prints all resolved values.
| Variable | Default | Description |
|---|---|---|
DATABASE_PATH | ccflux.db | Path to the SQLite database file. Created on first start. |
LISTEN_ADDR | 0.0.0.0:8080 | TCP bind address. In production, bind to 127.0.0.1:8080 and put a reverse proxy in front. |
ACCESS_TOKEN_EXPIRY_SECS | 28800 | How long access tokens live, in seconds. Default is 8 hours. |
REFRESH_TOKEN_ROLLING_DAYS | 90 | Each successful token exchange extends the refresh token's expiry by this many days. Active users never need a new token. |
RATE_LIMIT_PER_MINUTE | 30 | Max requests per access token per minute across /report, /token, and /register-key. Returns 429 when exceeded. |
BODY_LIMIT_KB | 64 | Maximum request body size in kilobytes. Requests larger than this receive 413. |
REQUIRE_SIGNATURES | false | Set to 1 or true to reject /report requests that lack a valid Ed25519 signature. Enable once all devices have registered keys. |
ADMIN_TOKEN | (unset) | Enables the admin dashboard at /admin/. Must be a strong random string. Dashboard is disabled when unset. |
COOKIE_SECURE | false | Set to 1 or true to add ; Secure to the admin session cookie. Set this when serving over HTTPS. |
Plugin binary environment variables
These are set automatically by Claude Code from the plugin's userConfig and do not need to be configured manually.
| Variable | Source | Description |
|---|---|---|
CLAUDE_PLUGIN_OPTION_API_ENDPOINT | Plugin userConfig api_endpoint | The receiver URL for /report. |
CLAUDE_PLUGIN_OPTION_API_TOKEN | Plugin userConfig api_token | The user's long-lived refresh token. |
CLAUDE_PLUGIN_ROOT | Set by Claude Code | Absolute path to the plugin directory. Used by the binary to verify the transcript belongs to this CC instance. |
Binary fallback: config.json
If the CLAUDE_PLUGIN_OPTION_* env vars are empty (e.g. when the plugin settings UI is unavailable), the binary reads:
<data_dir>/ccflux/config.json
Format:
{
"endpoint": "https://ccflux.example.org/report",
"token": "rtok_abc123..."
}
Set this file to mode 0600. If both the env vars and the config file are absent, the binary exits silently — no reporting occurs.
Development-only variables
| Variable | Description |
|---|---|
CCFLUX_ALLOW_HTTP=1 | Allows the binary to POST to http:// endpoints. For local development only. Never set this in production. |
CCFLUX_CA_CERT=<path> | Path to a PEM-encoded CA certificate to add to the TLS trust store. Use this when your receiver is behind a reverse proxy with a self-signed or internal CA cert (e.g. Caddy local CA). The cert is added on top of the bundled Mozilla root CAs — public CAs work without this variable. See TLS with an internal CA in Troubleshooting. |
Plugin userConfig fields
Configured via Claude Code plugin settings UI or CLAUDE_PLUGIN_OPTION_* env vars.
| Field | Type | Description |
|---|---|---|
api_endpoint | string | Your receiver's /report URL, e.g. https://ccflux.example.org/report |
api_token | string (sensitive) | Your personal refresh token. Stored in the system keychain. |
State files
The binary stores state in <data_dir>/ccflux/. For the default CC installation this is ~/.claude/ccflux/.
| File | Permissions | Description |
|---|---|---|
signing_key | 0600 | Ed25519 private key bytes (raw, 64 bytes). Never transmitted. |
key_registered | 0644 | Contains the base64-encoded public key that was successfully registered. Absent means unregistered. |
key_revoked | 0644 | Marker file. Present means the device key was revoked by IT. Binary goes silent while this exists. |
token_cache.json | 0600 | Cached access token and expiry. Refreshed automatically near expiry. |
pending_reports.jsonl | 0644 | Queue of reports generated before the device key was registered. Max 500 entries. |
<session_id>.offset | 0644 | Per-session offset: { "line": N, "turn": N, "session_start": "...", "closed": false } |
activity.log | 0644 | Rolling diagnostic log (~64 KB cap). Records token refreshes, key registrations, reports sent/queued, and errors. Check this first when troubleshooting. |
errors.log | 0644 | Append-only error log. Errors are also mirrored here with an ERROR prefix. |
config.json | 0600 | Optional fallback config (endpoint + token). Preferred: plugin settings UI. |
SQLite schema
CREATE TABLE refresh_tokens (
token TEXT PRIMARY KEY,
email TEXT NOT NULL,
division TEXT,
expires_at TIMESTAMP NOT NULL,
revoked INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE access_tokens (
token TEXT PRIMARY KEY,
refresh_token TEXT NOT NULL REFERENCES refresh_tokens(token),
email TEXT NOT NULL,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE device_keys (
public_key TEXT PRIMARY KEY,
email TEXT NOT NULL,
device_id TEXT,
registered_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_seen_at TIMESTAMP,
revoked INTEGER DEFAULT 0
);
CREATE TABLE usage_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
received_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
user_email TEXT NOT NULL,
user_token TEXT NOT NULL,
session_id TEXT NOT NULL,
turn_index INTEGER NOT NULL,
timestamp_utc TIMESTAMP NOT NULL,
session_start_utc TIMESTAMP,
model TEXT NOT NULL,
input_tokens INTEGER NOT NULL DEFAULT 0,
output_tokens INTEGER NOT NULL DEFAULT 0,
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
cache_write_tokens INTEGER NOT NULL DEFAULT 0,
plugin_version TEXT,
schema_version INTEGER NOT NULL DEFAULT 1,
UNIQUE(session_id, turn_index, model)
);
The UNIQUE(session_id, turn_index, model) constraint handles idempotent retries: duplicate POSTs use INSERT OR IGNORE.
Receiver endpoints
| Method | Path | Auth | Description |
|---|---|---|---|
POST | /token | Refresh token (Bearer) | Exchange a refresh token for a short-lived access token. Returns {"access_token": "...", "expires_at": "..."}. |
POST | /register-key | Access token (Bearer) | Register a device Ed25519 public key. Body: {"public_key": "<base64>", "device_id": "<hostname>"}. |
POST | /report | Access token (Bearer) | Ingest a usage payload. See payload schema below. |
GET | /health | None | Returns {"status":"ok","db":"ok"} or 503 if the DB is unreachable. |
GET | /metrics | None | Prometheus text format counters and gauges. Restrict at the reverse proxy if exposing externally. |
GET | /admin/ | Admin token (cookie or Bearer) | Admin dashboard. Disabled unless ADMIN_TOKEN is set. |
Usage payload schema
The binary POSTs this JSON structure to /report:
{
"schema_version": 1,
"session_id": "uuid",
"user_email": "jsmith@example.org",
"turn_index": 42,
"timestamp_utc": "2026-05-11T04:32:10Z",
"session_start_utc": "2026-05-10T09:15:00Z",
"models": {
"claude-sonnet-4-6": {
"input_tokens": 14200,
"output_tokens": 1800,
"cache_read_tokens": 9400,
"cache_write_tokens": 0
}
},
"plugin_version": "0.1.0"
}
A single turn may contain usage from multiple models (e.g. Sonnet for the main response plus a tool-use round with a different model). Each model gets its own key in models.
Prometheus metrics
Available at GET /metrics. All counters reset on receiver restart (in-memory).
| Metric | Type | Description |
|---|---|---|
ccflux_reports_accepted_total | counter | Usage reports that returned HTTP 200 |
ccflux_reports_auth_rejected_total | counter | Reports rejected due to invalid or expired token |
ccflux_reports_sig_rejected_total | counter | Reports rejected due to signature failure |
ccflux_reports_rate_limited_total | counter | Reports dropped due to rate limiting |
ccflux_token_exchanges_total | counter | Successful refresh → access token exchanges |
ccflux_key_registrations_total | counter | Successful device key registrations |
ccflux_active_access_tokens | gauge | Current number of non-expired access tokens (queries DB) |
403 error codes
When /report returns 403, the X-CCFLUX-Error response header contains a machine-readable code. The binary reads this and responds accordingly.
| Code | Description | Binary behaviour |
|---|---|---|
key-revoked | The device's Ed25519 key has been revoked by IT. | Logs to errors.log, clears pending_reports.jsonl, writes key_revoked marker. Goes silent until re-provisioned. |
timestamp-stale | The X-CCFLUX-Timestamp header is more than 5 minutes old. | Logs to errors.log. For live reports this indicates >5 min clock skew. For queued reports, discards the entry (cannot be resent with a valid timestamp). |
signature-invalid | The Ed25519 signature does not verify. | Logs to errors.log. Retries on the next turn. |
key-not-registered | The public key in the signature header is not in the receiver's database. | Clears the key_registered marker file, queues the payload, retries registration on the next turn. |
signature-required | REQUIRE_SIGNATURES=1 is set and no signature headers were present. | Logged as a generic 403 failure. Upgrade the binary — all current versions sign requests. |
Troubleshooting
Where to look first
The binary logs all significant events — token refresh, key registration, each report sent, and all errors — to:
~/.claude/ccflux/activity.log
For a custom CC config dir (e.g. claude-work):
~/.claude-work/ccflux/activity.log
Check this file first. Each line is timestamped. Errors are prefixed with ERROR and also written to errors.log. The binary always exits 0 — nothing is ever shown in your CC session.
The log is capped at ~64 KB and automatically trims itself, so it will not grow unbounded.
Common errors and fixes
endpoint must use https://
POST failed: endpoint must use https:// — plain HTTP would expose the bearer token; got: http://...
Cause: The configured endpoint uses http:// instead of https://.
Fix: Update your endpoint to use https://. If you're setting up a dev environment and need plain HTTP, set CCFLUX_ALLOW_HTTP=1 in the wrapper script — but never do this in production.
refresh token expired or revoked
refresh token expired or revoked — contact your IT admin to issue a new one
Cause: Your refresh token has been revoked by IT, or it expired due to inactivity (no CC use for longer than REFRESH_TOKEN_ROLLING_DAYS, default 90 days).
Fix: Contact your IT admin to issue a new refresh token. Update it in your plugin settings or config.json.
ccflux: device key revoked — contact your IT admin to re-provision
Cause: Your device's Ed25519 signing key was revoked by IT (e.g. you reported a lost laptop).
Fix: Ask IT to re-provision you. Once they confirm, delete the marker file and regenerate your key:
rm ~/.claude/ccflux/key_revoked
rm ~/.claude/ccflux/signing_key
rm ~/.claude/ccflux/key_registered # if present
On the next CC session, a new keypair is generated and registered automatically.
ccflux: request rejected as timestamp-stale (clock skew?)
Cause: The X-CCFLUX-Timestamp header value is more than 5 minutes from the server's clock. This means your machine's system clock is significantly off.
Fix: Sync your system clock:
# Linux
sudo timedatectl set-ntp true
# macOS
sudo sntp -sS time.apple.com
If clock skew is persistent, check your NTP configuration.
ccflux: signature-invalid — this is unexpected, retrying next turn
Cause: The Ed25519 signature was rejected despite the key being registered. This is unusual and typically indicates a transient issue.
Fix: Usually resolves itself on the next turn. If it persists across many turns, delete and regenerate the signing key:
rm ~/.claude/ccflux/signing_key
rm ~/.claude/ccflux/key_registered
The new key is generated and registered on the next turn.
HTTP 401 errors
Cause: The access token is invalid or expired, or the bearer token in the request is malformed.
Fix: Check that your refresh token in plugin settings (or config.json) is correct. Delete the token cache to force a fresh exchange:
rm ~/.claude/ccflux/token_cache.json
If the error persists, the refresh token itself may be revoked — contact IT.
No data in the admin dashboard
Symptom: You've installed the plugin and completed a few CC turns, but your email doesn't appear in the dashboard.
Steps to diagnose:
-
Check
activity.logfirst. If it showsno credentials — create ..., the config file wasn't found or is in the wrong location. If the log doesn't exist at all, the hooks never fired — see below. -
Did you reload plugins and start a fresh session? Plugin hooks only apply to sessions started after the plugin is loaded. If you skipped this step:
- Run
/plugins reloadin your current CC session - Exit that session
- Start a new session
- Run
-
Confirm the plugin is installed in the right CC config directory. If you use a custom alias, make sure the plugin is in the matching plugins directory.
-
Confirm the endpoint is reachable:
curl https://ccflux.example.org/health # expect: {"status":"ok","db":"ok"} -
Check whether an offset file was created:
ls ~/.claude/ccflux/*.offsetIf no
.offsetfile exists, theSessionStarthook may not have fired. Check that the plugin'shooks.jsonis present and the scripts are executable:ls -la ~/.claude/plugins/ccflux/scripts/ -
Check the
pending_reports.jsonlqueue:wc -l ~/.claude/ccflux/pending_reports.jsonlIf this has entries and keeps growing, reports are being queued but not sent. The device key is probably not registered yet — check
activity.logfor registration errors.
pending_reports.jsonl growing indefinitely
Cause: The device key failed to register (network issue on first session, or the receiver was unreachable).
What happens: Reports queue up locally (max 500 entries; oldest are dropped when full). On each successful live report, one queued entry is drained. Once the key registers, the queue drains automatically.
Fix:
- Confirm the receiver is reachable:
curl https://ccflux.example.org/health - Check
activity.logfor registration errors - If the key is stuck, try deleting
key_registered(if it exists) to force a re-registration attempt:rm ~/.claude/ccflux/key_registered
TLS with an internal CA
Symptom: activity.log shows:
ERROR POST failed: tls connection init failed: invalid peer certificate: UnknownIssuer
ERROR POST failed: tls connection init failed: invalid peer certificate: BadSignature
Cause: The binary uses Mozilla's bundled root CAs (webpki-roots). If your receiver sits behind a reverse proxy with a self-signed or internal CA certificate (e.g. Caddy's local CA, a corporate PKI), the binary won't trust it by default.
Fix: Set CCFLUX_CA_CERT to the path of the CA certificate (PEM format) before launching Claude Code:
# Linux / macOS / Git Bash on Windows
export CCFLUX_CA_CERT="/path/to/intermediate.crt"
# Windows PowerShell
$env:CCFLUX_CA_CERT = "C:\path\to\intermediate.crt"
Which cert to use: Use the certificate that directly signed your server's TLS certificate. For Caddy's local CA this is the intermediate cert, not the root:
# Caddy running as root — intermediate is here:
sudo cat /root/.local/share/caddy/pki/authorities/local/intermediate.crt
# Caddy running as your user:
cat ~/.local/share/caddy/pki/authorities/local/intermediate.crt
Verify the chain is correct before setting the variable:
openssl s_client -connect your-host:443 \
-CAfile /path/to/intermediate.crt \
-partial_chain 2>&1 | grep "Verify return"
# Should print: Verify return code: 0 (ok)
If openssl returns code 30 (authority and subject key identifier mismatch), Caddy may have regenerated its PKI after issuing the current server cert. Wipe Caddy's data directory and restart it to resync:
sudo rm -rf /root/.local/share/caddy/ # adjust path if not running as root
# restart Caddy
Note:
CCFLUX_CA_CERTis only needed for dev/test setups with self-signed certs. In production, use a certificate from a public CA (Let's Encrypt, etc.) and no extra configuration is required.
Windows / PowerShell issues
If running natively on Windows (not WSL), the .ps1 wrapper scripts must be permitted to execute:
Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned
If the binary produces no errors but no data appears in the dashboard, check whether the plugin/bin/ccflux-windows-x86_64.exe binary is present. If it's missing, download the release and copy it into plugin/bin/.
Verifying a specific turn was reported
To confirm a specific session's data reached the receiver:
SELECT * FROM usage_events
WHERE user_email = 'jsmith@example.org'
ORDER BY received_at DESC
LIMIT 10;
The received_at column is when the receiver stored the event. timestamp_utc is when the turn occurred on the user's machine.
Known limitations
SessionEnd unreliability
The SessionEnd hook is killed by Claude Code before asynchronous work can complete. The nohup/disown pattern in session_end.sh mitigates this but is not guaranteed. The Stop per-turn hook is the primary reporting path — SessionEnd is best-effort for the final turn of a session.
In practice, if a user ends their session abruptly (closes the terminal), the last turn may be reported late or not at all. All previous turns are unaffected.
SIGKILL crashes
If Claude Code is killed with SIGKILL (e.g. kill -9, OOM killer), no hooks fire. At most one in-flight turn is lost. The offset file is not updated, so the next session will re-read from the last successful position — no duplicate reporting.
JSONL schema instability
Claude Code's transcript format is undocumented. If the parser starts returning 0 tokens for all turns, the sessionId or usage field names may have changed in a CC update. Check activity.log for unexpected-structure warnings, then inspect a recent transcript file:
# Find a recent session transcript
ls -lt ~/.claude/projects/*/ | head -5
# Check the first assistant entry
grep '"type":"assistant"' ~/.claude/projects/<hash>/<session>.jsonl | head -1 | python3 -m json.tool
Compare the field names against what the CLAUDE.md documents as the confirmed schema.