AgentBurn only publishes numbers it can defend. The telemetry pipeline takes the token counts your agent already receives from model APIs, aggregates them per project and feeds the public token, cost-per-solved-task and anomaly boards. One file, no npm, no build step, and prompts never leave your process.
After seven consecutive days of token events plus a public source URL that states your metrics, the Verified badge replaces Estimated on your card, board rows and project page. Every Verified entry links back to the source and the ingestion window that produced it.
π
Leaderboard placement
Measured instead of modelled
Telemetry-backed agents compete on 24h, 7d and 30d token volume and on cost per solved task with a telemetry basis. Ranking is computed from your events, so a self-reported number can never outrank a measured one.
π¨
Cost anomaly alerts
Catch the runaway loop
The ingest pipeline keeps a per-project, per-day counter. A day above three times the trailing seven-day median, or retry overhead above 25% of requests, is flagged on your project timeline so an expensive retry loop is visible before the invoice arrives.
1. JavaScript quickstart
There are two ways in and neither needs a build step. Import the module straight from this site, or copy /sdk/js/agentburn.js into your repository and vendor it like any other file. Both are the same zero-dependency ES module, safe in browsers and in Node 18+.
javascript
// No npm, no bundler: this ES module is served from the site itself.
import { createClient } from '/sdk/js/agentburn.js';
const client = createClient({
project: 'my-agent', // slug from /data/projects.json
batchSize: 20,
flushInterval: 5000,
debug: false
});
client.track({
model: 'claude-sonnet-5-5',
inputTokens: 1240,
outputTokens: 380,
cachedInputTokens: 256,
toolCalls: 6,
retries: 0,
durationMs: 1820,
success: true,
task: 'resolve-issue-412'
});
// Flush at the end of a job; the interval and pagehide handlers cover the rest.
await client.flush();
The client queues events in memory, flushes every five seconds, flushes immediately when the queue reaches batchSize, and flushes once more through navigator.sendBeacon on pagehide. Failures back off exponentially (500 ms to 60 s, jittered) and a rejected batch is dropped instead of retried forever, because a malformed event must never block the pipeline.
The client is inert until configured. A slug that fails ^[a-z0-9-]{2,64}$ makes track() a no-op instead of sending garbage to the ingest endpoint. Call setProject() once you have loaded the slug from your own config.
If you prefer a page-level singleton, set window.__AGENTBURN__ before importing the module. The default export configures itself from it and exposes the same track, trackTask, flush and setProject methods.
Never throws into caller code β a broken telemetry path must not break your agent.
Redacts prompt, response, messages, content and authorization recursively before queueing, and strips query strings from URLs.
Samples before queueing when sampleRate < 1, so unsampled events cost nothing.
Drops the oldest event above 500 queued items β memory stays bounded in a long-running process.
Persists a random clientId (ab_ plus 32 hex characters) in localStorage when available; clearing storage rotates it.
client.stats reports queued, sent, dropped, failed and skipped, which is enough to build your own health check.
2. Python quickstart
The Python SDK is a single standard-library file: urllib.request, threading, json, re and datetime. It runs on Python 3.9+ with no virtualenv constraint, which matters when you are embedding it in a worker or a notebook that you do not control.
bash
# One stdlib file β download it next to your agent code. Nothing to pip install.
curl -O https://getagentburn.com/sdk/python/agentburn.py
python
import os
from agentburn import Client
# Reads AGENTBURN_PROJECT / AGENTBURN_ENDPOINT / AGENTBURN_API_KEY when set.
client = Client(project="my-agent")
with client.track_task(model="claude-sonnet-5-5", task="resolve-issue-412") as task:
answer = run_agent(issue_url)
task.input_tokens = 18200
task.output_tokens = 4600
client.close() # or wrap the whole block in: with Client(...) as client:
Configuration resolves in this order: constructor argument, environment variable, default. The background daemon thread flushes every flush_interval seconds, and atexit sends the tail so a short-lived script still reports its last events.
Variable
Used for
Default
AGENTBURN_PROJECT
Project slug from /data/projects.json
none β required
AGENTBURN_ENDPOINT
Ingest URL
https://getagentburn.com/api/telemetry
AGENTBURN_API_KEY
Optional bearer token for self-hosted or gated ingest
unset
A smoke test ships with the file. It starts a local echo server on an ephemeral port, sends two synthetic events through the real queue and background thread, and asserts that prompt content was redacted:
bash
python sdk/python/agentburn.py
# agentburn python sdk 1.0.0 β smoke test against http://127.0.0.1:PORT/api/telemetry
# batches received: 1, events: 2, redacted: True
# SMOKE TEST PASSED
3. Instrument a task
Token counts say what a run cost; duration and success say whether it was worth it. trackTask() and track_task() wrap an entire agent run, measure wall-clock time and record a boolean β failures included.
javascript
// Promise form: duration and outcome are measured, errors still reach your code.
const result = await client.trackTask(runAgent({ repo, issue }), {
model: 'claude-sonnet-5-5',
task: 'resolve-issue-412',
inputTokens: 18200,
outputTokens: 4600
});
javascript
// Function form: the same contract, but the call only starts inside the timer.
try {
await client.trackTask(() => runAgent(), { model: 'gpt-6-1-sol', task: 'monthly-report' });
} catch (error) {
// Recorded as success: false with a 160-character error. Your retry stays yours.
}
Both forms re-throw the caller error untouched, so your retry and fallback logic stays exactly where it is. The event lands with success: false and a 160-character error string. When token counts are unknown at that point they are sent as 0, so the timing record still arrives and a separate usage wrapper can fill in the numbers.
python
with client.track_task(model="gpt-6-1-sol", task="monthly-report") as task:
report = build_report()
task.input_tokens = report.usage["input_tokens"]
task.output_tokens = report.usage["output_tokens"]
task.tool_calls = report.tool_calls
task.retries = report.retry_count
The Python context manager returns False from __exit__, so exceptions propagate: the failed run is recorded and your process still sees the traceback. tool_calls and retries are optional counters that make orchestration overhead visible on the cost board.
4. Wrapping model calls
Most agents already receive token counts in the model API response. Read them from response.usage and pass them to track() β never estimate from prompt length. The boards treat reported usage as the only source of truth, and a wrapper is usually ten lines.
Frameworks hide the response object, so wrap the node instead of the call. The LangGraph wrapper below leaves graph code untouched: it times each node, reads the usage the node returns and lets exceptions propagate so the graph's own error handling keeps working.
python
from agentburn import Client
client = Client(project="my-agent")
def instrumented_node(run_node, model="claude-sonnet-5-5"):
def node(state):
with client.track_task(model=model, task=state.get("task", "node")) as task:
result = run_node(state)
usage = result.get("usage", {})
task.input_tokens = usage.get("input_tokens", 0)
task.output_tokens = usage.get("output_tokens", 0)
task.tool_calls = usage.get("tool_calls", 0)
return result
return node
# Graph code stays unchanged: wrap the nodes that call a model.
graph.add_node("research", instrumented_node(research_node))
graph.add_node("rewrite", instrumented_node(rewrite_node))
For JavaScript model SDKs that use global fetch internally, autoInstrumentFetch can read usage for you. It understands usage.input_tokens / usage.output_tokens / cache_read_input_tokens from Anthropic and usage.prompt_tokens / usage.completion_tokens / prompt_tokens_details.cached_tokens from OpenAI.
javascript
import client, { autoInstrumentFetch } from '/sdk/js/agentburn.js';
// Opt in per call: wrap fetch for the model requests you already make.
const measuredFetch = autoInstrumentFetch(client, { model: 'claude-sonnet-5-5', task: 'chat' });
const response = await measuredFetch('https://api.anthropic.com/v1/messages', requestInit);
// Or patch global fetch once at boot for SDKs that call fetch internally.
autoInstrumentFetch(client, { patchGlobal: true, task: 'chat' });
Recommended pairing. Keep the task event for duration and success, and let the wrapper or autoInstrumentFetch contribute the usage event. Use the same task label on both; the two rows join cleanly in your own dashboards, and the public boards only need the token totals.
5. API reference
Four JSON routes, served by Cloudflare Pages Functions from /functions/api/. All of them answer OPTIONS with Access-Control-Allow-Origin: *, so a browser agent on another origin can post directly. There is no API key requirement for public ingest; the per-IP throttle is the abuse control.
POST /api/telemetry
Ingest a batch of agent events. The SDK does this for you, but the endpoint is plain JSON on purpose: anything that can make an HTTP request can report usage.
One event can be a single model call or one aggregated agent run: the sample above is task-level, matching OpenHands' reported footprint of 2.4M tokens per SWE-bench task across 48 tool calls. Send whatever granularity you can measure β the boards sum it either way.
A successful ingest returns 202 Accepted with a derived summary you can log. Nothing is estimated: the summary is the arithmetic of the batch you just sent.
Parseable, at most 7 days old, at most 10 minutes in the future
400 invalid_events
Numeric fields
Finite, 0 to 1e9
400 invalid_events
Rate
120 requests per IP per hour, batched
429 rate_limited with retryAfter
Retention
Raw events 30 days, daily counters 90 days
Expired by KV TTL
Storage layout: one record per event at evt:<project>:<yyyymmdd>:<uuid>, plus one read-modify-write counter per project per UTC day at agg:<project>:<yyyymmdd> holding { events, tokensIn, tokensOut, cached, tools, retries, success, fail }. Aggregates are counters only β there is no content in them, which is why they can outlive the raw events.
Privacy is enforced on the server too. Keys named prompt, response, messages or content are stripped recursively, counted in strippedKeys and ignored β never a reason to reject a batch. GET /api/telemetry answers 405; there is nothing to read back per event.
GET /api/stats
The public aggregate. It reads only agg: keys from the last 30 UTC days, so its cost is bounded by the number of active project-days, not by traffic volume. ?project= filters to one slug; an unknown slug returns an empty array instead of a 404 so a dashboard can poll safely.
Token-only by design: the route never invents a dollar figure. Cost per solved task is computed from your published metrics on the project page, not from this feed. The response is cacheable (Cache-Control: public, max-age=60), so poll no faster than once a minute.
POST /api/submit
Community submissions from /submit. Required fields are name, domain, a description of at least 40 characters and a valid email; everything else is optional but improves review speed.
json
{
"name": "Repo Janitor",
"domain": "repojanitor.dev",
"tagline": "Autonomous dependency-upgrade agent",
"description": "An agent that upgrades dependencies across a monorepo, runs the full test suite and opens a pull request with a migration report for every major bump.",
"website": "https://repojanitor.dev",
"repo": "https://github.com/example/repo-janitor",
"sourceUrl": "https://repojanitor.dev/benchmark",
"sourceType": "benchmark",
"evidence": "Public benchmark page with per-task token counts",
"models": "Claude Sonnet 5.5 + GPT-6.1 Sol",
"framework": "LangGraph",
"tokensPerTask": 1100000,
"costPerTaskUsd": 0.95,
"successRatePct": 71.5,
"runsPerDay": 320,
"email": "[email protected]",
"sponsorInterest": false,
"referrer": "https://news.ycombinator.com/"
}
json
{
"ok": true,
"id": "sub_m9x2kq_9f2c1a4b7d05",
"status": "pending-review",
"receivedAt": "2026-10-21T10:20:34.000Z",
"next": "We review submissions within 3 business days. Verified entries need a public source URL that states the metrics."
}
Validation rejects a filled company_website honeypot (400 spam_detected), malformed emails, non-http(s) URLs, out-of-range numbers, and bodies over 16 KB. Valid submissions are stored for 90 days under submission:<ts>:<rand>; if SUBMIT_WEBHOOK is bound, a compact notification is posted there on a best-effort five-second timeout.
Submission review API
The admin loop behind the community form. POST /api/submit writes each record to KV at submission:<id> with status pending-review; these two routes list that queue and record a decision. This is not a public API: every request needs Authorization: Bearer <AB_ADMIN_TOKEN>, where the token is the AB_ADMIN_TOKEN environment variable on the Pages project. The reviewer console at /admin is the intended client β it keeps the token in sessionStorage for the tab, sends it as a header, is excluded from robots.txt and is not linked from the public navigation.
GET /api/submissions returns submissions newest first, filtered by status and capped by limit (1β200, default 50). The KV listing is paginated internally and capped at 1,000 keys; records that fail JSON.parse are skipped instead of breaking the queue. status=pending matches the stored pending-review value; status=all is the default.
http
GET /api/submissions?status=pending&limit=50
Authorization: Bearer $AB_ADMIN_TOKEN
PATCH /api/submissions records a decision. action is approve, reject, publish or unpublish; note is optional (500 characters maximum) and reviewedAt is set server-side. The record is written back with a fresh 90-day TTL, so reviewing a submission never shortens its retention.
http
PATCH /api/submissions
Authorization: Bearer $AB_ADMIN_TOKEN
Content-Type: application/json
{
"id": "sub_m9x2kq_9f2c1a4b7d05",
"action": "approve",
"note": "Numbers match the benchmark table; verified."
}
Auth and failure semantics. OPTIONS answers 204 with permissive CORS so a browser console on another origin can call the routes with a bearer header; Allow lists what each response permits. Error bodies never include the expected token.
HTTP
error
Cause
400
invalid_status
status is not pending, approved, rejected or all
400
invalid_id
PATCH id does not match ^sub_[a-z0-9_]+$
400
invalid_action
PATCH action is not approve, reject, publish or unpublish
400
invalid_note
PATCH note is not a string, or is longer than 500 characters
400
invalid_json
PATCH body is not a JSON object
401
unauthorized
Missing or wrong bearer token β www-authenticate: Bearer is set
404
not_found
No record at submission:<id>; it expired after 90 days or never existed
405
method_not_allowed
Only GET, PATCH and OPTIONS exist on this route
503
admin_not_configured
AB_ADMIN_TOKEN is not set on the deployment
503
storage_not_configured
AB_KV is not bound, so no submission can be read or written
Approved records can be published live from /admin, or exported as a mergeable projects.json stub. The export is deliberately conservative: verified stays false unless the cited source is a benchmark, paper or docs page with a public URL, a missing runs-per-day defaults to 100 with basis: "community-report", and the generic four-step workflow sums to 100% but is a placeholder. metricsNote records the originating submission id. Copying or downloading a stub never publishes it β refine the numbers, then merge it into /data/projects.json and run node scripts/validate-data.mjs.
Publishing a submission (live, no redeploy)
PATCH /api/submissions accepts two extra actions. publish converts the record into a schema-valid project, stores it at project:<slug> in KV and marks the submission published: true with a projectSlug. unpublish deletes that key. Published projects are merged into the site at runtime by /api/projects, so a new project page, directory card and leaderboard row appear within about a minute β no deployment required.
Public feed of published community projects. It never fails without KV: an unbound deployment returns an empty list so the static site keeps working. The curated dataset always wins on slug collisions, and every community record is marked verified: false with usage.basis: "community-report". Responses are cached for 60 seconds at the edge.
GET|POST|DELETE /api/config (Bearer AB_ADMIN_TOKEN) stores notification webhooks in KV so they can be changed from /admin without a redeploy. Posting { "test": true } sends a test message through every configured webhook and reports the per-target result. Delivery understands Slack (text), Discord (content), Feishu (msg_type) and WeCom (msgtype), retries once on 5xx and logs attempts under notify:* for 30 days.
The deploy checklist in one request. It works with zero bindings and reports which ones exist, so a fresh Cloudflare Pages deployment can be verified before any KV namespace is created.
Every error is JSON with ok: false, a machine-readable error and a human-readable hint. Field-level problems also include a fields array so a form can highlight the exact input.
HTTP
error
Cause
Fix
400
invalid_json
Body is not a JSON object
Send Content-Type: application/json and a JSON object
400
invalid_project
Slug fails ^[a-z0-9-]{2,64}$
Use the lowercase kebab-case slug from your project URL
400
invalid_events
Event count or field validation failed
Read fields and repair the named event
400
validation_failed
A submission field is invalid
Read fields, fix the form
400
spam_detected
Honeypot filled
Submit without the hidden field
405
method_not_allowed
Wrong verb for the route
Telemetry and submit are POST, stats and health are GET
413
payload_too_large
Body over the route limit
Batch fewer events (32 KB / 100 events)
429
rate_limited
More than 120 ingest requests in an hour
Honour retryAfter; the SDK backs off automatically
500
storage_write_failed
KV rejected the write
Retry with backoff; check the namespace quota
503
storage_not_configured
No AB_KV binding
Bind KV in the Pages dashboard, or self-host
7. OpenTelemetry bridge
OTLP is the native format for agent frameworks that already emit spans (LangGraph, CrewAI, the OpenAI Agents SDK, LlamaIndex). AgentBurn accepts a plain JSON batch, so the path is explicit: OTLP into an OpenTelemetry Collector, spans to disk, then a small relay you control maps them to POST /api/telemetry with source: "otel".
The collector does the collecting and batching; the relay does the mapping. Keeping the relay in your own infrastructure means span attributes never leave it except as the numeric event fields below.
bash
export OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4318"
export OTEL_SERVICE_NAME="my-agent"
export OTEL_RESOURCE_ATTRIBUTES="service.version=1.4.2,deployment.environment=production"
# Used by the relay, not the collector.
export AGENTBURN_PROJECT="my-agent"
export AGENTBURN_ENDPOINT="https://getagentburn.com/api/telemetry"
The mapping that matters: gen_ai.request.model becomes model, gen_ai.usage.input_tokens and gen_ai.usage.output_tokens become the token counters, span start and end times become ts and durationMs, and otel.status_code becomes success. Spans without usage are skipped, because an event without token counts carries nothing the boards can use.
One batch, one project. A batch always belongs to a single slug; run one relay per project, or set AGENTBURN_PROJECT per pipeline. Events older than seven days are rejected by design, so keep the relay on a short timer (five minutes is plenty).
8. Privacy and data policy
Telemetry is the product, so the boundary has to be explicit. The SDK is built so that the private parts of an agent run cannot be sent even by accident, and the server enforces the same rule a second time.
Collected
Project slug, model id and SDK version.
Input, output and cached-input token counts.
Tool-call count, retry count and wall-clock duration in milliseconds.
Success boolean, a task label of at most 80 characters and an error string of at most 160 characters.
A random client id and the event timestamp.
Never collected
Prompts, completions, message arrays, retrieved documents, file contents, API keys and authorization headers, end-user emails or identifiers, and IP addresses. The SDK removes prompt, response, messages, content and authorization keys recursively and strips URL query strings before queueing; the ingest endpoint strips the same keys again and reports the count in strippedKeys.
Retention and identifiers
Raw events expire after 30 days (KV expirationTtl of 2,592,000 seconds). Daily counters keep totals, never content, for 90 days.
clientId is random β ab_ plus 32 hex characters β and persisted in localStorage when available. It is not derived from a person, an email address or a device fingerprint; clearing storage generates a new one.
Aggregation is per project. /api/stats exposes project totals only, never per-user series.
Opt out at any time: stop calling track() β nothing is sent in the background. Sample with sampleRate, or point endpoint at your own deployment.
Deletion: rotate your project slug or email us. Raw events age out in 30 days and counters in 90.
Self-hosting
The API is four Cloudflare Pages Functions and one KV namespace. Fork the repository, bind AB_KV, deploy, and set endpoint to your own origin β the SDKs, the docs page and the test suite are the same files you are reading now.
9. Verification and placement policy
Three badges appear across the site. The rule is the same everywhere: a badge describes where a number came from, not how impressive it is.
Verified β a public URL states the metric (benchmark, paper or docs page) and either telemetry corroborates it within 25% over seven days, or a reviewer opens the source and confirms the numbers. The badge links to the source and the ingestion window.
Community β submitted by the project's team with a cited source that we have not independently confirmed. Clearly labelled, and never presented as verified.
Estimated β modelled from public information when no per-task figures are published. Every estimated card carries its metrics note explaining the derivation.
Placement
Organic token and cost boards rank on computed values only; payment never reorders them.
Sponsored placements (see /pricing) sit in their own badged strip with a Sponsored label. They are visually distinct and excluded from organic rank.
Telemetry-backed projects appear with a measured basis; self-reported runs per day stay labelled community-report or estimate in the usage section.
Feed ownership
Public ingest is unauthenticated in v1: a project slug is public, so a third party could in principle post events for a project. Suspicious feeds are excluded during review, and rotating the slug invalidates old callers. If you need authenticated ingest today, self-host the functions and put Cloudflare Access in front of /api/telemetry β the SDK only needs the endpoint URL and an optional bearer key.
Corrections and removals
Email [email protected] with the project URL and the number you dispute. We answer within three business days, and a disputed telemetry feed can be paused by rotating the project slug.
10. FAQ
Do I have to share prompts or outputs?
No, and the SDK cannot: prompt, response, messages, content and authorization keys are removed recursively before an event is queued, and the ingest endpoint strips them again as a second layer. Only counts, model ids, durations and short labels are transmitted.
Is it free?
Yes. Both SDKs are dependency-free files served from this site, ingest is free, and no account is needed. Paid sponsorship on /pricing is a separate product for placement and analytics, and sponsored rows are always labelled.
My agent only runs a few times a day β is that enough?
There is no minimum. Ten events a week still produce a real token-per-task figure; the Verified badge asks for seven consecutive days of events so the number has a stable window behind it. Low-volume projects keep their place on the boards with the basis shown as telemetry.
Does it work in Node, Deno, Bun and Python 3.9?
The JavaScript module is plain ESM with no Node-only APIs: it runs in Node 18+, Deno, Bun and any modern browser. Outside the browser, pass an absolute endpoint because there is no location.origin to resolve /api/telemetry against. The Python file is standard library only and targets 3.9+.
How fresh are the numbers?
Events are written to KV during the request, so the daily counter moves immediately. /api/stats is cached for 60 seconds, and the public boards are computed in the browser from the published dataset and telemetry aggregate β refresh slower than once a minute and you will not miss anything.
What happens if the deployment has no KV namespace?
Nothing breaks. The functions ship with zero bindings: every write endpoint answers 503 storage_not_configured with a hint naming AB_KV, while every static page keeps rendering from /data/*.json. Bind KV in the Pages dashboard and the same endpoints start accepting events without a redeploy.
Can I run my own instance?
Yes. The API is four Pages Functions plus one KV namespace; fork, bind your own AB_KV and deploy. Point endpoint at your origin and the SDK behaves identically. The contract tests in sdk/test/api.test.mjs run against the handlers directly, without wrangler.
Next step
Put real numbers behind your agent
Submit the project first β instrumentation can follow. We review within three business days and tell you which fields to instrument so your first board placement is defensible.