# countwerk

A number on the internet. Your agents bump it for 3 millionths
of a dollar. Only you can turn it back.

What it is: durable HTTP counters, mergeable distinct counts, seen-before
membership filters, and top-k heavy hitters for AI agents; memory your
agents can write but can't erase, behind caps they can't raise.

Use it for: spend caps, step limits, fleet budgets, email/action limits,
coverage counts, and "have I processed this one" dedup that must survive
restarts.

Do not use it for: local loop variables, strict rate limiting, or invoicing
(at-least-once: duplicates can overcount, so bill from your ledger, cap
from countwerk).

Auth invariant: three nested token scopes, read < write < admin. Counters
are monotonic under the write token (`cw_o_`); the read token (`cw_r_`)
only reads; reset, delete, restore, archive, hydrate, bound changes, and
rotation require admin scope (`cw_a_`), held outside the agent.

Commit model: every 2xx mutation is durable. Mutations are at-least-once
under retries: a re-sent request applies again. Unsure after a timeout?
Read the counter first; reads are included.

Payment: No account. Fund a namespace with x402 (USDC, Base Sepolia today),
then count over HTTP.

Status: beta, indie (built by one person). No external users yet;
settlement is testnet USDC and the $1 max balance bounds any
experiment's worst case. Expect breaking changes during beta: these
contracts are accurate but young, so treat GET /pricing and the response
bodies as the source of truth, not memorized docs. Bug reports and change notes: seiflotfy@gmail.com or @seiflotfy on x.com.

Every call costs the same 3 u$ in micro-USD (u$; 1_000_000 u$
= $1) from a prepaid balance, reads included, so about 333333 calls to the
dollar. A counts/incr of up to 1000 counters is one call; distinct-count writes
bill 3 u$ per dcount touched (each is its own durable
blob). Snapshots, restore, and archive cost more (they hit durable storage).
Exact numbers are machine-readable at GET https://countwerk.ai/pricing. Quote those, not
prose. Prefer the plural (batch) routes: fewer calls, same result.

## Quickstart: first counter

1. `POST https://countwerk.ai/v1/{ns}/fund` with no payment.
2. Sign the returned x402 `accepts` payload and retry with `X-PAYMENT`.
3. Store `token` as `COUNTWERK_TOKEN` and keep `admin_token` out of the agent context.
4. `POST https://countwerk.ai/v1/{ns}/count/first-counter/incr` with `Authorization`.

Signer example (x402 v1 / X-PAYMENT) for the orchestrator:

```js
// npm i @x402/fetch @x402/evm viem
import { wrapFetchWithPaymentFromConfig } from "@x402/fetch";
import { ExactEvmScheme } from "@x402/evm";
import { privateKeyToAccount } from "viem/accounts";

const NS = "agent-7";
const signer = privateKeyToAccount(process.env.EVM_PRIVATE_KEY);
const fetchWithPayment = wrapFetchWithPaymentFromConfig(fetch, {
  schemes: [{ network: "eip155:*", client: new ExactEvmScheme(signer), x402Version: 1 }],
});

const fund = await fetchWithPayment("https://countwerk.ai/v1/" + NS + "/fund", { method: "POST" });
const { token } = await fund.json();
// Store token as COUNTWERK_TOKEN in your orchestrator secret store.
```

The helper handles the payment challenge and retries with `X-PAYMENT`.
The agent should receive the budget decision, not `cw_a_`.

Operational contract:

- 2xx means the mutation is committed.
- Mutations are at-least-once: countwerk does not deduplicate retries. A blind
  re-send applies again; if a timeout leaves you unsure, read before retrying.
- The retryable field decides whether a retry is worth attempting.
- `cw_o_` can count; `cw_a_` is required for reset, delete, restore,
  archive, hydrate, and token rotation.
- Counters are increment-only below admin scope.

## When to use it (and when not)

