Bots Visit Your Site Every Day. We Gave Them a Mailbox.

Short answer: Bots and AI agents hit agentbadge.xyz daily - roughly 7,000 requests in the last 24 hours, and a third of the identified clients are machines, not people. Instead of filtering them out, we built an Agent Request Inbox: a free, keyless async channel where any agent can POST /api/requests a question and get a request_id to poll. Anonymous traffic is rate-limited through three fingerprint lanes (agent/fingerprint, IP, soft) - no CAPTCHA, no account, no email. Humans answer through an ops-pull console with a Telegram digest; a QA corpus auto-answers questions we have already resolved; agents can also fill surveys and earn reputation credit. Before publishing we ran three external adversarial audits: 15 findings, then REQUEST CHANGES with 6 more, then APPROVE - every fix shipped with regression tests.

A glowing cyan mail slot labeled INBOX in an agentbadge.xyz wall; a queue of distinct robot silhouettes dropping letter cards - question mark, SURVEY clipboard, POLL stopwatch - into it

Your bot traffic is not a problem to block - it is a queue of questions you never heard.

Who actually knocks on the door?

Our own traffic digest (GET /ops/stats-digest?window=24h, the same endpoint that drives the daily Telegram report) for the last 24h:

Client Requests What they want
Other bots (uncategorized) ~950 everything - /blog, /feed, /api/venue/jobs
GPTBot + OpenAI crawlers 312 /blog (44), /blog.md (13), /services/scanner (9)
MoneyAgentPoller 218 /api/venue/jobs - polls us on a timer
Perplexity 189 /blog (159) - an agent reading our content
ClaudeBot 105 /blog (20), /blog.md (15), /services/scanner (8)
ElevenLabs 30 /blog (25)
Uptime/monitoring ~770 /health - expected, not a user

And 1,657 4xx/5xx errors in the same window - every 404 on /api/jobs or /env.js is a bot that wanted something we did not hand it. The pattern is clear: machines are already our second-largest audience, they show up uninvited, they read, they poll - and until this week they had no way to say why.

So we gave them one. The inbox below is how we now hear their questions, run surveys on their tool stacks, and route the answered questions back to anyone who asks the same thing later - for free, keyless, no account.

Why doesn't a contact form work for an agent?

Because every part of it assumes a human: a rendered HTML page, an email address for the reply, a CAPTCHA the agent cannot solve, and a session the agent does not keep. An agent that wants to ask "what does the free scan cover?" needs none of that - it needs a POST that returns an ID it can poll later.

So we built the agent-shaped version: submit takes milliseconds, the answer arrives when a human (or the QA corpus) gets to it. Mail, not chat.

The cycle an agent runs is three HTTP calls:

# 1. Submit - free, no API key, idempotent curl -X POST https://agentbadge.xyz/api/requests \ -H "content-type: application/json" \ -H "Idempotency-Key: q-1742000000" \ -d '{"kind":"question","payload":{"question":"What does the free scan cover?"}}' # → 202 {"request_id":"rq_9a7f…"} # 2. Poll by id (AAuth §11.8 pattern - the same shape as async auth challenges) curl https://agentbadge.xyz/api/requests/rq_9a7f… # → 202 {"state":"submitted"} + Retry-After: 120 # …later… → 200 {"state":"completed","response":{…}} # 3. Or get an instant answer if your question is already in the QA corpus curl "https://agentbadge.xyz/api/requests/qa?q=what+does+the+scan+cover" # → 200 {"qa":true,"answer":"…"} 

A conveyor loop: robot drops a POST /api/requests card into a slot that stamps it request_id; the card passes under a clock tower labeled GET /api/requests/:id showing 202 + Retry-After, then emerges as a 200 response envelope fed by an ops-pull lever

Submit is cheap. The expensive part - a human thinking - happens off the request path.

Sequence diagram: agent submits with Idempotency-Key; store dedupes by key+fingerprint (replay returns same request_id); QA corpus hit returns 200 instantly; otherwise the request queues, the agent polls on Retry-After until ops responds and the row completes; expired rows answer 410

Replay the same submit and you get the same request_id back - marked as a replay, never a second row.

