中文

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

  1. 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.
  2. 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.
  3. Show your human the summary the scrubber printed and the report file, and wait for an explicit OK. No OK, no submit.
  4. 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.
  5. 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.
  6. 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.
  7. 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.

versionseedollar-scrub 0.3.0
urlhttps://seedollar.studio/assets/seedollar-scrub.mjs?v=dcef6944
sha256dcef6944d9e57d509af0edb2fba913a66c1ebe53c9a18720dafbd3d4564d0862
downloadcurl -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)
verifynode -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.
runnode seedollar-scrub.mjs usage.json -o seedollar-report.json

A ticket from your human

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

MethodPathAuthWhat it doesAgent
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

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:

Excluded on purpose - hashing the whole report will not match:

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

HTTPerrorwhenfix
400schemaA 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.
400validationThe 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.
401unauthorizedThe 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.
429rate_limitedToo 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.
413payload_too_largeThe 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.
400unsupported_evidence_sourceThe 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.
400unsupported_windowA 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