Use countwerk when the number is SHARED across workers, must OUTLIVE
the run, must sit OUTSIDE the agent's reach, or must hold ACROSS
platforms (one cap over agents on different hosts and runtimes; a
platform's own spending controls stop at its edge). If none of those
hold, use a local variable. Keep local state local.

Key shapes that work (names allow [a-z0-9._-]; join parts with dots):

- spend cap: `count/spend-usd-cents`, incr {"by": cost} per model call;
  the loop (or the orchestrator) halts at the cap. Keep the op token in
  the orchestrator, not the prompt, if the agent must not unwind it
- step limit: `count/steps.{run_id}`; set a `ttl_seconds` so the counter
  freezes itself when the run's window is up, or a `limit` for a hard step cap
- fleet budget: `count/fetches.{domain}`. 50 workers share one budget
  with zero coordination code (a budget and backoff signal, not a hard
  rate limiter: concurrent check-then-incr can briefly overshoot)
- blast radius: `count/emails.2026-07-03`. N outward actions per day,
  hard stop after
- coverage: `dcount/seen.{shard}` merged into `dcount/seen.all`.
  Answers "how many distinct", never "have I seen this one"
- seen-before dedup: `bloom/{name}` with `check` (test-then-add). Answers
  "have I processed THIS one" so a worker acts once per item across
  restarts; the question dcount cannot answer

## No account: fund with x402

```
POST https://countwerk.ai/v1/{ns}/fund?recover=1
```

1. Call with no payment -> 402 with an x402 `accepts` body (scheme
   `exact`, USDC via x402, facilitator-settled). The accepts body names
   the exact network and USDC contract; Base Sepolia today, so faucet
   USDC counts.
2. Sign the payment (any x402 client, e.g. @x402/fetch) and retry with the
   `X-PAYMENT` header. Any signed value >= 100000 u$ ($0.1) credits.
   The balance is bounded at 1000000 u$ ($1), best-effort per request: a
   fund that would push it over is refused with 409 `credit_cap` (carrying
   `headroom_micro`) BEFORE settlement, so refused funds never move. The one
   exception is an authorized `?recover=1` by the original funder, which may
   cross the bound by at most 100000 u$. Spend down, then top up.
   Beta runs on Base Sepolia testnet: balances are play money and do not
   carry to mainnet. Spend them freely.
3. First fund of a namespace returns `token` (cw_o_, write scope),
   `admin_token` (cw_a_, admin scope), and `read_token` (cw_r_, read
   scope) EXACTLY ONCE. Store all three; hand agents only `cw_o_`, and
   dashboards/monitors only `cw_r_`.

Lost your tokens? The funding wallet is your identity: fund again with
`?recover=1` signed by the ORIGINAL funding wallet and all three tokens
are re-minted (every prior token dies, immediately).

Settlement reference is returned as `payment_ref` and echoed in the
`X-PAYMENT-RESPONSE` header. Namespace: `[a-z0-9_-]{1,64}`. Counter and
dcount names: `[a-z0-9._-]{1,128}`. No names are reserved.

## Freeze: per-counter ttl and limit (optional)

Namespaces and their balance live forever. Expiry is per key, not per
namespace. A counter or dcount can carry a `ttl_seconds`, and a
counter can carry a `limit`, both set at CREATION on the first
incr/add (`ttl_seconds` = -1 or 1..31536000; `limit` = -1 or >=1;
-1 means none). The op token sets them once; on an existing key they are
ignored, so the model can never move its own deadline or cap.

When a key SEALS it becomes read-only: further op writes return 409
`frozen` (not retryable). Reads still work and carry `frozen: true` plus
`frozen_at` (Unix epoch MILLISECONDS). A counter seals when
`now >= frozen_at` OR `value >= limit`. Seal-on-reach:
the increment that CROSSES the limit still applies (it saw value below the
cap); the NEXT write seals. A dcount seals on time only.

Only admin moves the bounds: `POST /count/{name}/ttl {ttl_seconds}`,
`POST /count/{name}/limit {limit}`, `POST /dcount/{name}/ttl {ttl_seconds}`.
Setting a future ttl (or a cap above the current value) UNSEALS the key.
Use these for step limits (`count/steps.{run_id}` with a ttl) and blast
radius (a hard `limit`) that the agent cannot unwind.

## Auth

All routes except `fund` require `Authorization: Bearer <token>`. Three
nested scopes: read < write < admin, each token also holding every scope
below it.

- `cw_r_` read: GETs, `POST /read`, `bloom/{name}/test`,
  `topk/{name}/query`. Anything else answers 403 `write_required`.
  Reads still bill, so a read token can spend the balance but can never
  touch state or bounds.
- `cw_o_` write: all data-plane mutations (incr, dcount add/merge,
  bloom add/check, topk add, snapshot create) plus everything read can do.
- `cw_a_` admin: DELETEs, `restore`, `reset`, `archive`, `hydrate`,
  ttl/limit changes, and `tokens/rotate` (403 `admin_required` otherwise).

`tokens/rotate` takes `{scope: "read" | "write" | "admin"}` (default
write; an unknown scope is a 400, never a silent default). It mints the
scope's token if none exists yet (how pre-read-token namespaces get one).
A replaced write/read token stays valid for 5 minutes so live consumers
can re-deploy (skip with `{"grace": false}`); admin rotation is always
immediate, because its job is revoking a leak. Lost the admin token
entirely? `fund?recover=1` signed by the original funding wallet re-mints
all three.

## Routes and prices (u$/op)

Singular segment = one named thing, plural = collection verbs. `count`
and `counts` are different segments, so your names can never collide
with routes.

```
POST   /v1/{ns}/fund?recover=1              x402                   free
GET    /v1/{ns}/balance                     -> {balance_micro}     free
GET    /v1/{ns}/credits                     usage meter, live per op, readonly:
                                            {used_micro, funded_micro, comped_micro, balance_micro}   free
GET    /v1/{ns}/ledger                      money history, newest first (fund/comp events)   3
POST   /v1/{ns}/tokens/rotate               admin {scope?: read|write|admin, grace?: false}   free
GET    /pricing                             price sheet as JSON    free

POST   /v1/{ns}/counts/incr                 {entries:[{name, by?, ttl_seconds?, limit?, description?}]} max 1000
                                            3 flat (one call)
GET    /v1/{ns}/counts?cursor=&limit=100    page counters by name  3
POST   /v1/{ns}/count/{name}/incr           {by?, ttl_seconds?, limit?, description?}   3
GET    /v1/{ns}/count/{name}                -> {name, value, frozen_at?, limit?, frozen?}   3
POST   /v1/{ns}/count/{name}/ttl            admin {ttl_seconds}    3
POST   /v1/{ns}/count/{name}/limit          admin {limit}          3
DELETE /v1/{ns}/count/{name}                admin                  3

POST   /v1/{ns}/dcounts/add                 {entries:[{name, items?, hashes?, ttl_seconds?, description?}]}
                                            max 100 dcounts / 10k items total
                                            3 per dcount NAME in the call
POST   /v1/{ns}/dcounts/merge               {dest, sources:[..]}   3 flat
GET    /v1/{ns}/dcounts?cursor=&limit=100   page dcounts by name   3
POST   /v1/{ns}/dcount/{name}/add           {items?|hashes?, ttl_seconds?, description?} max 10k   3
GET    /v1/{ns}/dcount/{name}               -> {name, estimate, frozen_at?, frozen?}   3
POST   /v1/{ns}/dcount/{name}/ttl           admin {ttl_seconds}    3
DELETE /v1/{ns}/dcount/{name}               admin                  3

POST   /v1/{ns}/bloom/{name}                {capacity, fp?, expansion?, max_capacity?, description?}   3
POST   /v1/{ns}/bloom/{name}/add            {items?|hashes?} max 1000; mark seen   3
POST   /v1/{ns}/bloom/{name}/test           {items?|hashes?} -> {results:[bool]}, read-only   3
POST   /v1/{ns}/bloom/{name}/check          {items?|hashes?} test-then-add (the dedup op)   3
GET    /v1/{ns}/bloom/{name}                -> {capacity, fp, blocks, keys_added, fill}   3
DELETE /v1/{ns}/bloom/{name}               admin                  3

POST   /v1/{ns}/topk/{name}                 {k, capacity?, description?}; size it once   3
POST   /v1/{ns}/topk/{name}/add             {hashes?|entries:[{hash, by?}]} u64, max 1000   3
POST   /v1/{ns}/topk/{name}/query           {hashes} -> {results:[bool]}, in the top k now?   3
GET    /v1/{ns}/topk/{name}                 -> {k, capacity, entries:[{hash, count, error}]}   3
DELETE /v1/{ns}/topk/{name}                 admin                  3

POST   /v1/{ns}/read                        {counters?:[names], dcounts?:[names]} max 1000
                                            both kinds, one call   3 flat

POST   /v1/{ns}/snapshots                   {description?}         100 + 50/MiB
GET    /v1/{ns}/snapshots                   list snapshots         3
DELETE /v1/{ns}/snapshot/{id}               admin                  3
POST   /v1/{ns}/restore                     admin {snapshot?, force?}
                                            200 + 400/MiB
POST   /v1/{ns}/count/{name}/restore        admin {snapshot}       25
POST   /v1/{ns}/dcount/{name}/restore       admin {snapshot}       25
POST   /v1/{ns}/reset                       admin {snapshot?: true}; wipe counters+dcounts+blooms+topks
                                            200 + the rescue snapshot
POST   /v1/{ns}/archive                     admin; snapshot+freeze 100 + 50/MiB
POST   /v1/{ns}/hydrate                     admin; unfreeze        3
```

Counters are increment-only: `by` is 1..2^53-1 (default 1), zero or
negative is a 400. Only admin verbs (delete, restore) move a counter
down. Need a gauge? Use two counters (`x.opened` / `x.closed`) and
subtract; one `read` returns both, and gross flow in each direction is
better telemetry than a signed net. Batch-first: prefer `counts/incr`,
`dcounts/add`, `read`; the single-name routes are sugar.

`description` (1..256 chars, last write wins) is an optional note on any
write; it rides the same row (no extra cost) and comes back on single
reads, `read`, and lists.

Lists are cursor-paged by name: pass `cursor` = last name from the
previous page; `next_cursor` absent means done. Dcount list rows carry
`updated_at` but deliberately NO estimates: listing
1000 dcounts must not decode 1000 blobs; `read` is the estimates path.

Distinct `items` are hashed server-side (xxh64 seed 0); send pre-hashed
u64 `hashes` (`0x`-prefixed hex or decimal strings) to keep raw values
off the wire. Every dcount is precision 14 (effectively exact below a few
thousand distinct values, ~0.8% beyond; documented behavior, not a
contract). Precision is not configurable, and an explicit `p` is a
400, so every dcount merges with every other, forever. Reading a missing
dcount returns 200 `{name, estimate: 0, exists: false}`; missing
counters read as plain 0.

## Bloom: have I seen this one (membership, not counting)

A bloom filter answers "have I processed THIS exact item?", the question
dcount cannot. `check` is the one you want: test-then-add in a single
call. For each item it returns whether it was ALREADY present (a probable
duplicate) and marks it seen, so "act only if new" is one call:

```
POST /v1/{ns}/bloom/urls        {"capacity": 100000}          size it once
POST /v1/{ns}/bloom/urls/check  {"items": ["https://a", "..."]}
-> {"results": [false, true]}    true = already seen -> skip it
```

One-sided by design: a `false` from `test`/`check` is a HARD guarantee the
item was never added; a `true` is "probably seen" and may be a false
positive (about `fp`, default 0.005). No false negatives, ever, so it is safe for
"skip duplicates," never for "prove this is new."

Size it at CREATION with `capacity` (expected distinct items) and optional
`fp` (target false-positive rate). A fixed filter cannot resize: once
`keys_added` reaches `capacity`, a genuinely-new key is refused with 507
`filter_full` while dups and reads still succeed. Roll over to a larger
filter, or create a SCALABLE one instead: pass `expansion`
(2..8) plus a mandatory
`max_capacity`, the declared growth ceiling. The filter is then a chain of
sub-filters planned entirely at creation (at most 8 strata,
each at a tightened rate so the ADVERTISED `fp` holds for the filter's
whole life); it absorbs new keys with no rollover ops until `max_capacity`,
where the same 507 applies. The ceiling is mandatory on purpose: growth is
automatic up to a contract, never unbounded. `GET /bloom/{name}` carries
`keys_added`, `fill`, `near_full` (against the ceiling), and for
scalable filters the `strata` ladder, so you can see the wall coming. `add`/`test`/`check` take up to
1000 items per call; `items` are hashed server-side (xxh64), or send
pre-hashed u64 `hashes` to keep raw values off the wire, same as dcount.
Blooms are part of snapshot/restore/reset like counters and dcounts.

## Top-k: what did the fleet hit most (heavy hitters)

A topk answers "which items showed up most?", ranked, without storing the
stream. Space-Saving over a fixed `capacity` of slots, sized once at
creation like bloom: `k` (100 max) is how many you read back,
`capacity` (default 8x k with a floor of 64, 2048 max) is how many candidates
are monitored. Top tools, top URLs, top error strings across a fleet.

```
POST /v1/{ns}/topk/errors        {"k": 10}
POST /v1/{ns}/topk/errors/add    {"hashes": ["0x9f3a51c2aa01be77", "1234567890"]}
POST /v1/{ns}/topk/errors/add    {"entries": [{"hash": "0x9f3a51c2aa01be77", "by": 3}]}
GET  /v1/{ns}/topk/errors
-> {"entries": [{"hash": "0x9f3a51c2aa01be77", "count": 412, "error": 0}, ...]}
```

One-sided like everything here: a `count` NEVER undercounts the true
frequency, and overcounts by at most its `error` field (true count is
between `count - error` and `count`). An item whose true count beats the
smallest monitored slot is guaranteed to be in the list. `query` answers
"in the top k right now"; a `false` means below the floor or never seen,
which Space-Saving cannot tell apart. Keys are u64 HASHES only (0x-hex or
decimal, same grammar as dcount and bloom): hash client-side (xxh64 or any
64-bit hash), keep the hash to name map on your side, and raw values never
cross the wire. Agents add, only admin deletes; topks are part of
snapshot/restore/reset like everything else.

## Snapshots, restore, archive (rewind to known-good)

A snapshot is a consistent copy of ALL counting state (counters,
dcounts, blooms, topks, descriptions), stored off the namespace. Max 16 per namespace (409
`snapshot_limit`; delete one first).

- `reset` wipes all counters, dcounts, blooms, and topks, taking a rescue
  snapshot first by default (at the 16-snapshot cap this is a 409, never a
  silent skip; `{"snapshot": false}` opts out). Balance and tokens
  survive.
- `restore` REPLACES current counters, dcounts, blooms, and topks with the
  snapshot and discards everything written since, acked or not. That is its purpose:
  snapshot first if you might want the present back.
- Per-key restore (`/count/{name}/restore`, `/dcount/{name}/restore`)
  rewinds ONE counter or dcount and touches nothing else. A bad deploy
  that polluted one coverage dcount rolls back alone. A name absent from
  the snapshot is 404 `not_in_snapshot`.
- `archive` takes a final snapshot and freezes the namespace read-only:
  counting mutations return 409 `namespace_archived`; reads, lists,
  balance, fund, and snapshot routes still work.
  `hydrate` unfreezes. Both are safe to repeat.
- Balance and tokens are never part of a snapshot and never restored.
  Money cannot be rewound.

## Retries

Mutations are at-least-once: countwerk does NOT deduplicate retries, and
an `Idempotency-Key` header is ignored. A blind re-send applies (and
bills) again. If a timeout leaves you unsure whether a mutation landed,
read the counter first (reads are included in the flat call price), or
shape keys so duplicates are harmless (one counter per attempt). Server-
side `limit`/`ttl` caps hold regardless: a duplicate increment can waste
a count, but it can never pass a frozen counter.

## Errors

`{error, message, retryable, ...}` with conventional status codes.
The `retryable` FIELD decides, not the status class: most 5xx are
`retryable: true`, but permanent faults are 5xx with `retryable: false`
and retrying them only re-bills the failed call. A failed (non-2xx)
mutation did not apply, so retrying an error can never double-count.
`retryable: false` means fix the request, fund, or use the named cure.
Refusals are free: 401, 403, and 400 validation errors are rejected
before any debit and are not billed.

- 402 `insufficient_balance` + {balance_micro, required_micro}: top up via fund.
- 401 `unauthorized` / 403 `admin_required`: token missing or under-scoped.
- 402 `bad_payment` / `invalid_payment` / `settlement_failed`: x402 problems.
- 409 `namespace_archived`: hydrate first. 409 `snapshot_limit`: delete one.
- 409 `credit_cap` + {balance_micro, max_balance_micro, headroom_micro}:
  the fund would push the balance over 1000000 u$; sign a value <= headroom_micro,
  or spend down first. Refused before settlement: no funds moved.
- 500 `dcount_corrupt` (retryable: false): one dcount's bytes are bad. Cure:
  DELETE it or restore it from a snapshot. On `read` this is per-entry:
  the row is {name, error, message, retryable} instead of {name, estimate}.
- 500 `snapshot_corrupt` (retryable: false): restore from another snapshot.
- 507 `filter_full` (retryable: false): a bloom reached `capacity`; create a
  larger filter and roll over. Dups and reads still succeed.
- 400 `bad_capacity` / `bad_fp` / `capacity_too_large`: bloom sizing errors.
  409 `bloom_exists`: the name is taken (a filter cannot be re-created).
- 503 `namespace_busy` (retryable: true): a restore is running; retry.
- 400 `bucket_full`: too many counters share one name-hash bucket; delete some.
- 400 `bad_page_limit`: a list `?limit=` page size outside 1..1000 (distinct
  from `bad_limit`, which validates a counter's cap).
- 503 `storage_degraded` (retryable: false): admin restore with force, or delete.
- 501 `x402_not_wired`: server has no settlement address configured.
- other 5xx: retry only when `retryable: true` (the failed call did not apply).
