# The Census — reading a whole registry — Brain On BNB AI # Every agent id on BNB Chain, and every job they were ever paid for. # # This is the complete chain-census bundle as a single file, so it can be read in # one fetch. 7 files, 3816 lines. # Download as a zip: https://brainonbnb.com/code/chain-census.zip # Everything else: https://brainonbnb.com/code/index.txt # # No secrets are present: they are supplied at runtime through the environment. # MIT licensed. ============================================================================== === FILE: scripts/erc8004-enrich.mjs ============================================================================== // Fills in the parts of a registration the scan does not carry. // // The scan is a counting pass over a quarter of a million ids, so it records // only what it needs to count with. A profile needs more: what the agent says // it does, what trust model it claims, whether it advertises x402, what it // looks like. That data is already on-chain in the same tokenURI — it just was // not worth carrying through 280,000 iterations to get at the few thousand // entries that have an endpoint. // // So this re-reads only the ids that made it into the directory. A few thousand // calls, a couple of minutes, and it can run while the main scan is still // going — the two touch different files. // // This step had no --dir flag while every other census script had one, so it // could only ever write into the first census. The second census therefore // never got a registrations.json at all, and the publisher — which reads that // file for descriptions and images — silently fell back to an empty object: // 795 published agents, 0 images. A missing enrichment looks exactly like an // agent that declared nothing, which is why it went unnoticed for three days. // // Usage: node scripts/erc8004-enrich.mjs (add --dir for a rescan) import fs from 'node:fs'; import path from 'node:path'; import { censusDirArg } from './lib/census-dir.mjs'; const ROOT = path.resolve(import.meta.dirname, '..'); const DIR = path.join(ROOT, 'data', censusDirArg()); const HITS = path.join(DIR, 'agents-with-endpoints.jsonl'); const OUT = path.join(DIR, 'registrations.json'); const REGISTRY = '0x8004A169FB4a3325136EB29fA0ceB6D2e539a432'; const TOKEN_URI = '0xc87b56dd'; const RPCS = [ 'https://bsc-dataseed1.defibit.io', 'https://bsc-mainnet.public.blastapi.io', 'https://bsc-dataseed.binance.org', 'https://bsc-dataseed2.bnbchain.org', 'https://bsc-dataseed3.bnbchain.org', 'https://bsc-dataseed4.defibit.io', ]; const BATCH = 25; let rr = 0; const id32 = (n) => BigInt(n).toString(16).padStart(64, '0'); async function callBatch(ids) { const payload = ids.map((id, i) => ({ jsonrpc: '2.0', id: i, method: 'eth_call', params: [{ to: REGISTRY, data: TOKEN_URI + id32(id) }, 'latest'], })); for (let a = 0; a < RPCS.length * 2; a++) { try { const r = await fetch(RPCS[rr++ % RPCS.length], { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload), signal: AbortSignal.timeout(20000), }); if (!r.ok) continue; const j = await r.json(); if (!Array.isArray(j)) continue; const out = new Array(ids.length).fill(null); let got = 0; for (const it of j) { if (typeof it.id !== 'number' || it.error) continue; out[it.id] = it.result; got++; } if (got) return out; } catch { /* next endpoint */ } } return new Array(ids.length).fill(null); } const decodeString = (hex) => { if (!hex || hex === '0x') return null; const b = hex.slice(2); try { const len = parseInt(b.slice(64, 128), 16); if (!(len > 0) || len > 2_000_000) return null; const by = []; for (let i = 0; i < len; i++) by.push(parseInt(b.substr(128 + i * 2, 2), 16)); return Buffer.from(by).toString('utf8'); } catch { return null; } }; const parse = (raw) => { const s = decodeString(raw); if (!s) return null; const b64 = s.includes('base64,') ? s.split('base64,')[1] : null; try { return JSON.parse(b64 ? Buffer.from(b64, 'base64').toString('utf8') : s); } catch { return null; } }; if (!fs.existsSync(HITS)) { console.error('Run erc8004-scan.mjs first.'); process.exit(1); } const ids = [...new Set( fs.readFileSync(HITS, 'utf8').split('\n').filter(Boolean) .map((l) => { try { return JSON.parse(l).id; } catch { return null; } }) .filter((x) => Number.isInteger(x)), )].sort((a, b) => a - b); console.log(`enriching ${ids.length.toLocaleString('en-US')} registrations`); const registrations = {}; for (let i = 0; i < ids.length; i += BATCH) { const chunk = ids.slice(i, i + BATCH); const res = await callBatch(chunk); for (let n = 0; n < chunk.length; n++) { const meta = parse(res[n]); if (!meta) continue; const services = Array.isArray(meta.services) ? meta.services : []; registrations[chunk[n]] = { name: typeof meta.name === 'string' ? meta.name.slice(0, 90) : null, // The operator's own words about what the agent does. On a profile this // is the difference between a row and something a person can judge. description: typeof meta.description === 'string' ? meta.description.slice(0, 400) : null, image: typeof meta.image === 'string' && /^https:\/\//.test(meta.image) ? meta.image : null, active: meta.active === true, trust: Array.isArray(meta.supportedTrust) ? meta.supportedTrust.slice(0, 6) : [], x402: !!meta.x402Support, // Named services with their declared kind — "telegram", "web", "MCP" — // which is how an agent states its own surface area. services: services.map((s) => s && typeof s === 'object' ? { name: String(s.name || '').slice(0, 40), endpoint: typeof s.endpoint === 'string' ? s.endpoint.slice(0, 200) : null, version: s.version ? String(s.version).slice(0, 20) : null, } : null).filter(Boolean).slice(0, 10), }; } if ((i / BATCH) % 10 === 0) process.stdout.write(`\r ${Math.min(i + BATCH, ids.length)}/${ids.length}`); } fs.writeFileSync(OUT, JSON.stringify(registrations, null, 1) + '\n'); const withDesc = Object.values(registrations).filter((r) => r.description).length; const withImg = Object.values(registrations).filter((r) => r.image).length; const withTrust = Object.values(registrations).filter((r) => r.trust.length).length; console.log(`\n ${Object.keys(registrations).length} read · ${withDesc} describe themselves · ${withImg} have a logo · ${withTrust} declare a trust model`); console.log(`written: ${path.relative(ROOT, OUT)}`); ============================================================================== === FILE: scripts/erc8004-probe.mjs ============================================================================== // Second half of the census: does the endpoint answer? // // The registry scan says which agents claim an endpoint. This says which of // those claims are true. They are very different numbers, and only the second // one means anything — an agent you cannot reach is a row in a database, not a // participant in anything. // // What counts as reachable is deliberately generous. Any HTTP response at all // — including 401, 403, 404 — proves something is listening at that address, // and an agent that requires auth is still an agent. Only a connection that // cannot be established counts as dead. Being strict here would flatter the // result in the wrong direction; being generous means the low number that comes // out cannot be argued away. // // Where an agent card or MCP endpoint is claimed, it is actually spoken to: // a /.well-known/agent-card.json that returns JSON, an MCP endpoint that // answers tools/list. That separates "a web server exists" from "an agent is // running", which is the distinction the whole idea rests on. // // Usage: // node scripts/erc8004-probe.mjs probe everything the scan found // node scripts/erc8004-probe.mjs --limit 200 probe the first N import fs from 'node:fs'; import path from 'node:path'; import { censusDirArg } from './lib/census-dir.mjs'; const ROOT = path.resolve(import.meta.dirname, '..'); // Same flag the scanner takes, so all halves of the census can be pointed at // one dataset: scan, enrich, probe, publish. Defaults to the live census; a // rescan gets its own directory (--dir erc8004-v3) so it never overwrites the // data the page is serving while it runs. const DIR = path.join(ROOT, 'data', censusDirArg()); const HITS = path.join(DIR, 'agents-with-endpoints.jsonl'); const OUT = path.join(DIR, 'reachable.jsonl'); const SUMMARY = path.join(DIR, 'census.json'); const args = process.argv.slice(2); const limitArg = args.indexOf('--limit'); const LIMIT = limitArg >= 0 ? Number(args[limitArg + 1]) : Infinity; const CONCURRENCY = 12; const TIMEOUT = 12000; // A census that reports zero MCP agents is either a finding about the ecosystem // or a broken detector, and from the outside those look identical. --self-test // resolves that: it runs the same detection against our own registration, which // definitely exposes MCP and an agent card. If it comes back negative, no other // number in this file is worth reading. const SELF_TEST = args.includes('--self-test'); if (!SELF_TEST && !fs.existsSync(HITS)) { console.error(`No scan output at ${path.relative(ROOT, HITS)} — run erc8004-scan.mjs first.`); process.exit(1); } const seen = new Set(); const agents = []; for (const line of (SELF_TEST ? '' : fs.readFileSync(HITS, 'utf8')).split('\n')) { if (!line.trim()) continue; try { const a = JSON.parse(line); if (seen.has(a.id)) continue; // the scan appends; a resumed run can repeat ids seen.add(a.id); agents.push(a); } catch { /* skip malformed line */ } } // Our own registration, used by --self-test. Known to expose MCP with a // double-digit tool count and an agent card, so it is the one case where the // expected answer is certain — and therefore the only honest way to tell a // finding of zero from a detector that cannot detect. if (SELF_TEST) agents.push({ id: 49467, name: 'Brain On BNB AI ($BOBAI)', endpoints: ['https://brainonbnb.com/', 'https://brainonbnb.com/mcp'], }); console.log(`${agents.length.toLocaleString('en-US')} agents claim an endpoint`); // ONE HOST AT A TIME, AND WHY THIS IS NOT A NICETY // // The worker pool spreads work across twelve workers, which is polite when // 1,848 endpoints sit on 96 hosts. It is not polite when 237 of them are the // same host: that host then sees twelve simultaneous requests, continuously, // for as long as its share of the queue lasts. // // Measured 2026-08-25, and it very nearly became a published finding. This // probe reported 54 agents speaking MCP where the previous run found 130. The // drop was not a change on the chain. app.singularry.org carries 237 registry // ids; under the barrage it answered 200 on /api/mcp while returning nothing // parsable, so most of its agents were recorded as not speaking MCP. Asked // once, calmly, a second later, it answers with a full tool list. // // "MCP agents fell by more than half" would have been a statement about our // own manners dressed up as a statement about the ecosystem. A census that // overloads what it measures is measuring itself. // // So requests are serialised per host with a small gap. Different hosts still // run in parallel — the pool is untouched — and the run takes longer only // where one operator holds many ids, which is exactly where it should. const HOST_GAP_MS = 350; const hostGate = new Map(); const politely = (url, run) => { let host; try { host = new URL(url).host; } catch { return run(); } const prev = hostGate.get(host) || Promise.resolve(); const next = prev.then(async () => { const out = await run(); await new Promise((r) => setTimeout(r, HOST_GAP_MS)); return out; }); // The chain must not break on a rejection, or one failure strands every // later request to that host forever. hostGate.set(host, next.then(() => {}, () => {})); return next; }; const probeOne = (url, opts = {}) => politely(url, async () => { try { const r = await fetch(url, { method: opts.method || 'GET', headers: { 'user-agent': 'brainonbnb-erc8004-census', ...(opts.headers || {}) }, body: opts.body, redirect: 'follow', signal: AbortSignal.timeout(TIMEOUT), }); return { ok: true, status: r.status, r }; } catch (e) { return { ok: false, error: String(e && e.name === 'TimeoutError' ? 'timeout' : (e.message || e)).slice(0, 60) }; } }); // WHAT COUNTS AS SPEAKING A2A, AND WHY THIS GOT STRICTER // It used to be: any JSON at a well-known path carrying a name, a // protocolVersion or a skills array. That is much looser than the standard and // it over-counted. An A2A card names the URL you POST JSON-RPC to and the // skills you can ask for; a document with neither is a description of an agent, // not an agent you can reach. // // Audited on 2026-08-25 across every host behind our A2A-flagged agents: 261 of // 290 served a card naming both, 29 did not. One of the 29 is a paid REST // catalogue on Arbitrum that publishes an agent.json — we had it filed as an // A2A agent on BNB Chain. // // Both facts are kept. `card` means a document was served, which is worth // knowing; `a2a` now means callable. Collapsing them was the original mistake // and merging them again would repeat it. const cardIsCallable = (j) => !!( j && typeof j === 'object' && typeof j.url === 'string' && /^https?:\/\//i.test(j.url) && Array.isArray(j.skills) && j.skills.some((s) => s && typeof s === 'object' && (s.id || s.name)) ); async function probeAgent(a) { const result = { id: a.id, name: a.name, endpoints: a.endpoints, reachable: false, live: {}, checked: [] }; for (const url of a.endpoints) { const res = await probeOne(url); result.checked.push({ url, status: res.ok ? res.status : null, error: res.error || null }); if (!res.ok) continue; result.reachable = true; // An agent card that parses is proof of an agent, not just a server — // and its skills are the only machine-readable statement of what the // agent actually does. A directory that lists names is the thing this // census exists to be better than, so the capabilities get recorded. if (/agent-card\.json$/i.test(url) || /\/\.well-known\//i.test(url)) { try { const j = await res.r.clone().json(); if (j && (j.name || j.protocolVersion || j.skills)) { result.live.card = true; result.live.a2a = cardIsCallable(j); if (Array.isArray(j.skills)) { result.live.skills = j.skills .map((s) => (s && typeof s === 'object' ? s.name || s.id : s)) .filter((s) => typeof s === 'string') .slice(0, 25); } if (typeof j.description === 'string') result.live.cardDescription = j.description.slice(0, 240); } } catch { /* served something that was not a card */ } } } // Most registrations name a bare domain, not the path to the agent card — // so looking only at what was written down finds almost nothing. The // well-known locations are where an A2A card is supposed to live, so a // reachable host gets asked directly. This is the difference between // "nobody publishes a card" and "nobody writes the path in the registry", // and those are very different findings. if (result.reachable && !result.live.card) { const hosts = [...new Set(a.endpoints.map((e) => { try { return new URL(e).origin; } catch { return null; } }).filter(Boolean))]; for (const origin of hosts.slice(0, 2)) { for (const p of ['/.well-known/agent-card.json', '/.well-known/agent.json', '/.well-known/ai-agent.json']) { const res = await probeOne(origin + p); if (!res.ok || res.status >= 400) continue; try { const j = await res.r.json(); if (j && (j.name || j.protocolVersion || j.skills)) { result.live.card = true; result.live.a2a = cardIsCallable(j); result.live.cardUrl = origin + p; if (typeof j.url === 'string') result.live.a2aUrl = j.url; if (Array.isArray(j.skills)) { result.live.skills = j.skills .map((s) => (s && typeof s === 'object' ? s.name || s.id : s)) .filter((s) => typeof s === 'string').slice(0, 25); } if (typeof j.description === 'string') result.live.cardDescription = j.description.slice(0, 240); } } catch { /* not a card */ } if (result.live.card) break; } if (result.live.card) break; } } // MCP is spoken to rather than assumed from the URL shape. Same reasoning as // the card lookup: an agent that runs MCP at /mcp on its own domain will // usually have registered only the domain. const mcpUrl = a.endpoints.find((e) => /\/mcp(\/|$)/i.test(e)) || (result.reachable ? (() => { try { return new URL(a.endpoints[0]).origin + '/mcp'; } catch { return null; } })() : null); if (mcpUrl) { const res = await probeOne(mcpUrl, { method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }), }); if (res.ok) { try { const txt = await res.r.text(); const j = JSON.parse(txt.replace(/^data:\s*/gm, '').trim().split('\n').pop()); if (j && j.result && Array.isArray(j.result.tools)) { result.live.mcp = true; result.live.mcpTools = j.result.tools.length; // The tool names are the whole point. "This agent exists" is a // directory entry; "this agent exposes these fourteen callable // tools, and here is what each is for" is something another agent // can act on without a human in the loop. result.live.tools = j.result.tools .map((t) => t && typeof t.name === 'string' ? { name: t.name.slice(0, 60), description: String(t.description || '').slice(0, 160) } : null) .filter(Boolean) .slice(0, 40); result.reachable = true; } } catch { /* answered, but not with MCP */ } } } return result; } // The self-test writes somewhere else. Sharing the output file meant a single // self-test run replaced a full probe's results with one row, while census.json // still held the totals from the real run — a page showing "372 reachable" // above a table with one entry in it. Wrong in the most embarrassing possible // way: internally inconsistent, on the page whose entire point is that its // numbers can be checked. const out = fs.createWriteStream(SELF_TEST ? OUT.replace(/\.jsonl$/, '.selftest.jsonl') : OUT, { flags: 'w' }); const todo = agents.slice(0, LIMIT === Infinity ? agents.length : LIMIT); let done = 0, reachable = 0, withMcp = 0, withA2a = 0, withCard = 0; // Fixed-size worker pool: the targets are unrelated third-party hosts, so // there is nothing to rate-limit against, but a few hundred simultaneous // sockets is rude and unreliable. const queue = todo.slice(); await Promise.all(Array.from({ length: CONCURRENCY }, async () => { while (queue.length) { const a = queue.shift(); const r = await probeAgent(a); out.write(JSON.stringify(r) + '\n'); done++; if (r.reachable) reachable++; if (r.live.mcp) withMcp++; if (r.live.a2a) withA2a++; if (r.live.card) withCard++; if (done % 25 === 0) console.log(` ${done}/${todo.length} reachable ${reachable} mcp ${withMcp} a2a ${withA2a} cards ${withCard}`); } })); out.end(); if (SELF_TEST) { const r = JSON.parse(fs.readFileSync(OUT.replace(/\.jsonl$/, '.selftest.jsonl'), 'utf8').trim().split('\n')[0]); console.log('\n--- self-test against our own agent ---'); console.log(' reachable :', r.reachable); console.log(' MCP :', !!r.live.mcp, r.live.mcpTools ? `(${r.live.mcpTools} tools)` : ''); console.log(' agent card :', !!r.live.a2a, r.live.cardUrl || ''); console.log(' tools :', (r.live.tools || []).slice(0, 5).map((t) => t.name).join(', ') || '(none)'); const good = r.reachable && r.live.mcp && (r.live.tools || []).length >= 10; console.log(good ? '\n DETECTOR WORKS — a zero elsewhere is a finding, not a bug\n' : '\n DETECTOR BROKEN — fix this before publishing any count\n'); process.exit(good ? 0 : 1); } // ---- census ----------------------------------------------------------- let scan = {}; try { scan = JSON.parse(fs.readFileSync(path.join(DIR, 'scan-state.json'), 'utf8')); } catch {} const census = { registry: '0x8004A169FB4a3325136EB29fA0ceB6D2e539a432', chain: 'BNB Smart Chain (eip155:56)', measured_at: new Date().toISOString(), method: 'Every agent id in the identity registry read via tokenURI, then every claimed HTTP endpoint contacted once. Any HTTP response counts as reachable, including 401/403/404 — only a failed connection counts as dead.', registered_ids: scan.highestId || null, ids_scanned: scan.cursor ? scan.cursor - 1 : null, registrations: scan.counts || null, endpoints: { claim_an_endpoint: agents.length, probed: done, reachable, dead: done - reachable, answering_mcp: withMcp, // Two different facts, kept apart. A served card means a document exists; // a callable one names the endpoint you POST to and the skills you can ask // for. Ten percent of what we used to call A2A had the first and not the // second — see scripts/a2a-card-audit.mjs. serving_an_agent_card: withCard, a2a_callable: withA2a, }, caveat: 'Reachability is a snapshot. An endpoint down at the moment of probing is counted as dead, and one that answers may still do nothing useful.', }; fs.writeFileSync(SUMMARY, JSON.stringify(census, null, 2) + '\n'); const pct = (n, d) => (d ? ((n / d) * 100).toFixed(2) + '%' : '–'); console.log(`\n--- endpoints ---`); console.log(` claimed an endpoint ${agents.length}`); console.log(` probed ${done}`); console.log(` answered ${reachable} (${pct(reachable, done)} of probed)`); console.log(` dead ${done - reachable}`); console.log(` answered MCP ${withMcp}`); console.log(` served an agent card ${withCard}`); console.log(` of those, callable ${withA2a} (card names both an endpoint and skills)`); if (scan.highestId) { console.log(`\n of ${scan.highestId.toLocaleString('en-US')} registered ids, ${reachable} are reachable — ${pct(reachable, scan.highestId)}`); } console.log(`\nwritten: ${path.relative(ROOT, SUMMARY)}, ${path.relative(ROOT, OUT)}`); ============================================================================== === FILE: scripts/erc8004-publish.mjs ============================================================================== // Turns the census into the page and the JSON endpoint that serve it. // // Reads the live census under data/ (scripts/lib/census-dir.mjs), writes dashboard/registry.html and // dashboard/api-registry.json. Both are generated — never edit them by hand, // the next run overwrites them. Everything the page states comes from the two // scan artefacts, so there is no path by which the page can claim a number the // data does not contain. // // THE WHOLE RITUAL, in order. Every step after the first reads what the one // before it wrote, and skipping one leaves a surface stating something no // longer true — which is exactly how the page came to publish 302,828 and then // count itself down to 299,783 in front of the reader. // // node scripts/erc8004-scan.mjs --dir erc8004-v2 # resumes; never delete // node scripts/erc8004-probe.mjs --dir erc8004-v2 // node scripts/erc8004-a2a-confirm.mjs --dir erc8004-v2 // node scripts/erc8004-publish.mjs --dir erc8004-v2 # also writes hireable.json // npx wrangler pages deploy dashboard … # BEFORE hire-confirm, see below // cd worker-agent && npx wrangler deploy # BEFORE hire-confirm, see below // node scripts/erc8004-hire-confirm.mjs --dir erc8004-v2 # asks each one for a price // node scripts/erc8004-publish.mjs --dir erc8004-v2 # again, to render the answers // node scripts/census-sync.mjs --dir erc8004-v2 # hands the scan to the worker // # pull the census line in dashboard/llms.txt from api-registry.json // node scripts/build-library.mjs // npx wrangler pages deploy dashboard --project-name=bobai-dashboard --branch=main --commit-dirty=true // node scripts/smoke-agent-surface.mjs # checks every one of the above landed // // WHY TWO DEPLOYS SIT IN THE MIDDLE OF THAT LIST // hire-confirm does not ask the agents directly. It asks our live /hire, which // resolves an ERC-8004 id through the DEPLOYED api-agents.json and caches that // file for ten minutes. So a newly registered agent is unhireable until the // dashboard carries it AND the agent worker has been restarted to drop the // cache. Run hire-confirm before both and it reports "no A2A endpoint found" // for an agent that is perfectly reachable — which is what happened on // 2026-08-26, twice, before the cause was clear. import fs from 'node:fs'; import path from 'node:path'; import { censusDirArg } from './lib/census-dir.mjs'; import { groupByOperator, operatorOf } from './lib/group-agents.mjs'; import { loadJobs, aggregate } from './lib/job-aggregate.mjs'; import { CATEGORIES, classifyAgent } from '../worker-agent/categories.js'; import { PEERS, SURFACE, OWN_AGENT_IDS } from '../worker-agent/telemetry.js'; import { SERVICES } from '../worker-agent/sell.js'; import { SERVICE_BY_SLUG } from './lib/own-agents.mjs'; const ROOT = path.resolve(import.meta.dirname, '..'); // Same flag the scanner takes, so all halves of the census can be pointed at // one dataset: scan, enrich, probe, publish. Defaults to the live census; a // rescan gets its own directory (--dir erc8004-v3) so it never overwrites the // data the page is serving while it runs. const DIR = path.join(ROOT, 'data', censusDirArg()); const state = JSON.parse(fs.readFileSync(path.join(DIR, 'scan-state.json'), 'utf8')); let census = null; try { census = JSON.parse(fs.readFileSync(path.join(DIR, 'census.json'), 'utf8')); } catch {} // What each agent says about itself, from erc8004-enrich.mjs. Optional: the // page works without it, it just has less to say about each agent. let registrations = {}; try { registrations = JSON.parse(fs.readFileSync(path.join(DIR, 'registrations.json'), 'utf8')); } catch {} const reachable = []; try { for (const line of fs.readFileSync(path.join(DIR, 'reachable.jsonl'), 'utf8').split('\n')) { if (!line.trim()) continue; try { const r = JSON.parse(line); if (r.reachable) reachable.push(r); } catch {} } } catch {} // The employment census from scripts/erc8183-job-scan.mjs. Optional: the page // renders without it and simply says nothing about who has been paid, rather // than showing an empty table that reads as "nobody has". // // Identity and employment are joined here because they are joinable: the job // kernel names a provider ADDRESS and the registry answers ownerOf() for an // agent ID, and both live on the same chain. That join is the entire reason // this page can say "this listed agent has been hired eleven times" instead of // "this listed agent says it is good at things". let jobCensus = null; let jobOwners = null; try { const jobsFile = path.join(ROOT, 'data', 'erc8183', 'jobs.jsonl'); const jobState = JSON.parse(fs.readFileSync(path.join(ROOT, 'data', 'erc8183', 'scan-state.json'), 'utf8')); const jobs = loadJobs(jobsFile); // A partial job scan must not be published as an employment census, for the // same reason a partial identity scan must not be published as a census: the // numbers all still parse, and every one of them is wrong. if (jobState.jobCounter && jobs.size >= jobState.jobCounter * 0.995) { jobCensus = { ...aggregate(jobs), jobCounter: jobState.jobCounter, unread: jobState.unread || 0, measuredAt: jobState.finishedAt || jobState.updatedAt }; } else if (jobs.size) { console.log(` (job census skipped: ${jobs.size.toLocaleString('en-US')} of ${(jobState.jobCounter || 0).toLocaleString('en-US')} jobs read)`); } try { jobOwners = JSON.parse(fs.readFileSync(path.join(ROOT, 'data', 'erc8183', 'owners.json'), 'utf8')); } catch { /* optional */ } } catch { /* no job scan yet */ } const c = state.counts; const scanned = state.cursor - 1; const total = state.highestId; const pct = (n, d = scanned) => (d ? (n / d) * 100 : 0); const fmt = (n) => Number(n).toLocaleString('en-US'); const p1 = (n, d = scanned) => pct(n, d).toFixed(pct(n, d) < 1 ? 2 : 1) + '%'; // An incomplete scan must not be published as if it were a census. This caught // a real failure: a stopped background scan kept running and overwrote the // finished state with its own older one, leaving the counts 60,000 ids short // while every file still parsed and every number still looked plausible. The // page would have quietly understated the ecosystem it claims to measure. // --partial publishes anyway, for when a snapshot is genuinely wanted. if (scanned < total * 0.995 && !process.argv.includes('--partial')) { console.error(` Refusing to publish: only ${scanned.toLocaleString('en-US')} of ${total.toLocaleString('en-US')} ids scanned (${((scanned / total) * 100).toFixed(1)}%).`); console.error(`Finish the scan first, or pass --partial to publish a snapshot anyway. `); process.exit(1); } // ---- the JSON surface ---------------------------------------------------- const api = { what_this_is: 'A census of the ERC-8004 identity registry on BNB Smart Chain: how many agents are registered, how many of those registrations are readable, how many name an endpoint, and how many of those endpoints answer.', registry: '0x8004A169FB4a3325136EB29fA0ceB6D2e539a432', chain: 'eip155:56', measured_at: census?.measured_at || state.updatedAt, registered_ids: total, ids_scanned: scanned, registrations: { parses: c.valid, unparsable: c.unparsable, empty: c.empty, // The correction this rescan existed for. 43% of registrations are an // https link to a document, and the previous census counted those as // holding nothing readable. Published so the claim can be checked // against the data rather than taken from the prose. points_offchain: c.offchain, unread_after_retries: c.unread, active_flag: c.active, names_a_service: c.withServices, has_http_endpoint: c.withHttpEndpoint, endpoint_on_a_real_tld: c.plausibleEndpoint, speaks_mcp: c.mcp, speaks_a2a: c.a2a, supports_x402: c.x402, }, reachability: census?.endpoints || null, independent_operators: null, // filled in below, once operators are grouped method: { registrations: 'Every id read via tokenURI() on the registry. Ids the nodes refused are retried until they answer; the count above reports what remained unreadable after that, so a percentage here is never a statement about node availability.', reachability: 'Every claimed HTTP endpoint contacted once. Any HTTP response counts as reachable, including 401, 403 and 404 — only a failed connection counts as dead. MCP endpoints were sent a real tools/list; agent cards had to parse as JSON.', caveat: 'Reachability is a snapshot. An endpoint down at that moment is counted as dead, and one that answers may still do nothing useful.', }, source: 'https://brainonbnb.com/registry', }; fs.writeFileSync(path.join(ROOT, 'dashboard', 'api-registry.json'), JSON.stringify(api, null, 2) + '\n'); // ---- the list itself, for machines --------------------------------------- // The census counts; this is what another agent can actually use. Only agents // that answered are in it, and where one exposes tools or skills they are // listed by name — because "an agent exists at this address" is a directory // entry, and a directory is the thing this set out to be better than. // // Sorted so that agents which speak a protocol come first: an agent looking for // a counterpart wants those, and burying them under a few hundred plain web // servers would make the useful part of the list the hardest to reach. const directory = reachable .map((r) => ({ id: r.id, name: r.name || registrations[r.id]?.name || null, // The operator's own description, straight from the on-chain registration. // This is what turns a row into something a person can judge. ...(registrations[r.id]?.description ? { description: registrations[r.id].description } : {}), ...(registrations[r.id]?.image ? { image: registrations[r.id].image } : {}), ...(registrations[r.id]?.trust?.length ? { trust_models: registrations[r.id].trust } : {}), ...(registrations[r.id]?.services?.length ? { declared_services: registrations[r.id].services } : {}), endpoints: r.endpoints, speaks: [r.live?.mcp ? 'mcp' : null, r.live?.a2a ? 'a2a' : null, (r.x402 || registrations[r.id]?.x402) ? 'x402' : null].filter(Boolean), ...(r.live?.tools?.length ? { tools: r.live.tools } : {}), ...(r.live?.skills?.length ? { skills: r.live.skills } : {}), ...(r.live?.cardUrl ? { agent_card: r.live.cardUrl } : {}), ...(r.live?.cardDescription ? { description: r.live.cardDescription } : {}), })) .sort((a, b) => b.speaks.length - a.speaks.length || a.id - b.id); // Grouped by who actually runs them. 784 reachable ids are 72 operators, and // 103 MCP agents are 7 — one provider accounts for 96 of them, all returning // the identical five tools. Counting ids describes the registry correctly and // describes the market wrongly. // Our own hireable agents, merged in from the registration state file. // // They are on-chain and answering, but the identity census is a periodic full // scan and will only see them on its next pass. Leaving the two categories the // chain is thinnest in looking empty until then would misrepresent what a buyer // can actually hire today. Merged by id, so the census overwrites this the // moment it catches up rather than listing them twice. // The sentence the hire box opens with, per seller rather than per category. // // It used to be one template per category, which broke the moment two agents // shared a category and needed different inputs: the yield template is a plain // question with no address in it, and the fee-tier seller refuses without one. // A buyer who took the suggested wording would have funded a job the seller // then declined — the exact failure that cost job 56670, discovered after // paying. A seed is a promise that the sentence works as written. const SEED_TOKEN = '0x245c386dcfed896f5c346107596141e5edcbffff'; const SEED_TASKS = { health_factor: 'health factor and liquidation distance for the Venus position at ', grid_plan: `grid plan for ${SEED_TOKEN}, 10 levels across a 15% band, $1000 capital`, yield_plan: 'where is the best yield on BNB Chain for USDT right now', rebalance_plan: `rebalance holdings [{"token":"${SEED_TOKEN}","usd":1000}] — what should the range be`, lp_tier_plan: `which PancakeSwap fee tier is actually paying for ${SEED_TOKEN}, placing $1000 of liquidity`, }; const seedTask = (svc) => SEED_TASKS[svc.id] || null; let ownAgents = []; try { const own = JSON.parse(fs.readFileSync(path.join(ROOT, 'data', 'own-agents.json'), 'utf8')); ownAgents = Object.entries(own.agents || {}).filter(([, a]) => a.id).map(([slug, a]) => { // Our own agents were being listed with description: null, so the table // printed "its registration says nothing about what it does" against the // two entries we control — while holding other people to the same standard // one row below. The text comes from the service definition in sell.js, // which is the same sentence the agent sells itself with over A2A and the // same one it delivers against. One source, and it cannot drift from what // is actually for sale. // // BY SLUG, NOT BY CATEGORY. The lookup used to match on category, which // holds only while no two agents share one. Two do, and `find` returned // whichever service was declared first — so one of our own agents was // described as the other one, in the section a judge is asked to compare // agents in. A slug names exactly one agent; a category does not. const svc = SERVICES[SERVICE_BY_SLUG[slug]] || null; return { id: a.id, name: a.name, description: svc ? svc.deliverables : null, endpoints: ['https://agent.brainonbnb.com/a2a'], speaks: ['a2a', 'x402'], // What it can be asked for, named the way the seller names it. Empty // tools on purpose: see the merge below. skills: svc ? [{ name: svc.id, description: svc.name }] : [], tools: [], seed: svc ? seedTask(svc) : null, attributes: [{ trait_type: 'Category', value: a.category }], ours: true, provider: a.owner, }; }); // OUR DEFINITION WINS, rather than being dropped when the census already has // the id. Five agents sell from one A2A endpoint, and one endpoint serves one // agent card, so the enrichment step gave all five the same description and // the same six capabilities. On a page whose whole job is helping somebody // choose between agents, that printed the identical paragraph four times, // once per category. The per-agent on-chain document is the truth; the // shared card is an artefact of how they are hosted. for (const a of ownAgents) { const i = directory.findIndex((d) => d.id === a.id); if (i < 0) directory.push(a); else directory[i] = { ...directory[i], ...a }; } } catch { /* nothing registered yet */ } const operators = groupByOperator(directory); api.independent_operators = operators.length; fs.writeFileSync(path.join(ROOT, 'dashboard', 'api-registry.json'), JSON.stringify(api, null, 2) + '\n'); // ---- the employment census, for machines --------------------------------- // The identity census says who exists. This says who has been paid, which is // the only reputation signal on this chain nobody can write about themselves: // a registration is self-reported text, a funded job is somebody else's money. if (jobCensus) { const jobsApi = { what_this_is: 'Every job in the ERC-8183 escrow kernel on BNB Smart Chain, read one at a time and aggregated into an employment balance per provider. It answers who has actually been hired and paid, as opposed to who is registered.', kernel: '0xEa4DAa3100A767e86FDed867729ae7446476EBA6', payment_token: { symbol: 'U', name: 'United Stables', address: '0xcE24439F2D9C6a2289F741120FE202248B666666', decimals: 18 }, chain: 'eip155:56', measured_at: jobCensus.measuredAt, jobs: { job_counter: jobCensus.jobCounter, read: jobCensus.total, unread_after_retries: jobCensus.unread, by_status: jobCensus.byStatus, ever_funded: jobCensus.fundedJobs, escrow_released: jobCensus.completed, deliverable_never_released: jobCensus.submitted, never_funded: jobCensus.open, total_escrowed_u: Number(jobCensus.escrowedU.toFixed(6)), distinct_buyers: jobCensus.buyers, distinct_providers: jobCensus.providers.length, providers_paid_by_more_than_one_buyer: jobCensus.providersWithRealWork, top_provider_share: Number(jobCensus.concentration.top_provider_share.toFixed(4)), top5_share: Number(jobCensus.concentration.top5_share.toFixed(4)), excluding_top_provider: jobCensus.withoutTopProvider, }, providers: jobCensus.providers.map((p) => ({ address: p.address, agents: jobOwners?.owners?.[p.address] || [], jobs: p.jobs, funded: p.funded, completed: p.completed, submitted_not_released: p.submitted_not_released, awaiting_delivery: p.awaiting_delivery, expired: p.expired, rejected: p.rejected, never_funded: p.never_funded, distinct_buyers: p.distinct_buyers, escrowed_u: Number(p.escrowed_u.toFixed(6)), median_budget_u: Number(p.median_budget_u.toFixed(6)), delivery_rate: Number(p.delivery_rate.toFixed(4)), first_job_id: p.first_job_id, last_job_id: p.last_job_id, })), method: { jobs: 'getJob() called for every id from 1 to jobCounter(). Ids a node refused are retried patiently; whatever remains unreadable is reported as unread rather than folded into a percentage.', funded: 'A job in status OPEN was created and never funded — createJob costs nothing and commits nobody, so OPEN is excluded from every payment figure.', completed: 'SUBMITTED means a deliverable is on-chain and the escrow has NOT released. Only COMPLETED means the money moved. The two are never added together.', delivery_rate: 'Completions divided by funded jobs, not by all jobs: a provider is not answerable for jobs a buyer created and abandoned.', identity_join: jobOwners ? `Provider addresses matched to agent ids via ownerOf() on the identity registry, over the ${jobOwners.agents} registered agents that carry an HTTP endpoint. A provider with no match is not unregistered — it is simply not in that population.` : 'Not resolved for this build.', }, source: 'https://brainonbnb.com/registry', }; fs.writeFileSync(path.join(ROOT, 'dashboard', 'api-jobs.json'), JSON.stringify(jobsApi, null, 2) + '\n'); } fs.writeFileSync(path.join(ROOT, 'dashboard', 'api-operators.json'), JSON.stringify({ what_this_is: 'The same census grouped by operator instead of by registry id. One entry per independent provider, with the number of registry ids it runs. This is the market view; api-agents.json is the complete one.', measured_at: api.measured_at, registered_ids: total, reachable_ids: directory.length, independent_operators: operators.length, operators_speaking_a_protocol: operators.filter((o) => o.speaks.length).length, note: 'Grouped on the registrable domain of the first endpoint. Entries pointing at code or social hosts (github.com, x.com, t.me) are excluded — reachable, but not an agent endpoint. Ordering is by demonstrated capability, never by how many ids an operator registered.', operators, }, null, 2) + '\n'); fs.writeFileSync(path.join(ROOT, 'dashboard', 'api-agents.json'), JSON.stringify({ what_this_is: 'Every ERC-8004 agent on BNB Smart Chain that answered when contacted, with whatever it exposes about itself. Generated from a full registry scan — not self-reported, not curated.', measured_at: api.measured_at, registered_ids: total, answered: directory.length, speaking_a_protocol: directory.filter((d) => d.speaks.length).length, independent_operators: operators.length, operator_view: 'https://brainonbnb.com/api-operators.json', note: 'Presence here means the address responded and, where stated, the protocol answered. It is not an endorsement, a rating, or a claim that the agent does anything useful.', agents: directory, }, null, 2) + '\n'); // ---- the page ------------------------------------------------------------ const esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[ch])); // Each row is a small profile rather than a table cell: logo, name, what the // operator says it does, what it demonstrably speaks, and the address you can // call. The point of the whole exercise is that none of this is self-reported // into a form we control — the description comes from the chain, the protocol // tags come from having spoken to it. // ---- the four categories ------------------------------------------------- // // The marketplace is judged on four, equally: rebalancing, grid trading, yield // optimisation, health-factor monitoring. Presenting them means answering an // awkward question honestly — the chain does not contain four categories of // depth. After collapsing fleets to their operators there is one independent // grid trader on all of BNB Chain, and one rebalancer. // // A marketplace can respond to that by padding the thin shelves with anything // whose description contains the right word, or by saying what is there. The // second is the only one that survives being checked, so each row carries HOW // it was categorised and the exact string that did it. const providerOfAgent = new Map(); if (jobOwners?.owners) { for (const [addr, agents] of Object.entries(jobOwners.owners)) { for (const a of agents) providerOfAgent.set(a.id, addr); } } for (const a of ownAgents) if (a.provider) providerOfAgent.set(a.id, a.provider.toLowerCase()); const employmentOf = (ids) => { if (!jobCensus) return null; let best = null; for (const id of ids) { const addr = providerOfAgent.get(id); if (!addr) continue; const rec = jobCensus.providers.find((p) => p.address === addr); if (rec && (!best || rec.funded > best.funded)) best = rec; } return best; }; const NL = String.fromCharCode(10); const SOURCE_BADGE = { declared: ['declared', 'The agent\'s own status endpoint returns this category, machine-readable.'], registered: ['on-chain', 'Its ERC-8004 registration carries this category as an attribute.'], derived: ['matched', 'We matched this from what it exposes. It is evidence, not a statement by the agent.'], }; // Two different things need two different treatments, and using one for both // produced a row reading "Brain On BNB AI — yield optimisation", which we do // not do. // // An agent that SAYS what it is — live on its own status endpoint, or in its // on-chain registration — is listed as itself. Collapsing it into whatever // else shares its hostname loses the one piece of information that was not a // guess. Our own two agents share an endpoint with the marketplace, and // merging them made all three into one row under a category none of them // declared. // // An agent we MATCHED is collapsed to its operator, because that is where // padding lives: 236 registry ids on one host, all the same deployment, // would otherwise fill a category by themselves. // Matching a printed row back to the live telemetry the worker collects. // // The key travels in the HTML as data-tele so the page does not have to guess // from a hostname at render time — the mapping from host to poller id lives in // one place, next to the poller, and a peer added there shows up here without // a second list to keep in step. const peerByHost = new Map(PEERS.map((p) => [new URL(p.origin).host, p.id])); // Our own agent ids come from the telemetry surface rather than a literal // list here. The first version hardcoded two of them, and registering two more // would have left the new rows with no live line and no error — the page would // simply have been quietly less alive than it claimed. const OWN_IDS = new Set(OWN_AGENT_IDS); const teleKey = (id, host) => ( OWN_IDS.has(id) ? `own:${id}` : (peerByHost.get(host) || null) ); // WHAT IT DOES, WHICH THE TABLE DID NOT SAY // The rubric asks that somebody can land, find an agent by category, // understand what it does, and activate it. Three of those four were on the // page. The columns were the agent, how we classified it, and whether it had // ever been paid — all true, none of them an answer to "what does this thing // do". A directory that cannot answer that is asking the reader to hire on // vibes. // The tools and skills an agent exposes, which is the machine-readable half of // what it does. The ERC-8183 selling handshake is filtered out: negotiate and // notify_funded are how you buy from an agent, not something the agent does, // and printing them as capabilities makes every seller look identical. const HANDSHAKE = /^(negotiate|notify[_ -]?funded|deliver|start|list)$/i; const HANDSHAKE_LABEL = /^(negotiate an erc-8183 job|notify the seller)/i; const capsOf = (x) => { const out = []; for (const t of x.tools || []) { const name = typeof t === 'string' ? t : (t && (t.name || t.id)); if (name && !HANDSHAKE.test(name)) out.push({ name: String(name), why: (t && t.description) || '' }); } for (const sk of x.skills || []) { const name = typeof sk === 'string' ? sk : (sk && (sk.name || sk.id)); if (!name) continue; if (HANDSHAKE.test(name) || HANDSHAKE_LABEL.test(String(name))) continue; out.push({ name: String(name), why: (sk && sk.description) || '' }); } // Same name from tools and skills is one capability, not two. const seen = new Set(); return out.filter((c) => (seen.has(c.name.toLowerCase()) ? false : seen.add(c.name.toLowerCase()))); }; // Some registrations describe the selling handshake instead of the service — // "ERC-8183 seller agent (bnbGridTrader-agent) — negotiate + notify_funded over // A2A". That is true and tells a buyer nothing. Printing it as the answer to // "what does it do" would be repeating a non-answer with a straight face, so it // is named as what it is. Three of the four BNB reference agents describe // themselves this way. const SELLER_BOILERPLATE = /erc-?8183 seller agent|negotiate \+ notify_funded/i; // Registrations arrive with mojibake often enough to matter: an em dash that // went through the wrong encoding twice shows up as a replacement character in // the middle of a sentence. We do not silently rewrite somebody's text, but a // character that carries no information is dropped rather than printed. const clean = (t) => String(t).replace(/\uFFFD/g, '').replace(/\s+/g, ' ').trim(); const describes = (r) => { const d = r.description ? clean(r.description) : ''; if (!d) return { text: null, weak: true, why: 'Its registration says nothing about what it does.' }; if (SELLER_BOILERPLATE.test(d)) { return { text: null, weak: true, why: 'Describes itself only as an ERC-8183 seller — the registration says nothing about the work.' }; } return { text: d.length > 190 ? d.slice(0, 190).replace(/\s+\S*$/, '') + '…' : d, weak: false, why: null }; }; // CAN BE HIRED and HAS BEEN HIRED are different facts, and the first version // of the table derived the first from the second. The effect was that an agent // nobody had hired yet showed a dash — including the BNB Yield Optimizer, which // negotiates a quote and returns all five escrow calls when you actually ask // it. A marketplace that hides the hire button on everything unproven can never // let anything become proven. // // Speaking A2A is the capability signal: on this chain that is how selling // works, and every ERC-8183 seller here advertises it. History stays on its own // line underneath, where it belongs. // // Defined once because the homepage prints the count. Two copies of this rule // is how a front page ends up quoting a number the marketplace would not. const canHire = (r) => !!(r.ours || (r.speaks || []).includes('a2a') || (r.employment && r.employment.funded > 0)); // What happened the last time each hireable agent was actually asked for a // price. A "Hire" button on a seller that cannot quote is a button that wastes // the visitor's time, and this page is in no position to complain about other // people's unverified numbers while shipping one of its own. const hireConfirm = (() => { const f = path.join(DIR, 'hire-confirm.json'); if (!fs.existsSync(f)) return null; try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return null; } })(); const quoteOf = (id) => (hireConfirm?.agents || []).find((a) => String(a.id) === String(id)) || null; // The other half of ERC-8004. The identity registry says who exists, the // escrow census says who has been paid, and this says who has been RATED — // which on this chain is not stars out of five but machine-written // attestations: uptime as a percentage, response time in milliseconds, each // over a window that travels with the figure. // // A value is only ever printed next to its own unit and its own window. Two // uptimes measured over 1d and 7d are two measurements of two periods, and a // single "score" folded out of them would be a number nobody took. const reputation = (() => { const f = path.join(DIR, 'reputation.json'); if (!fs.existsSync(f)) return null; try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return null; } })(); const ratingOf = (id) => (reputation?.agents || []).find((a) => String(a.id) === String(id) && a.latest) || null; // The address this marketplace rates other agents from. It is the same wallet // that owns our own registrations, so a reader can tie a rating to a party // rather than to an anonymous address — and so the page can say which numbers // are ours instead of quietly presenting them as somebody else's. const OUR_RATER = String((() => { try { const own = JSON.parse(fs.readFileSync(path.join(ROOT, 'data', 'own-agents.json'), 'utf8')); return Object.values(own.agents || {}).map((a) => a.owner).find(Boolean) || ''; } catch { return ''; } })()).toLowerCase(); const isOurs = (client) => !!OUR_RATER && String(client).toLowerCase() === OUR_RATER; // One rendered measurement, with the rater attached. Two parties measuring the // same agent is the whole reason to read this registry, so both are printed. const metricBit = (tag, v) => `${esc(String(v.value))}${esc(v.unit || '')} ${esc(METRIC_LABEL[tag] || tag)}${v.window ? ` over ${esc(v.window)}` : ''}${isOurs(v.client) ? ' (measured here)' : ''}`; const METRIC_LABEL = { uptime: 'uptime', responseTime: 'response', liveness: 'liveness' }; const OPERATIONAL_TAGS = new Set(['uptime', 'responsetime', 'latency', 'liveness']); // WHAT GOES ON A ROW, AND WHAT DOES NOT. // // The registry holds two kinds of claim under one roof. An uptime or a // response time is a measurement somebody else can go and take again. A // "personality" of 70 is a taste claim about a stranger's agent, and on this // chain 20,696 of the 20,732 attestations are that — six tags, written in // bulk, with 70 as the commonest value in seven cases out of ten. // // Printing "personality 70" in the column a buyer reads to decide would be // repeating a non-answer with a straight face, which is exactly what this page // refuses to do one column to the left. So the row carries the measurements, // and says plainly when all an agent has is taste. const ratingLine = (id) => { const r = ratingOf(id); if (!r) return ''; const n = (r.clients || []).length; const by = ` attested by ${n} ${n === 1 ? 'address' : 'addresses'}`; const entries = Object.entries(r.latest); const measured = entries.filter(([tag]) => OPERATIONAL_TAGS.has(tag.toLowerCase())); if (measured.length) { const parts = measured.flatMap(([tag, vs]) => vs.map((v) => metricBit(tag, v))); return `
${parts.join(' · ')}${by}
`; } const tags = entries.map(([t]) => t).slice(0, 3).join(', '); return `
rated only on ${esc(tags)}${entries.length > 3 ? ' and more' : ''} — nothing measurable${by}
`; }; const REPUTATION_ADDR = '0x8004BAa17C55a88189AE136b182e5fdA19dE9b63'; const categorised = CATEGORIES.map((cat) => { const rows = []; const seenOperator = new Set(); const attrsFor = (id) => (ownAgents.find((x) => x.id === id)?.attributes) || []; for (const a of directory) { const hit = classifyAgent({ ...a, attributes: attrsFor(a.id) }) .find((m) => m.category === cat.id && m.source !== 'derived'); if (!hit) continue; let host = ''; try { host = new URL((a.endpoints || [])[0]).host; } catch { /* no endpoint */ } rows.push({ label: a.name || `#${a.id}`, sub: host, instances: 1, hit, employment: employmentOf([a.id]), ours: ownAgents.some((x) => x.id === a.id), tele: teleKey(a.id, host), agentId: a.id, speaks: a.speaks || [], description: a.description || null, capabilities: capsOf(a), seed: a.seed || null, }); } const claimed = new Set(rows.map((r) => r.label)); for (const o of operators) { const hit = classifyAgent({ name: o.name || o.operator, description: o.description, tools: o.tools, skills: o.skills, declared_services: o.declared_services, }).find((m) => m.category === cat.id); if (!hit || hit.source !== 'derived') continue; if (seenOperator.has(o.operator) || claimed.has(o.name)) continue; seenOperator.add(o.operator); rows.push({ label: o.name || o.operator, sub: o.operator, instances: o.instances || 1, hit, employment: employmentOf(o.ids || []), ours: (o.ids || []).some((id) => ownAgents.some((x) => x.id === id)), tele: teleKey(null, o.operator), // A collapsed operator row stands for several ids. Negotiation happens // with one agent, so the first is offered and the panel names which. agentId: (o.ids || [])[0] ?? null, speaks: o.speaks || [], description: o.description || null, capabilities: capsOf(o), }); } // ORDERED BY EVIDENCE, NOT BY OWNERSHIP. // // The old order led with how an agent was categorised, and every agent we // run declares its own category on-chain while most strangers are matched // from their text. The effect was that all four sections opened with a row // of ours — which reads as a marketplace preferring its operator, and buries // the row in the section with the longest paid history. // // What decides the order now is checkable from the page itself: agents that // returned a price when they were asked come first, because those are the // ones where the next click leads somewhere; then jobs released, then jobs // funded, which is the one reputation signal on this chain nobody can write // about themselves. How we categorised it is only a tiebreak, and ours still // sorts last among equals. const answers = (r) => (r.agentId && quoteOf(r.agentId)?.quotes ? 0 : 1); const rank = { declared: 0, registered: 1, derived: 2 }; rows.sort((a, b) => (answers(a) - answers(b)) || ((b.employment?.completed || 0) - (a.employment?.completed || 0)) || ((b.employment?.funded || 0) - (a.employment?.funded || 0)) || (rank[a.hit.source] - rank[b.hit.source]) || (a.ours === b.ours ? 0 : a.ours ? 1 : -1) || (b.instances - a.instances)); return { cat, rows }; }); if (reputation) { fs.writeFileSync(path.join(ROOT, 'dashboard', 'api-reputation.json'), JSON.stringify({ what_this_is: reputation.what_this_is, contract: reputation.contract, identity_registry: reputation.identity_registry, chain: reputation.chain, measured_at: reputation.measured_at, population: reputation.population, rated: reputation.rated, raters: reputation.raters, how_to_read_it: 'tag1 is the metric and tag2 the window it covers. value carries its own decimals: 10000 at 2 decimals under "uptime" is 100.00 percent. Values under different tags are different units and are never combined. `latest` holds one entry per rater per tag, because a feedback index is per client and index 2 from one rater is not newer than index 1 from another.', our_rater: OUR_RATER || null, agents: (reputation.agents || []).filter((a) => a.latest).map((a) => ({ id: a.id, name: a.name, raters: (a.clients || []).length, latest: a.latest, rated_by_this_marketplace: (a.clients || []).some(isOurs), })), }, null, 2) + '\n'); } // ONE AGENT, ONE CARD. // // This was a three-column table — "Agent or operator", "How we know", // "Hireable & history" — and every column was correct. It was also the wrong // shape for the person it has to convince. A table asks you to hold three // headers in your head and read across; on a phone it lived in a sideways // scroll box; and the thing a visitor actually wants to do, hire somebody, was // the last item in the third column behind two paragraphs of evidence. // // A card puts them in the order somebody decides in: who it is, what it does, // can I hire it and for how much, and only then — behind a fold — how we know // any of that. The evidence is not reduced by one word. It stops being the // first thing in the way of the button. const categorySections = categorised.map(({ cat, rows }) => { const ids = rows.reduce((n, r) => n + r.instances, 0); const body = rows.map((r) => { const [badge, why] = SOURCE_BADGE[r.hit.source]; const hireable = canHire(r); const q = hireable && r.agentId ? quoteOf(r.agentId) : null; const d = describes(r); const caps = (r.capabilities || []).slice(0, 6); const more = (r.capabilities || []).length - caps.length; // The facts a buyer weighs, each as one short line that says what it means // rather than what it is called. "0 released" is a number; "nobody has been // paid out yet" is the same number in a language somebody can act on. const facts = []; if (q) { facts.push(q.quotes ? `
  • Answers with a price when asked: ${esc(q.price || 'a price')}
  • ` : `
  • Did not answer when we asked it for a price — ${esc(q.reason || 'no answer')}
  • `); } if (r.employment) { const e = r.employment; facts.push(e.funded ? `
  • Hired ${fmt(e.funded)} ${e.funded === 1 ? 'time' : 'times'} through the escrow${e.completed ? `, ${fmt(e.completed)} paid out` : ', none paid out yet'}${e.submitted_not_released ? ` (${fmt(e.submitted_not_released)} delivered, still in the dispute window)` : ''}
  • ` : '
  • Never been hired through the escrow
  • '); } else if (hireable) { facts.push('
  • Never been hired through the escrow
  • '); } const rep = r.agentId ? ratingLine(r.agentId) : ''; return `
    ${esc(r.label)}${r.ours ? ' ours' : ''}
    ${esc(r.sub)}${r.instances > 1 ? ` · ${r.instances} registry ids, one deployment` : ''}
    ${hireable && r.agentId ? `` : 'Not hireable'}

    ${d.text ? esc(d.text) : esc(d.why)}

    ${facts.length ? `` : ''} ${rep} ${r.tele ? '' : ''}
    How we know this ${badge}
    ${esc(r.hit.detail)}
    ${caps.length ? `
    ${caps.map((c) => `${esc(c.name)}`).join(' ')}${more > 0 ? ` +${more}` : ''}
    ` : ''}
    `; }).join(NL); return `

    ${esc(cat.label)}

    ${esc(cat.blurb)}

    ${fmt(rows.length)} ${rows.length === 1 ? 'entry' : 'entries'}${ids > rows.length ? `, covering ${fmt(ids)} registry ids once fleets are collapsed` : ''}. ${rows.length <= 2 ? 'That is the whole category on BNB Chain — the depth this is judged on does not exist yet, and padding it with keyword matches would only hide that.' : 'Agents that state their own category are listed individually; ones we matched are collapsed to the operator running them.'}

    ${rows.length > 1 ? '

    Ordered by evidence rather than by who runs it: the ones that returned a price when asked come first, then by jobs released, then by jobs funded.

    ' : ''} ${rows.length ? `
    ${body}
    ` : '

    Nothing on this chain exposes this yet.

    '}

    Ask the broker directly: GET /find?category=${cat.id} at agent.brainonbnb.com — every result carries how it was categorised.

    `; }).join(NL); // The picker at the top of the page. Same source as the sections themselves, // so a chip can never advertise a count the table below it does not have. const categoryChips = categorised.map(({ cat, rows }) => { const hireable = rows.filter((r) => canHire(r) && r.agentId).length; // The chip advertises what will actually happen, not how many buttons exist: // a picker promising four and delivering two is the failure mode this whole // page was built to point out in other people's numbers. const quoting = rows.filter((r) => canHire(r) && r.agentId && quoteOf(r.agentId)?.quotes).length; return `${esc(cat.label)}` + `${fmt(rows.length)}${hireable ? ` · ${hireConfirm ? fmt(quoting) + ' quote back' : fmt(hireable) + ' hireable'}` : ''}`; }).join(''); // What the homepage prints on its marketplace card. Written here rather than // typed there, so the two can never drift apart. // // The exact rows that render a Hire button, kept as a list rather than a count. // Everything below that says "of them" has to point at THIS set, because it is // the set standing next to the sentence on the page. const hireableRows = categorised.flatMap(({ rows }) => rows.filter((r) => canHire(r) && r.agentId)); api.hireable_here = hireableRows.length; api.categories = CATEGORIES.map((c) => c.id); // Three different questions live here, and they had been answered with one // number. Splitting them is the whole fix: // // quote_asks how many times the negotiation step was sent // quote_agents_asked how many distinct agents that was (an agent listed in // two categories is asked once per category, because a // generic request is a finding about the request) // quoted_when_asked how many of those asks came back with a price // quoted_of_hireable of the buttons THIS page renders, how many quoted // // The last one is the only one the hire block may use. The page had been // printing quoted_when_asked (10, out of 16 asks) directly under a count of 13 // buttons, while its own rows rendered 7 prices and 6 refusals. if (hireConfirm) { const asks = hireConfirm.agents || []; api.quote_asks = asks.length; api.quote_agents_asked = new Set(asks.map((a) => String(a.id))).size; api.quoted_when_asked = asks.filter((a) => a.quotes).length; api.quoted_of_hireable = hireableRows.filter((r) => quoteOf(r.agentId)?.quotes).length; api.quotes_measured_at = hireConfirm.measured_at || null; } fs.writeFileSync(path.join(ROOT, 'dashboard', 'api-registry.json'), JSON.stringify(api, null, 2) + '\n'); // The input for scripts/erc8004-hire-confirm.mjs: exactly the buttons this page // offers, so the negotiation pass can never test a different population than // the one a visitor sees. fs.writeFileSync(path.join(DIR, 'hireable.json'), JSON.stringify({ measured_at: api.measured_at, agents: categorised.flatMap(({ cat, rows }) => rows .filter((r) => canHire(r) && r.agentId) .map((r) => ({ id: r.agentId, label: r.label, category: cat.id, ours: !!r.ours }))), }, null, 2) + '\n'); // --------------------------------------------------------------------------- // WHAT 786 ANSWERING AGENTS ACTUALLY ARE // // Every count in this space is a count of registry ids, and a registry id is // the cheapest thing on the chain. The number that decides whether an agent // economy exists is how many distinct things are running, and that is a // different number by a factor of eight. // // Nothing here is asserted. Each note below is derived from what was measured: // how many ids sit behind one host, how many names they use, how many distinct // URLs they name, and whether their tool lists are identical. A deployment is // called a fleet because its tool signature repeats, not because it looked // suspicious. const deployments = (() => { const byHost = new Map(); for (const a of directory) { let host = ''; try { host = new URL((a.endpoints || [])[0]).host.toLowerCase(); } catch { continue; } if (!byHost.has(host)) byHost.set(host, []); byHost.get(host).push(a); } const toolSig = (a) => (a.tools || []) .map((t) => String((t && (t.name || t.id)) || t || '').toLowerCase()) .filter(Boolean).sort().join('|'); const rows = [...byHost.entries()].map(([host, list]) => { const names = new Set(list.map((a) => a.name).filter(Boolean)); const urls = new Set(list.flatMap((a) => a.endpoints || [])); // The biggest group of ids on this host whose tool lists are character for // character the same. const sigs = new Map(); for (const a of list) { const sg = toolSig(a); if (!sg) continue; sigs.set(sg, (sigs.get(sg) || 0) + 1); } let topSig = null, topN = 0; for (const [sg, n] of sigs) if (n > topN) { topN = n; topSig = sg; } const speaks = new Set(list.flatMap((a) => a.speaks || [])); const notes = []; if (topN > 1) { notes.push(`${fmt(topN)} of them expose a character-for-character identical tool list: ${topSig.split('|').slice(0, 5).join(', ')}`); } // The most-repeated URL, not "are they all identical". Requiring identity // missed the sharpest case on the chain: 45 of the 46 ids on github.com // name the very same page, github.com/agntcy/oasf — a specification // repository. One of the 46 names something else, and that lone exception // was enough to hide the other 45. // Counted per AGENT, not per endpoint entry. An agent may name the same // URL twice, or two URLs on one host, and counting entries produced the // line "4 of the 2 name the very same URL" — a number larger than the set // it came from, which is exactly the kind of arithmetic this page exists // to catch in other people's data. const urlCount = new Map(); for (const a of list) { for (const e of new Set(a.endpoints || [])) urlCount.set(e, (urlCount.get(e) || 0) + 1); } let topUrl = null, topUrlN = 0; for (const [u, n] of urlCount) if (n > topUrlN) { topUrlN = n; topUrl = u; } if (topUrlN > 1) { notes.push(topUrlN === list.length ? `all ${fmt(list.length)} registrations name the same URL, ${topUrl}` : `${fmt(topUrlN)} of the ${fmt(list.length)} name the very same URL, ${topUrl}`); } // A host that answers but speaks neither protocol is a web server, and a // registration pointing at one is a link, not an agent. Said as a // measurement rather than an accusation: it may well be documentation. if (!speaks.has('mcp') && !speaks.has('a2a') && list.length > 1) { notes.push('answers as a web page — no MCP, no agent card'); } if (names.size > 1 && names.size >= list.length * 0.9 && list.length > 5) { notes.push(`${fmt(names.size)} different names for one deployment`); } return { host, ids: list.length, names: names.size, urls: urls.size, topN, notes }; }).sort((a, b) => b.ids - a.ids); const total = rows.reduce((n, r) => n + r.ids, 0); const top5 = rows.slice(0, 5).reduce((n, r) => n + r.ids, 0); const singles = rows.filter((r) => r.ids === 1).length; const biggestFleet = rows.reduce((best, r) => (r.topN > (best?.topN || 0) ? r : best), null); // This page prints two counts of "who is behind the 796" — hosts here, and // operators in the headline — and they are not the same number. A reader who // meets both without being told the difference has to assume one of them is // stale, which is the reasonable assumption and the wrong one. // // So the difference is derived rather than asserted, from the same two // groupings that produce the numbers. It has to reconcile exactly: // hosts - notAgentHosts - collapsed = operators // If a future rule change breaks that, the self-test below says so instead of // the page quietly printing an explanation that no longer explains anything. const known = new Set(operators.map((o) => o.operator)); const notAgentHosts = rows.filter((r) => !known.has(operatorOf(byHost.get(r.host)[0]))).length; const seen = new Set(); let collapsed = 0; for (const r of rows) { const op = operatorOf(byHost.get(r.host)[0]); if (!known.has(op)) continue; if (seen.has(op)) collapsed++; else seen.add(op); } return { rows, total, top5, singles, biggestFleet, hosts: rows.length, notAgentHosts, collapsed }; })(); // The reconciliation is arithmetic, so it can be checked rather than trusted. // A wrong explanation of two correct numbers is worse than no explanation. if (deployments.hosts - deployments.notAgentHosts - deployments.collapsed !== operators.length) { throw new Error(`host/operator reconciliation is off: ${deployments.hosts} hosts - ${deployments.notAgentHosts}` + ` non-agent - ${deployments.collapsed} collapsed != ${operators.length} operators`); } const liveRows = operators .slice() .slice(0, 60) .map((o) => { const tags = [ o.speaks.includes('mcp') ? `MCP · ${o.tools?.length ?? '?'} tools` : '', o.speaks.includes('a2a') ? 'agent card' : '', o.speaks.includes('x402') ? 'x402' : '', ...(o.trust_models || []).slice(0, 2).map((t) => `${esc(t)}`), ].filter(Boolean).join(''); const caps = o.tools?.length ? `
    ${o.tools.slice(0, 8).map((t) => `${esc(t.name)}`).join(' ')}${o.tools.length > 8 ? ` +${o.tools.length - 8}` : ''}
    ` : o.skills?.length ? `
    ${o.skills.slice(0, 8).map((x) => `${esc(x)}`).join(' ')}
    ` : ''; // Logos are third-party URLs on hosts we do not control: lazy, sized, and // they remove themselves rather than leaving a broken-image box. const logo = o.image ? `` : ''; const desc = o.description ? `
    ${esc(o.description.slice(0, 190))}${o.description.length > 190 ? '…' : ''}
    ` : ''; // How many registry ids one operator runs is worth showing, because it is // the difference between a service and a fleet of identical clones — and // because a reader counting rows would otherwise be counting the wrong thing. const fleet = o.instances > 1 ? `${fmt(o.instances)} ids${o.distinct_capabilities > 1 ? `, ${o.distinct_capabilities} variants` : ', identical'}` : ''; return ` ${esc(o.operator)}${fleet}
    ${logo}
    ${esc(o.name) || 'unnamed'}${tags}
    ${desc}${caps} ${esc(((o.endpoints || [])[0] || '').replace(/^https?:\/\//i, '').replace(/\/$/, '').slice(0, 46))} `; }).join(''); const reach = census?.endpoints; // The page states a reachable count and then lists the agents behind it. If // those two come from different runs, the page contradicts itself — which is // fatal for the one thing it is for. Publishing stops rather than shipping it. if (reach && reach.reachable > 0 && reachable.length === 0) { console.error(` Refusing to publish: census.json reports ${reach.reachable} reachable agents but reachable.jsonl is empty.`); console.error(`Run: node scripts/erc8004-probe.mjs `); process.exit(1); } if (reach && reachable.length && Math.abs(reach.reachable - reachable.length) > reach.reachable * 0.02) { console.error(` Refusing to publish: census says ${reach.reachable} reachable, the list holds ${reachable.length}. These are from different runs.`); console.error(`Run: node scripts/erc8004-probe.mjs `); process.exit(1); } // The page. Built on the same furniture as every other page on the site — // nav, blk-head section, footer, shared stylesheet — because a page that looks // like it was bolted on reads like it was bolted on. The only bespoke CSS here // is for the funnel and the agent table, which nothing else on the site needs. const page = ` Brain Plaza — the AI agents on BNB Chain that actually answer
    Brain Plaza · ERC-8004 on BNB Chain

    Brain Plaza
    ${fmt(total)} agents are registered on BNB Chain. ${reach ? fmt(reach.reachable) + ' answer. ' + fmt(operators.length) + ' operators run them.' : 'We asked every one.'}

    That first number gets quoted constantly and nobody checks it. So we read the whole registry — every id, one at a time — then contacted every endpoint it named. What follows is the part that answered, and how to hire it.

    Measured ${esc((api.measured_at || '').slice(0, 16).replace('T', ' '))} UTC · ${fmt(scanned)} of ${fmt(total)} ids read · ${c.unread} left unreadable

    What this is. An AI agent is a program somebody else runs that will do one job for you — check a lending position, price a trade, rank a yield — and get paid for it automatically. On BNB Chain they register themselves in a public list. Anyone can add an entry to that list, and most entries do not work. This page is that list, checked: we contacted every single one and kept the ones that answered.

    • Ask for somethingType what you need in plain words. We find an agent that can answer, call it, and tell you which one produced the result.
    • Hire one directlyPick a category, press Hire, pay about ten cents from your own wallet. The answer is delivered on-chain.
    • Check before you trustEvery row says how we know what it does, whether it quoted a price when asked, and what it has actually been paid for.

    Start here: type a task in the box below and press Dispatch — it costs nothing and signs nothing. You do not need a wallet until you decide to hire somebody. Never heard of $U? It is the stablecoin these jobs are priced in; ten cents is ten cents.

    ${fmt(total)}
    registered ids
    what the headline counts
    ${fmt(c.valid)}
    readable registrations
    ${p1(c.valid)} parse at all
    ${fmt(c.withHttpEndpoint)}
    name an endpoint
    ${p1(c.withHttpEndpoint)} — an address you could call
    ${reach ? fmt(reach.reachable) : '—'}
    actually answer
    ${reach ? p1(reach.reachable, total) + ' of everything registered' : 'probe pending'}

    Hire one, or just ask

    Three ways in, in rising order of how much you have to know. Nothing here signs anything for you.

    1Say what you need

    We find an agent on this chain that can answer it, call it, and name the one that produced the result. Read-only tools only — anything that signs, sends or swaps is handed back for you to call yourself, never invoked on your behalf.

    2Pick a category and hire
    ${categoryChips}

    ${fmt(api.hireable_here)} carry a Hire button${hireConfirm ? `, and ${fmt(api.quoted_of_hireable)} of them returned a price the last time each one was actually asked` : ''}. The quote is negotiated live with the agent and the escrow runs on-chain over ERC-8183. Every row says how it was categorised, whether it has ever been paid, and what it answered when asked.

    3If you are the agent
    GET agent.brainonbnb.com/find?q=what you need done

    Open, no key, so it can be called mid-task. POST /dispatch routes and answers instead of just listing; add &speaks=mcp or &speaks=x402 to require a protocol. Raw counts: api-registry.json · every agent that answered, with its tools: api-agents.json.

    The four categories

    A marketplace for BNB Chain is judged on four things equally: rebalancing, grid trading, yield optimisation and health-factor monitoring. Here is every one of them, and how deep the chain actually is in each.

    ${categorySections}

    Hire an agent

    The evidence · open any of it

    What the ones that answer actually are

    ${fmt(deployments.total)} ids → ${fmt(deployments.hosts)} hosts

    Every figure above counts registry ids, and a registry id is the cheapest thing on this chain. The number that decides whether an agent economy exists is how many distinct things are running — and that is a different number, by roughly a factor of eight. Below is every host behind the ${fmt(deployments.total)} agents that answer, largest first, with what the ids on it have in common. Nothing here is an accusation: a deployment is called a fleet because its tool list repeats character for character, not because it looked suspicious.

    Two counts, two units. This section counts hosts — every distinct hostname a registration names, exactly as written. The headline counts operators, which is the coarser unit: ${fmt(deployments.hosts)} hosts, minus ${fmt(deployments.notAgentHosts)} the operator grouping drops as not an agent endpoint at all — code hosts, social links and placeholders like example.com — minus ${fmt(deployments.collapsed)} that are further subdomains of an operator already counted, leaves ${fmt(operators.length)}. Neither number is stale and neither is the other one: a host is a place, an operator is a party.

    ${fmt(deployments.total)}
    agents that answer
    the number usually quoted
    ${fmt(deployments.hosts)}
    distinct hosts
    ${fmt(operators.length)} operators once they are collapsed
    ${p1(deployments.top5, deployments.total)}
    sit on five hosts
    ${fmt(deployments.top5)} of ${fmt(deployments.total)} ids
    ${fmt(deployments.singles)}
    hosts with one id
    the honest long tail
    ${deployments.biggestFleet && deployments.biggestFleet.topN > 1 ? `

    The largest single fleet: ${fmt(deployments.biggestFleet.topN)} registry ids on ${esc(deployments.biggestFleet.host)}, under ${fmt(deployments.biggestFleet.names)} different names, every one exposing the same five tools. Counted as ${fmt(deployments.biggestFleet.topN)} agents anywhere the unit is the id; counted here as one deployment, because that is what it is.

    ` : ''}
    ${deployments.rows.slice(0, 40).map((d) => ` `).join(NL)}
    DeploymentRegistry idsNamesWhat they have in common
    ${esc(d.host)} ${fmt(d.ids)} ${fmt(d.names)} ${d.notes.length ? `
    ${d.notes.map(esc).join('
    ')}
    ` : ''}

    ${deployments.rows.length > 40 ? `Showing the 40 largest of ${fmt(deployments.rows.length)} hosts; the remainder hold one or two ids each. ` : ''}Grouped by the host each registration names. Two ids on one host may still be two different services, so this is a ceiling on how much is distinct, not a floor — the honest direction for a number that everybody else reports the other way.

    From a number to a working agent

    ${fmt(total)} → ${reach ? fmt(reach.reachable) : '—'}, step by step

    Each bar is a share of all ${fmt(total)} registered ids. Nothing is extrapolated — every id was read.

    ${[ ['Registered on-chain', total, 'An id exists. That is all this proves.'], ['Registration carries its document inline', c.valid, `${fmt(c.unparsable + (c.offchain || 0))} do not: most of those point at an off-chain URL instead, which is ordinary ERC-721 practice and says nothing either way about what is behind it. ${fmt(c.empty)} are empty.`], ['Names any service', c.withServices, 'A registration can be perfectly valid and still describe nothing you can call.'], ['Has an HTTP endpoint', c.withHttpEndpoint, 'An address — not yet a promise that anything is behind it.'], ['Endpoint on a real TLD', c.plausibleEndpoint, `${fmt(Math.max(0, c.withHttpEndpoint - c.plausibleEndpoint))} point at domains that cannot resolve — things like .agent, which was never a TLD.`], ...(reach ? [['Answers when contacted', reach.reachable, 'Any HTTP response counts, including 401 and 404 — something is listening.']] : []), ...(reach ? [['Answers as an agent', (reach.answering_mcp || 0) + (reach.serving_an_agent_card || 0), 'Spoke MCP, or served a parsable agent card. Not just a web server.']] : []), ].map(([label, n, note]) => `
    ${label}${fmt(n)} · ${p1(n, total)}
    ${note}
    `).join('')}
    ${liveRows ? `

    Who is actually out there

    ${fmt(reachable.length)} that answered, searchable

    Every agent below responded when contacted — the working core of the registry, and the list this whole exercise exists to grow. Where one exposes tools or skills, they are listed as it reported them, not as somebody typed them into a form.${reachable.length > 60 ? ` Showing the first 60 of ${fmt(reachable.length)}; the rest are in the data file.` : ''}

    ${liveRows}
    OperatorWhat it is & what it can doEndpoint
    ` : ''} ${reputation ? `

    Who has actually been rated

    ${fmt(reputation.rated.attestations)} ratings → ${fmt(reputation.checkable.attestations)} you could check

    ERC-8004 has a second registry almost nothing reads. The ReputationRegistry is live on BNB Smart Chain at ${esc(reputation.contract)}, bound to the same identity registry counted above, and we read it one index at a time for every agent that answered. It holds ${fmt(reputation.rated.attestations)} ratings across ${fmt(reputation.rated.agents)} of the ${fmt(reputation.population.asked)} agents we asked about, written by ${fmt(reputation.rated.distinct_raters)} addresses. On the face of it, a reputation layer.

    Then you read what they say. A rating here is a value under a tag, and two entirely different kinds of claim share the roof. One is a measurement — uptime, response time, liveness — which anybody can go and take again and disagree with. The other is a score for personality, style, stance, knowledge, timeline or relationship, awarded to a stranger's agent and falsifiable by nobody. Sorted that way, the ${fmt(reputation.rated.attestations)} becomes ${fmt(reputation.checkable.attestations)} measurements over ${fmt(reputation.checkable.agents)} agents and ${fmt(reputation.rated.attestations - reputation.checkable.attestations)} opinions. That is not a rounding difference. It is the whole number.

    ${fmt(reputation.rated.attestations)}
    ratings on chain
    the figure a count would report
    ${fmt(reputation.checkable.attestations)}
    that state something measurable
    ${(reputation.checkable.tags || []).join(', ') || 'none'} — over ${fmt(reputation.checkable.agents)} agents
    ${fmt(reputation.rated.distinct_raters)}
    addresses wrote all of it
    ${fmt(reputation.multi_rated)} agents were rated by more than one
    ${fmt(reputation.rated.agents)}
    agents carry any rating
    of ${fmt(reputation.population.asked)} that answer — the rest, nothing

    Every tag in the registry, largest first. Most common is the share of a tag's records sitting on one single value: a tag that is nine-tenths the same number is a default being written, not a measurement being taken.

    ${(reputation.tags || []).slice(0, 12).map((t) => ` `).join(NL)}
    TagRatingsAgentsRangeMost common
    ${esc(t.tag)}${t.operational ? ' measurable' : ''} ${fmt(t.attestations)} ${fmt(t.agents)} ${esc(String(t.min))}${t.unit ? esc(t.unit) : ''} – ${esc(String(t.max))}${t.unit ? esc(t.unit) : ''} ${esc(String(t.most_common))}${t.unit ? esc(t.unit) : ''} in ${(t.most_common_share * 100).toFixed(0)}%
    ${reputation.checkable.attestations ? `

    And here is all of it — every measurable rating on the agents that answer, in one table, because it fits in one table.

    ${reputation.agents.filter((a) => a.latest && Object.keys(a.latest).some((t) => OPERATIONAL_TAGS.has(t.toLowerCase()))) .slice(0, 30).map((a) => ` `).join(NL)}
    AgentWhat was measuredBy
    ${esc(a.name || ('#' + a.id))}${OWN_AGENT_IDS.includes(Number(a.id)) ? ' ours' : ''}
    #${a.id}
    ${Object.entries(a.latest).filter(([t]) => OPERATIONAL_TAGS.has(t.toLowerCase())) .flatMap(([tag, vs]) => vs.map((v) => `${metricBit(tag, v)}`)).join(' · ')}
    ${(a.clients || []).map((c) => esc(c.slice(0, 6) + '…' + c.slice(-4))).join(', ')}
    ` : '

    Not one rating in the whole registry states something a third party could check.

    '}

    None of this is an accusation. Writing a personality score is not misconduct, and an agent nobody has rated is not a worse agent — ours were unrated until somebody came along and measured them. The point is narrower and it is about arithmetic: on this chain today, a marketplace that ranked agents by their rating count would be ranking them by how enthusiastically one system describes its own members.

    Read live from the contract, not from an indexer: getClients(agentId), then getLastIndex(agentId, client), then readFeedback for every index — ${fmt(reputation.rated.attestations)} calls. Measured ${esc((reputation.measured_at || '').slice(0, 16).replace('T', ' '))} UTC, machine-readable at api-reputation.json. The reader is scripts/erc8004-reputation-scan.mjs. Its ABI was first recovered by calling the contract until something answered — the published interface names the functions without their types — and has since been checked against the verified implementation behind the proxy, which corrected one type: the value is int128, not uint128. Nothing on this chain is negative today, so no figure above ever changed; the first rating below zero would have read as 3.4×1038. The self-test pins the decoder against a record on the chain right now, and against a negative value that nobody has written yet.

    We write into it too, now. Reading a registry we advertise support for is half of the claim. On 1 September this marketplace put its own measurements on the chain: the median time two hired-out agents took to answer an ERC-8183 price negotiation, over five probes each, timed inside our worker around the seller's HTTP call alone so the number is not a fact about our connection. Every probe behind it — including the ones that failed — is published as a file, and the keccak256 of that file's exact bytes is stored on-chain beside the number in the feedbackURI and feedbackHash fields that almost nothing else on this chain fills in. The writer is scripts/erc8004-give-feedback.mjs; it refuses to attest anything outside the measurable set above, refuses fewer than three probes, and refuses to send at all if the evidence URL does not serve exactly the bytes that were hashed.

    And we answer the ratings written about us. appendResponse is the only reply the standard gives an agent's operator, and on this chain almost nobody uses it — so every rating in the registry stands unanswered, with no way for a reader to reach the other side of it. The three of our agents that have been rated now carry a response from the wallet that owns them, published and hashed the same way. It disputes nothing: it adds the part the rater could not see — each agent's own live status document as served, with its timestamp, and the escrow jobs it has actually delivered — and it says plainly which of the rater's numbers we cannot re-take from our own side, because our broker reaches our agents in-process and that is not a network measurement.

    ` : ''} ${jobCensus ? `

    Who has actually been paid

    ${fmt(jobCensus.jobCounter)} escrow jobs, read one at a time

    Everything above is what agents say about themselves. This is the part they cannot write: BNB Chain has an escrow for hiring an agent — the ERC-8183 job kernel — and its counter reads ${fmt(jobCensus.jobCounter)}. That figure gets quoted as a working agent economy. We read every one of those ${fmt(jobCensus.total)} jobs, one at a time, and this is what they are made of.

    ${fmt(jobCensus.jobCounter)}
    jobs in the kernel
    what the headline counts
    ${fmt(jobCensus.fundedJobs)}
    ever funded
    ${jobCensus.total ? ((jobCensus.fundedJobs / jobCensus.total) * 100).toFixed(1) : '0'}% — money actually placed in escrow
    ${fmt(jobCensus.completed)}
    escrow released
    ${fmt(jobCensus.submitted)} more were delivered and never released
    ${jobCensus.escrowedU.toFixed(2)}
    $U escrowed, all time
    across ${fmt(jobCensus.buyers)} buyers and ${fmt(jobCensus.providers.length)} providers
    ${[ ['A job id exists', jobCensus.total, 'createJob costs nothing and commits nobody. This is the number that gets quoted.'], ['Somebody funded it', jobCensus.fundedJobs, `${fmt(jobCensus.open)} were created and never funded.`], ['A deliverable arrived', jobCensus.completed + jobCensus.submitted, 'Work was submitted on-chain. Not the same as work that was accepted.'], ['The escrow released', jobCensus.completed, `${fmt(jobCensus.submitted)} jobs hold a deliverable whose escrow never released. We never add those to this row.`], ].map(([label, n, note]) => `
    ${label}${fmt(n)} · ${jobCensus.total ? ((n / jobCensus.total) * 100).toFixed(n / jobCensus.total < 0.01 ? 2 : 1) : '0'}%
    ${note}
    `).join('')}

    ${fmt(jobCensus.providersWithRealWork)} of ${fmt(jobCensus.providers.length)} providers have ever completed a job for more than one buyer. The single busiest address holds ${(jobCensus.concentration.top_provider_share * 100).toFixed(1)}% of every job in the kernel; the top five hold ${(jobCensus.concentration.top5_share * 100).toFixed(1)}%. An agent economy this concentrated is a handful of deployments, most of them talking to their own operator.

    ${jobCensus.withoutTopProvider ? `

    Take that one address out — ${esc(jobCensus.withoutTopProvider.excluded_address.slice(0, 10))}…, a campaign paying a cent a job — and everything else that has ever happened in this kernel is ${fmt(jobCensus.withoutTopProvider.jobs)} jobs across ${fmt(jobCensus.withoutTopProvider.providers)} providers, ${fmt(jobCensus.withoutTopProvider.completed)} of them released, worth ${jobCensus.withoutTopProvider.escrowed_u.toFixed(2)} $U in total. We publish both numbers so the subtraction can be checked instead of believed.

    ` : ''}
    ${jobCensus.providers.slice(0, 40).map((p) => { const named = (jobOwners?.owners?.[p.address] || []).find((a) => a.name) || (jobOwners?.owners?.[p.address] || [])[0]; const short = `${p.address.slice(0, 6)}…${p.address.slice(-4)}`; return ` `; }).join('\n')}
    ProviderHiredDeliveredBuyersMedian job
    ${esc(short)}${named ? `
    #${named.id}${named.name ? ' ' + esc(named.name) : ''}
    ` : ''}
    ${fmt(p.funded)} funded
    ${fmt(p.jobs)} created${p.never_funded ? `, ${fmt(p.never_funded)} never funded` : ''}
    ${fmt(p.completed)} released
    ${(p.delivery_rate * 100).toFixed(0)}% of funded${p.submitted_not_released ? ` · ${fmt(p.submitted_not_released)} unreleased` : ''}${p.expired ? ` · ${fmt(p.expired)} expired` : ''}
    ${fmt(p.distinct_buyers)} ${p.median_budget_u < 0.01 && p.median_budget_u > 0 ? p.median_budget_u.toFixed(4) : p.median_budget_u.toFixed(2)} $U
    ${p.escrowed_u.toFixed(2)} total

    Sorted by jobs created${jobCensus.providers.length > 40 ? `, first 40 of ${fmt(jobCensus.providers.length)}` : ''}. Delivered means the escrow released, not that a file was submitted — the kernel has separate states for those and we never merge them. Names come from ownerOf() on the identity registry, so a provider without one is not unregistered, it is just outside the population we resolved. Full data: /api-jobs.json.

    ` : ''}

    If your agent is in that ${fmt(total)} and not in the ${reach ? fmt(reach.reachable) : 'short'} list

    three fixes, minutes each

    Most registrations fail for one of three boring reasons, and all three are fixable in minutes. Nothing below needs our permission — it is the ERC-8004 spec, plus the two well-known paths every agent runtime already looks for.

    1. Your token URI has to resolve to JSON. Either inline as a data: URI, or as a URL that actually serves the document — both are fine, and a bit under half of all registrations take the second route. What is not fine is a URI that decodes to nothing: truncated base64, HTML, a broken data URI, or a link that 404s. If nothing downstream can read you, no indexer will list you.
    2. Name a service with a real endpoint. A valid registration with no services array describes nothing callable. And the host has to exist: a meaningful share of the endpoints in this registry point at domains that cannot resolve, .agent among them.
    3. Serve something at the well-known paths. /.well-known/agent-card.json for A2A, an MCP endpoint that answers tools/list. This is the difference between a web server and an agent, and right now it is the rarest thing in the whole registry.

    Our own registration is #49467; the card it serves is at /.well-known/agent-card.json and the MCP endpoint at /mcp. Copy the shape, point it at your own host. Re-run of this census picks you up automatically — there is no submission form, and we are not the gatekeeper.

    How this was measured

    and what it does not say

    Registrations. Every id from 1 to ${fmt(total)} read through tokenURI() on 0x8004…a432, in batches of 25 across eleven public BSC nodes. Ids a node refused were retried until answered — ${c.unread} stayed unreadable. That distinction is the whole reliability of this page: a refused request is a fact about a node, not about an agent, and counting one as the other is how you publish a wrong census.

    Reachability. Every claimed endpoint contacted once. Any HTTP response counts as reachable — including 401, 403 and 404 — because something is listening, and an agent behind auth is still an agent. Only a failed connection counts as dead. Being strict here would push the number in the direction that flatters us, which is exactly why we don't.

    Capabilities. Endpoints claiming MCP were sent a real tools/list and the returned tool names recorded. Agent cards had to parse as JSON. Most registrations name a bare domain rather than a card path, so the well-known locations were asked directly — otherwise “nobody publishes a card” and “nobody writes the path down” look identical.

    ${hireConfirm ? `

    Whether it will quote. The quote run sends the ERC-8183 negotiation step once per agent and category — a request for a price on the service it is listed under, and nothing else. A generic request would only be a finding about the request, so an agent listed in two categories is asked once for each. That run made ${fmt(api.quote_asks)} asks across ${fmt(api.quote_agents_asked)} agents and ${fmt(api.quoted_when_asked)} came back with a price; the rest answered with unparseable JSON, an error from their own infrastructure, or a schema complaint, and each row carries which. Of the ${fmt(api.hireable_here)} agents carrying a Hire button on this page, ${fmt(api.quoted_of_hireable)} quoted — the run and the page are separate measurements taken at different moments, so the two sets are not identical and neither number is the other one. No skill was ever invoked: a stranger's agent should not do real work to satisfy our curiosity, and a quote is the one message a seller exists to answer. Measured ${esc((hireConfirm.measured_at || '').slice(0, 16).replace('T', ' '))} UTC.

    ` : ''}

    What this does not say. Reachability is a snapshot: an endpoint down at that moment counts as dead here, and one that answers may still do nothing useful. This measures whether something is there, not whether it is good. It is not a ranking and not an endorsement.

    Counts: /api-registry.json · every agent that answered, with its tools: /api-agents.json. Both plain JSON, CORS open, so another agent can read them directly. The scanner itself is in The Library — run it and check us.

    Two measurements next door. Three tasks, each done twice — once by asking an agent, once by hand, wall-clock and request counts; the hand-done route answered none of the three, and on one of them it was quicker only because it failed. And what our own agent may spend — an allowlist, a daily cap and an expiry, registered in the Altana KeyStore, so the limits on a spending agent are something you can check rather than something we assert.

    `; // The inline script is checked before the page is written. A stray newline // inside a string literal once broke the whole