中文
See Dollar - for agents
See Dollar is an AI token speedtest: submit your coding-agent token usage for the last 30 days, get a verifiable result, a public rank, and a shareable usage portrait.
Human? The step-by-step guide is at /how (Chinese).
Schema version 0.3.0.
Machine-readable: /.well-known/seedollar.json -
compressed: /llms.txt
The normative parts below (endpoint table, field names, hash scope, error codes) are English only. They are rendered from the same source as /llms.txt and /.well-known/seedollar.json - a second translated copy would be a second spec to keep in sync, and spec drift is exactly what this project has been bitten by.
Submit a result
- Collect on your human's machine: npx ccusage@20.0.19 daily --json --by-agent > usage.json . This is not fully offline: the first run downloads ccusage from npm, and ccusage may fetch a public model price list while pricing; neither request carries usage data. usage.json stays on the machine: never upload, paste, print or edit it.
- Scrub deterministically: download /assets/seedollar-scrub.mjs (exact versioned URL and sha256 in "The deterministic scrubber" below), check its sha256, then run node seedollar-scrub.mjs usage.json -o seedollar-report.json. It makes no network calls and writes a schema 0.3.0 report with evidence_source "local_scan". Do not write, convert or edit the report yourself. If seedollar-scrub.mjs exits non-zero, no report was written: stop and show your human its message. Never open, print, edit, filter or rewrite usage.json, and never write a report by hand - that would be you changing the numbers. Your human decides whether to re-collect with the pinned ccusage command.
- Show your human the summary the scrubber printed and the report file, and wait for an explicit OK. No OK, no submit.
- Get a ticket. If your human gave you one from /me (test_id + upload_token), use it: the result then lands on their existing identity. Otherwise POST /v1/tests -> { test_id, upload_token, expires_in } - this creates a new anonymous identity each time. A ticket expires 600 seconds after it is issued, so get your own after the OK. A ticket from /me started counting when your human pressed the button: if it has expired by the OK, ask them for a new one.
- POST /v1/tests/{test_id}/report with header Authorization: Bearer <upload_token> and the report file as the body, byte-for-byte (curl --data-binary @seedollar-report.json). Any content_sha256 the response (or later the result page) shows must equal the one the scrubber printed.
- If step 5 answered status "not_ranked", stop here: the report is stored but nothing in it is rankable, so no rank will ever appear. Otherwise poll GET /v1/tests/{test_id} until rank_status is "ready" (or "failed", or "withheld" = stored but kept off the leaderboard, see rank_withheld_reason). It starts as "pending". Stop polling as well if that response has status "not_ranked". The same response carries facets (per-day rankable volume for the 30 calendar days, active days, model count, five-channel breakdown) frozen at submission.
- The result is public at /t/{test_id} (the result_url of the ticket). Share that one: it is pinned to this submission and never changes. /r/{public_id} shows the subject current result and moves on every retest.
# 1 - collect on your human's machine (npx downloads ccusage on first run)
npx ccusage@20.0.19 daily --json --by-agent > usage.json
# 2 - scrub deterministically (no network; never edit what it writes; if it exits non-zero, stop and show your human)
# macOS / Linux / Git Bash:
curl -fsSL -o seedollar-scrub.mjs "https://seedollar.studio/assets/seedollar-scrub.mjs?v=dcef6944"
# Windows cmd / PowerShell (in PowerShell 5.1 a bare curl is Invoke-WebRequest):
curl.exe -fsSL -o seedollar-scrub.mjs "https://seedollar.studio/assets/seedollar-scrub.mjs?v=dcef6944"
node -e "const h=require('crypto').createHash('sha256').update(require('fs').readFileSync('seedollar-scrub.mjs')).digest('hex');if(h==='dcef6944d9e57d509af0edb2fba913a66c1ebe53c9a18720dafbd3d4564d0862'){console.log('sha256 ok')}else{console.error('sha256 MISMATCH '+h);process.exit(1)}"
node seedollar-scrub.mjs usage.json -o seedollar-report.json
# 3 - show your human the summary and the report file; go on only after an explicit OK
# 4 - a ticket: use the one your human gave you from /me, otherwise
curl -sX POST https://seedollar.studio/v1/tests
# 5 - submit the file byte-for-byte (TOKEN and TEST_ID come from step 4)
curl -sX POST https://seedollar.studio/v1/tests/$TEST_ID/report \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
--data-binary @seedollar-report.json
# 6 - poll until rank_status is ready (or failed / withheld)
curl -s https://seedollar.studio/v1/tests/$TEST_ID
The upload_token is one-time, expires after 600 seconds, and is scoped to a single action. It cannot read an identity or modify anything. See Dollar never needs a long-lived API key.
The deterministic scrubber
Never write, convert or edit the report yourself. Run this file: it is the same code as the web page scrubber, and every number in the report comes from ccusage through it.
| version | seedollar-scrub 0.3.0 |
| url | https://seedollar.studio/assets/seedollar-scrub.mjs?v=dcef6944 |
| sha256 | dcef6944d9e57d509af0edb2fba913a66c1ebe53c9a18720dafbd3d4564d0862 |
| download | curl -fsSL -o seedollar-scrub.mjs "https://seedollar.studio/assets/seedollar-scrub.mjs?v=dcef6944"
curl.exe -fsSL -o seedollar-scrub.mjs "https://seedollar.studio/assets/seedollar-scrub.mjs?v=dcef6944" (Windows cmd / PowerShell) |
| verify | node -e "const h=require('crypto').createHash('sha256').update(require('fs').readFileSync('seedollar-scrub.mjs')).digest('hex');if(h==='dcef6944d9e57d509af0edb2fba913a66c1ebe53c9a18720dafbd3d4564d0862'){console.log('sha256 ok')}else{console.error('sha256 MISMATCH '+h);process.exit(1)}"
sha256sum seedollar-scrub.mjs · shasum -a 256 seedollar-scrub.mjs · certutil -hashfile seedollar-scrub.mjs SHA256 · Get-FileHash seedollar-scrub.mjs -Algorithm SHA256 Compare the hex they print with sha256 above. Get-FileHash prints it in uppercase: compare ignoring case. |
| run | node seedollar-scrub.mjs usage.json -o seedollar-report.json |
- One file, Node 20+, zero dependencies, no network calls. It is the same code the web page runs when a human drops raw ccusage output on it.
- It reads usage.json, writes the report to -o seedollar-report.json (or stdout) and prints a summary for your human on stderr. Apart from report_id (random on every run, not part of content_sha256), the report is byte-for-byte the plain text the web page shows before submitting, for the same input on the same UTC day; content_sha256 is identical.
- The summary lists everything the report holds, including ccusage's own cost estimate client_cost_usd (on every row and in totals), the random report_id and the collector / scrubber versions. Show it to your human as printed.
- It never submits anything and never touches the network: submitting is a separate step that you run only after your human says OK.
- If seedollar-scrub.mjs exits non-zero, no report was written: stop and show your human its message. Never open, print, edit, filter or rewrite usage.json, and never write a report by hand - that would be you changing the numbers. Your human decides whether to re-collect with the pinned ccusage command.
- Do not run it if the sha256 does not match. Re-read this file (a deploy updates the file and the published sha256 together) and download again.
- The ?v= in the URL is only a cache key - the server answers an old v with the current file. The sha256 is the lock.
- What the sha256 protects against: a truncated download, a cached copy of another version, a copy from somewhere else. It does NOT protect against this site itself being compromised; for that the file is unminified and readable end to end.
- Windows PowerShell 5.1: its > writes UTF-16, so use -o for the report. The scrubber reads a UTF-16 usage.json fine. Download with the curl.exe line (download_windows in /.well-known/seedollar.json): in PowerShell 5.1 a bare curl is Invoke-WebRequest and the plain download line fails.
- This removes transcription mistakes; it is not a verification of the numbers. The server cannot tell an edited file from an untouched one if the hash was recomputed - so your human checks the summary, and the content_sha256 in the response and on the result page must equal the one the scrubber printed.
A ticket from your human
- Your human can give you a ticket for their existing identity: on /me they press "give my agent a ticket" and paste you a short text with test_id and upload_token.
- Use it instead of POST /v1/tests. The result then lands on their identity (submission_path is still "agent"); calling POST /v1/tests yourself would create a second, separate identity.
- It is the same kind of ticket: single use, can only submit that one test_id, and expires 600 seconds after your human pressed the button - not after you read it. Do the collect / scrub / OK steps first; if it expired (401 expired), ask your human for a new one.
What your human was told to paste
If a human pasted one of these at you, this is the full text of it.
Go to https://seedollar.studio/llms.txt and follow it to submit my last 30 days of coding-agent token usage to See Dollar.
Read https://seedollar.studio/llms.txt and submit my last 30 days of coding-agent token usage to See Dollar.
Every number must come from the tools, never from you:
1. run: npx ccusage@20.0.19 daily --json --by-agent > usage.json
2. download seedollar-scrub.mjs from the URL in llms.txt, check its sha256, then run: node seedollar-scrub.mjs usage.json -o seedollar-report.json
3. show me the summary it prints and the report file, then wait for my OK;
4. after I say OK, submit the file byte-for-byte - do not edit, round or retype any number.
If the scrubber exits non-zero, stop and show me its message - never open, edit or rewrite usage.json and never write a report yourself.
Never upload or paste usage.json anywhere. If the server answers 400, show me the issues and re-run the scrubber - do not patch the JSON.
The /me button fills in the three values; this is the same text with placeholders.
Read https://seedollar.studio/llms.txt and submit my last 30 days of coding-agent token usage to See Dollar with this ticket - it keeps my existing See Dollar identity:
test_id: <test_id>
upload_token: <upload_token>
(single use, valid until <expires_at, UTC> - its 10 minutes started when I pressed the button)
Do NOT call POST /v1/tests: that would put the result on a second identity. Do steps 1-3 first and use the ticket only after my OK; if it has expired by then, ask me for a new one.
Every number must come from the tools, never from you:
1. run: npx ccusage@20.0.19 daily --json --by-agent > usage.json
2. download seedollar-scrub.mjs from the URL in llms.txt, check its sha256, then run: node seedollar-scrub.mjs usage.json -o seedollar-report.json
3. show me the summary it prints and the report file, then wait for my OK;
4. after I say OK, POST the file byte-for-byte to https://seedollar.studio/v1/tests/<test_id>/report with header Authorization: Bearer <the upload_token above>.
If the scrubber exits non-zero, stop and show me its message - never open, edit or rewrite usage.json and never write a report yourself.
Never upload or paste usage.json anywhere. If the server answers 400, show me the issues and re-run the scrubber - do not patch the JSON.
Endpoints
| Method | Path | Auth | What it does | Agent |
POST |
/v1/tests |
none or Authorization: Dollar <client_token> (Dollar desktop app only) |
Start a speedtest. Returns a one-time upload_token and (for new visitors) creates an anonymous subject.- No request body required.
- Response: { test_id, upload_token, expires_in, schema_version, result_url, subject_url, subject_public_id, subject_created }. Share result_url (/t/, pinned); subject_url (/r/) moves on every retest.
- Browsers additionally receive an httpOnly subject cookie. Agents do not, and must not ask a user for one.
- Dollar (desktop app) sends Authorization: Dollar <client_token> and gets a ticket for the subject that issued that token; no cookie is set.
- Agents: without a credential this creates a NEW anonymous identity every time. If your human already has a See Dollar identity, ask them for a ticket from /me (POST /v1/subjects/me/agent-ticket) and skip this call - the result then lands on their existing identity.
|
yes |
POST |
/v1/tests/{test_id}/report |
Authorization: Bearer <upload_token> or browser subject cookie or Authorization: Dollar <client_token> (Dollar desktop app only) |
Submit a usage report for this test. This is the only write endpoint an agent ever needs.- Header: Authorization: Bearer <upload_token>. This is the path agents use.
- Body: a schema 0.3.0 report object (see fields below). Send the report and nothing else.
- Agents: the body is the file seedollar-scrub.mjs wrote, sent byte-for-byte (curl --data-binary @seedollar-report.json) after your human said OK. Never build, convert or edit the JSON yourself.
- An accepted / unchanged response carries content_sha256: it must equal the one seedollar-scrub.mjs printed. If it does not, tell your human.
- evidence_source must be "local_scan" for now. The other values in the schema are planned but cannot be stored yet; they get 400 unsupported_evidence_source.
- Response (202): status is "accepted" or "unchanged" (same content resubmitted) with the rank fields, or "not_ranked" when no row in the window is rankable (stored, not on the leaderboard; nothing to poll).
- The token is scoped to this test_id and expires after 600 seconds. It cannot read or modify anything.
- A rejected report does NOT burn the token: re-run seedollar-scrub.mjs and resubmit its output with the same ticket within the 600 seconds (independent implementations: fix your implementation).
- Do NOT put submission_path in the report. It does not exist in the schema. The server records how you got here (Bearer token => agent, browser cookie => web_upload, Dollar client token => dollar) from your credential, never from the body.
- A stored report can still be kept off the leaderboard: the response then has status "accepted", rank_status "withheld", and rank_withheld_reason + rank_withheld_message (machine code + human sentence; both are null otherwise). Reasons: "duplicate_of_existing_subject" (the same content_sha256 is already the current result of another identity; the first submission counts, so do not resubmit the same file from a fresh ticket/identity), "quarantined" (outlier review pending; enters the leaderboard once cleared), "quarantine_rejected".
|
yes |
GET |
/v1/tests/{test_id} |
none |
Poll a test for its result. Returns rank_status = pending until the rank has been computed.- status first: "awaiting_report" = the ticket exists but no report was submitted yet; "not_ranked" = the report is stored but had no rankable rows, so there will never be a rank - STOP polling; "ready_or_pending" = a result exists, branch on rank_status.
- rank_status is the ONLY field you branch on for the rank. Never infer state from rank_at_ingest being null.
- rank_status is one of pending | ready | failed | withheld. Stop polling on ready, failed or withheld (withheld = stored and visible to you, not on the leaderboard; see rank_withheld_reason / rank_withheld_message).
- rank_at_ingest is null while rank_status is pending, failed or withheld. The field is always present.
- stale (read-only): true when window_to is more than 3 days before today (UTC). A result covers the 30 days ending on its window_to, not "the last 30 days"; it is frozen at submission and does not roll. Stale results stay on the leaderboard, marked. Submit a fresh report to refresh.
- facets (frozen at submission): by_day[30], active_days, models, breakdown, rankable_channels. null for results older than facets.
|
yes |
GET |
/v1/leaderboard |
none |
The public 30-day token leaderboard.- Query: window (only "30d" is valid; anything else is a 400), limit (default 50, max 200), offset.
- Query: public_id=<10 chars> returns just that subject’s row, including its rank.
- Query: region=<ISO 3166-1 alpha-2> keeps only subjects that self-declared that region. Ranks stay global (they are not re-numbered); an unknown code is a 400 bad_region.
- Ranking metric is rankable_token_volume_v1. evidence_source and submission_path are shown but never sorted on.
- Each entry carries window_from / window_to (the 30 days that score covers) and stale (true when window_to is more than 3 days before today, UTC). Stale entries are NOT removed or re-ranked; they are only marked.
- One report counts once: if the same content_sha256 is the current result of several identities, only the earliest submission is on the leaderboard. Withheld results (duplicate / under outlier review) are not on it.
|
yes |
GET |
/v1/leaderboard/regions |
none |
How many leaderboard entries self-declared each region. Aggregate only; small counts are masked.- Query: window (only "30d" is valid; anything else is a 400).
- Counts the same set as /v1/leaderboard (withheld, retired and duplicate-content results are not on it), so total equals /v1/stats subjects_ranked.
- Response: { window, total, regions: [{ region, count }], unknown, generated_at }. count is a number, or "lt3" when fewer than 3 entries declared that region. Numeric regions come first (count desc, then code); "lt3" regions follow in code order, so the order does not reveal 1 vs 2.
- unknown = entries that declared no region: "lt10" when fewer than 10, otherwise { count, approx: true } with count rounded DOWN to a multiple of 10.
- region is the self-declared ISO 3166-1 alpha-2 code set on /me. It is never inferred from IP or headers, and entries without one are never placed on a map.
- Public and cacheable: Cache-Control public, max-age=60.
|
yes |
GET |
/v1/stats |
none |
Site-wide counters for the 30-day window.- Returns subjects_ranked and total_rankable_token_volume_v1 over the same comparison set the leaderboard uses.
- total_rankable_token_volume_v1 is the sum of the results currently on the leaderboard. Each result covers the 30 days ending on its own window_to (some may be stale), so the total is NOT "tokens in the last 30 days".
- Retired subjects are excluded, so these counters never double-count one person who started fresh.
|
yes |
GET |
/v1/hit/stats |
none |
Anonymous share-funnel counters: how many times each whitelisted share / page event happened per UTC day.- Query: days (default 30, max 90). Anything else is a 400 bad_days.
- Response: { from, to, days: [{ day, counts: { <event>: n } }], totals, events, note, generated_at }. Every day in the range is listed; a day with no events has counts {}.
- Counts are clicks / page views, not people. Nothing behind them is stored: no id, IP, user agent or cookie. They are written by See Dollar pages when a visitor shares or follows a shared link; agents have nothing to write here.
- Public and cacheable: Cache-Control public, max-age=60.
|
yes |
GET |
/v1/subjects/me |
browser subject cookie or Authorization: Dollar <client_token> (Dollar desktop app only) |
Your own current result and rank. Requires the browser subject cookie (or a Dollar client token).- An upload_token is always rejected here with 401. A ticket can write one score; it can never read an identity.
- Response also carries rank_delta: previous rank_at_ingest minus current, only when both revisions are ready; otherwise null. Dollar shows it as the arrow next to MY SEE DOLLAR POSITION.
- Agents: use GET /v1/leaderboard?public_id=<your public id> instead. It needs no credential.
- current carries stale and rank_withheld_reason / rank_withheld_message like GET /v1/tests/{test_id}; leaderboard_entry is null while the current result is withheld.
|
no - needs a browser cookie |
POST |
/v1/subjects/me/reset |
browser subject cookie |
NOT ME / START FRESH. Retires the current subject and clears the cookie.- There is no account and no recovery. Once retired, the old public_id cannot be recovered.
- Already-shared result links keep working. The retired score leaves the leaderboard.
|
no - needs a browser cookie |
POST |
/v1/subjects/me/profile |
browser subject cookie |
Set or clear your display name and self-declared region. Browser cookie only.- Body fields are independent and optional: { display_name: "Sam_L" | null, region: "SG" | null }. Absent field = unchanged; null = clear.
- display_name: 3-20 chars of [A-Za-z0-9_-], globally unique (case-insensitive), reserved words rejected. Display only — it never becomes a credential and never affects ranking.
- region: self-declared ISO 3166-1 alpha-2. Never inferred from IP or headers; display and filtering only.
- 409 name_taken when the name is already in use. Clearing a name releases it for anyone.
|
no - needs a browser cookie |
POST |
/v1/subjects/me/agent-ticket |
browser subject cookie |
Give my agent a ticket: issue ONE upload ticket bound to this subject, for the human to paste to their agent. Browser cookie only.- No request body. Response: { test_id, upload_token, expires_in, expires_at, schema_version, result_url, subject_url, subject_public_id }. The upload_token is shown once.
- Same ticket as POST /v1/tests: single use, expires after 600 seconds, scoped to POST /v1/tests/{test_id}/report only. It cannot read the identity or issue anything. Issuing one does not cancel earlier tickets; each simply expires.
- The agent submits with Authorization: Bearer <upload_token>. The server records submission_path "agent" and puts the result on the subject that issued the ticket, so one person keeps one identity across browser and agent.
- Never creates a subject: without a valid cookie it is 401 no_credential. An upload_token or a Dollar client token is always 401 here.
- Agents: you cannot call this. If you need a ticket for an existing identity, ask your human to press the button on /me and paste you the text it shows.
|
no - needs a browser cookie |
POST |
/v1/subjects/me/client-tokens |
browser subject cookie |
Connect Dollar: issue a long-lived, revocable client token for this subject. Browser cookie only.- Body: { kind: "dollar" }. Response: { client_token, kind, created_at }. The plaintext token is shown exactly once.
- Paste it into Dollar. Dollar then sends Authorization: Dollar <client_token> to POST /v1/tests, POST /v1/tests/{test_id}/report and GET /v1/subjects/me.
- A client token can never retire the subject or issue further tokens. Revoke at any time with /v1/subjects/me/client-tokens/revoke.
|
no - needs a browser cookie |
POST |
/v1/subjects/me/client-tokens/revoke |
browser subject cookie |
Disconnect Dollar: revoke every client token of this subject. Browser cookie only.- Body: { kind: "dollar" }. Revoked tokens get 401 with reason "revoked" immediately.
|
no - needs a browser cookie |
GET |
/t/{test_id} |
none |
Public page for ONE submission, pinned to the revision it created. This is the default share URL.- Immutable: it always shows the result of that submission, at the content_sha256 it was made with.
- Share this one. /r/{public_id} shows the subject CURRENT result and changes every time they retest.
|
yes |
GET |
/r/{public_id} |
none |
Current result for a subject, by its 10-character public id. Changes on every retest.- Accepts both 7F2AC913K5 and the display form 7F2AC-913K5.
- This is the "see where I rank now" entry point, NOT the share URL. For sharing use /t/{test_id}.
- JSON: current carries window_from / window_to, stale, rank_status (incl. withheld) and rank_withheld_reason / rank_withheld_message.
|
yes |
Report fields
- schema_version is "0.3.0".
- generated_at and snapshot.taken_at are DATE ONLY, "YYYY-MM-DD" in UTC. Clock times are rejected: they are not collected.
- rows[].model_raw starts with one of these vendor prefixes: claude- gpt- o[0-9] gemini- codex- deepseek qwen llama mistral grok kimi glm (o[0-9] means the letter o followed by a digit; matching ignores case; one routing namespace such as "anthropic.", "openai." or "global.anthropic." may come first). seedollar-scrub.mjs folds every other model name into "custom" - private model names are not sent - and rows that then share date, tool and "custom" come out of it as one row carrying the summed tokens. Independent implementations are held to the same rule.
- Older 0.2.0 reports (second-precision timestamps, unfolded model names) are still accepted.
The schema is strict - any key not listed here is rejected:
schema_version, report_id, generated_at, evidence_source, snapshot, provenance, tools_detected, rows, totals, taken_at, window, kind, start, end, content_sha256, collector, collector_version, collector_command, scrubber_version, generator, generator_version, estimation_method, date, tool, model_raw, tokens, sessions, client_cost_usd, input, output, cache_read, cache_creation, reasoning
snapshot.content_sha256
Agents following this file do not compute it: seedollar-scrub.mjs does. This section is for people maintaining an independent, reviewed implementation (CI tooling, a desktop client).
Required, and the server recomputes it. The server recomputes it and rejects the report on mismatch (rule V12). A guessed or omitted value always fails.
content_sha256 = lowercase_hex(SHA-256(UTF8(JCS(hash_scope))))
The hash scope is a whitelist - exactly these keys, nothing else:
domain: "seedollar-snapshot-v1" (a literal member of the object, not a byte prefix)schema_version: report.schema_versionevidence_source: report.evidence_sourcewindow: kind = snapshot.window.kind, from = snapshot.window.start, to = snapshot.window.end (NOTE the rename: start->from, end->to)tools_detected: report.tools_detected, sorted by Unicode code pointrows: report.rows, sorted by (date, tool, model_raw) code point; each row keeps ONLY date, tool, model_raw, sessions and tokens{input,output,cache_read,cache_creation,reasoning}
Excluded on purpose - hashing the whole report will not match:
- totals — a pure function of rows, redundant
- rows[].client_cost_usd — floats differ across implementations
- report_id — a fresh uuid each time; including it would break idempotency
- generated_at and snapshot.taken_at — clocks are not content
- the entire provenance block — packaging metadata, not content
- snapshot.content_sha256 itself — anti-recursion
- RFC 8785 JCS: object keys sorted by Unicode code point at every level, no whitespace, no trailing newline.
- UTF-8, no BOM. Integers only inside tokens; null is allowed only at rows[].tokens.reasoning and rows[].sessions.
Self-check for independent implementations only (agents using seedollar-scrub.mjs skip this). For this report subset:
{
"schema_version": "0.3.0",
"evidence_source": "local_scan",
"snapshot": {
"window": {
"kind": "30d",
"start": "2026-07-14",
"end": "2026-08-12"
}
},
"tools_detected": [
"codex-cli",
"claude-code"
],
"rows": [
{
"date": "2026-08-12",
"tool": "codex-cli",
"model_raw": "gpt-5.5",
"tokens": {
"input": 5,
"output": 6,
"cache_read": 7,
"cache_creation": 8,
"reasoning": null
},
"sessions": null
},
{
"date": "2026-08-11",
"tool": "claude-code",
"model_raw": "claude-opus-5",
"tokens": {
"input": 1,
"output": 2,
"cache_read": 3,
"cache_creation": 4,
"reasoning": null
},
"sessions": null
}
]
}
the canonical JSON is:
{"domain":"seedollar-snapshot-v1","evidence_source":"local_scan","rows":[{"date":"2026-08-11","model_raw":"claude-opus-5","sessions":null,"tokens":{"cache_creation":4,"cache_read":3,"input":1,"output":2,"reasoning":null},"tool":"claude-code"},{"date":"2026-08-12","model_raw":"gpt-5.5","sessions":null,"tokens":{"cache_creation":8,"cache_read":7,"input":5,"output":6,"reasoning":null},"tool":"codex-cli"}],"schema_version":"0.3.0","tools_detected":["claude-code","codex-cli"],"window":{"from":"2026-07-14","kind":"30d","to":"2026-08-12"}}
and content_sha256 must be 9e64e7b84c83f8f61d252b40cb12e32aa54576068ef54d0f7b38b07ba4ab478a.
Independent implementations: if yours does not reproduce that hex, fix your implementation before submitting - the server will reject the report. Agents: never edit a report to make a hash match; re-run the scrubber.
When a submit fails
| HTTP | error | when | fix |
| 400 | schema | A field is missing, has the wrong type, or breaks a cross-field rule. This includes window.kind="30d" must span exactly 30 calendar days inclusive, and every row.date must fall inside the window. issues[] names each violation with its path. | If you used seedollar-scrub.mjs: do not edit the file. Re-download the scrubber (check the sha256 above), re-run it, show your human the new summary and POST the new file with the same ticket. Independent implementations: fix what issues[] lists. A schema failure does NOT consume the upload_token. |
| 400 | validation | The report parsed but a validator rule rejected it. BY FAR the most common one: your content_sha256 does not match what the server recomputed (rule V12) - issues[0] then shows both hexes. NOTE: this is reported as error="validation", NOT as "content_sha256_mismatch". | If you used seedollar-scrub.mjs, the file was changed after the scrubber wrote it: re-run the scrubber and submit its output unchanged. Independent implementations: reproduce the self-check vector until your hex matches. Also does NOT consume the ticket. |
| 401 | unauthorized | The reason field says which: no_credential (no Authorization header), already_used (the ticket was already consumed by a successful submit), expired (older than the TTL), not_your_test. | If your human gave you the ticket from /me, ask them for a new one - do not call POST /v1/tests, that is a different identity. Otherwise POST /v1/tests for a fresh ticket. Tickets are single-use and short-lived. |
| 429 | rate_limited | Too many POST /v1/tests per hour from one IP without any credential. | Wait for the next hour. Reusing a credential you already hold is not rate limited. |
| 413 | payload_too_large | The request body is larger than 2 MiB. Checked while streaming, before the body is parsed. | A 30-day report is a few tens of KB. A body this large is almost certainly not a report - check what you are sending. |
| 400 | unsupported_evidence_source | The report parsed, but its evidence_source is not one the server can store yet. Accepted today: local_scan. agent_generated and user_estimated are planned (the schema already has them) but cannot be stored yet. | Submit the report seedollar-scrub.mjs wrote (it is always local_scan, rows produced by the collector named in provenance; see the ranking scope note below). Does NOT consume the ticket - resubmit the right file with the same upload_token. |
| 400 | unsupported_window | A window= query value other than 30d. | Only 30d exists. The server never silently falls back to it. |
Rows outside the server window do not fail the submit - they are stored and simply not counted. A report entirely outside it returns 202 with rankable_token_volume_v1 = 0, not an error. The ranking window is anchored to the real today, not to the window declared in your report.
Only rows that match a semantics profile count toward rankable_token_volume_v1: tool claude-code, codex-cli, gemini-cli [draft profile: results are labelled PROFILE: DRAFT] collected by ccusage >=20.0.0 <21.0.0 (`ccusage daily --json --by-agent`). Rows from any other tool, collector or collector version are stored but not counted. If no row inside the 30-day window counts, the submit returns 202 with status "not_ranked" and rankable_token_volume_v1 = null.
Do not
- Never write, convert, transcribe, round or edit any number in a report. The report is the file seedollar-scrub.mjs wrote; submit it byte-for-byte. If a submit is rejected, re-run the scrubber - never patch the JSON.
- Never upload, paste or send the raw ccusage output (usage.json) anywhere - not to See Dollar, not into a chat. Only the scrubbed report leaves the machine.
- Never open, print, edit, filter or rewrite usage.json yourself: only ccusage writes it and only seedollar-scrub.mjs reads it (printing it would send the raw output to your model provider). If seedollar-scrub.mjs exits non-zero, stop and show your human its message; never write a report by hand.
- Never submit before your human has seen the scrubber summary and the report file and said OK.
- If your human gave you a ticket from /me, do not call POST /v1/tests: that would put the result on a second identity. If the ticket expired, ask them for a new one.
- Never ask a human to paste a long-lived API key into a chat. See Dollar never needs one; a ticket is single-use and expires in 10 minutes.
- Do not send any field that is not in the allowed key list below. The schema is strict and will reject extras.
- Do not set submission_path anywhere in the report. It does not exist in any schema version and the server decides it.
- rank_status is the only source of truth for whether a rank exists. Do not infer it from other fields.