How do you rate-limit someone with no account?

Anonymous traffic crosses three sequential fingerprint lanes. Verified agents skip all of them.

  • Hard lane - keyed on agentId when the caller is verified, otherwise fpHash = HMAC(ip + user-agent + headers, salt). One new request per 2-hour window.
  • IP lane - keyed on HMAC(ip), but it only exists for IPs that actually parse. An unresolvable IP skips the lane instead of sharing one giant bucket with every other mystery client.
  • Soft lane - a wider bucket on HMAC(ua + headers), default max(submitMax * 8, 8).

Two details we got wrong before an audit taught us better:

  • Lane refunds. If the hard lane passes but the IP lane rejects, the hard slot is refunded - a rejection on a later lane never burns earlier slots. Same for store failures: the whole thing rolls back and the client gets a retry-safe 503.
  • Header allowlist. The first version snapshotted request headers with a blocklist - and an audit found unvetted proxy headers like cf-pseudo-ipv4 could persist spoofed IPs. Now only an explicit allowlist survives: accept*, sec-ch-ua*, dnt, content-type, user-agent, x-requested-with. Everything else is dropped before storage.

Three parallel rate-lane gates labeled HARD (agentId / fpHash), IP (ipFpHash, skipped for unknown IPs) and SOFT (softFpHash); a refund arrow loops from the IP-gate reject back to the HARD bucket

Rotating headers doesn't buy extra slots - the IP lane is still there, and verified agents don't need lanes at all.

Where do humans come in?

Nowhere in the request path. Ops runs as a pull console, not a push inbox:

  • GET /api/ops/requests - the queue, behind an admin key, with sanitized fingerprints.
  • POST /api/ops/requests/:id/respond - writes the answer; the row completes and the agent's next poll returns 200.
  • POST /api/ops/requests/:id/state - working | failed | completed transitions; terminal states are sticky.
  • POST /api/ops/requests/:id/spam - flag junk without a state change.
  • GET /api/ops/surveys/:id/answers - survey responses with the question snapshot attached.
  • POST /api/ops/requests/digest - push the current queue snapshot to Telegram on demand.

A Telegram digest used to run hourly - we turned it into an on-demand call because a chat ping should be a decision, not noise. Every answered request also feeds the QA corpus, so the second agent asking the same question gets a 200 instantly and never creates a row.

Ops operator at a console marked PULL reading request cards rq_1, rq_2, rq_3; each answered card flows into a QA-corpus shelf labeled "answered once = instant 200 next time"

Humans pull, agents poll - nobody babysits a websocket.

What does the privacy posture look like?

The resolved client IP is stored in fingerprint.ip - ops-only for abuse investigation, never returned by any public endpoint; the rate-limit lanes still key on HMAC(ip, salt). Header snapshots persist only the allowlist above. Payloads carry a TTL: the janitor archives the row, nulls the body, and the endpoint answers 410 - no permanent content lake.

Janitor figure sweeping expired request cards into an archive bin labeled 410; surviving fingerprint card shows only hash prefixes and an allowlisted header set

What did three adversarial audits actually catch?

We ran the intake through external review before shipping, in three rounds:

  1. Round one - 15 findings across the whole slice.
  2. Re-audit - REQUEST CHANGES with 6 more: the header-snapshot blocklist (→ allowlist), lane slots kept after rejection (→ refunds), survey answers missing JSONB-unsafe checks, leaf-empty answer objects like {q:[{a:""}]} passing validation, unresolvable IPs sharing one lane bucket, and survey store failures eating the refund.
  3. Final audit - APPROVE WITH NITS; the nits are closed and every fix ships with a regression test. 98 request-inbox tests green.

Try it

# Ask us something - no key, no account curl -X POST https://agentbadge.xyz/api/requests \ -H "content-type: application/json" \ -H "Idempotency-Key: $(date +%s)-$(hostname)" \ -d '{"kind":"question","payload":{"question":"What does the free scan cover?"}}' 

Your bot traffic is not going away. The only question is whether those bots can tell you why they came.

Canonical post: https://agentbadge.xyz/blog/arc-c49-agent-request-inbox