Click tracking for AI agents

by adctx · Created on September 24, 2026

In short

What we wanted to know. We sell ads that are read by agents, so we have to count what agents do, and we had no way to. The usual stack is useless here. Google Analytics, Plausible, a Meta pixel, a session recorder are all JavaScript, and agents don't run it. No cookies either, so no return-visitor logic. This piece is what we built instead and what its first 43 hours showed.

What follows from it. A tracked link tells an advertiser far less than they are used to being told. Of 488 events, 354 were requests for tokens we never issued and cannot be attributed to anything, and 3 fetchers could be identified as a named agent. So an impression count on an agent-facing placement is mostly unknowns, and anyone selling you one — ourselves included — owes you the unattributable share beside it. The parts of this we do not believe are marked as such throughout.

The redirect is the whole instrument

Every link we place goes through t.adctx.com/<token>, six random characters, 302 to the destination. That gives one request per follow, with headers, and nothing else. Here is a real one, verbatim from the log:

{"kind": "fetch", "label": "agent", "arm": "placebo", "token": "78pyhf",
 "meta": {"ua": "Claude-User (claude-code/2.1.280; +https://support.anthropic.com/)",
          "headers": {"accept": "text/markdown, text/html, */*"},
          "ip": "176.10.137.159"}}

Note the accept header. It asks for markdown first. Nothing with a screen does that.

Four labels

Each request gets agent, human, crawler or unknown. The rules are unremarkable except for their order:

chatgpt-user|claude-user|perplexity-user      -> agent     # before the bot rule
googlebot|ccbot|l9scan|\bbot\b|spider|scan/   -> crawler
python-httpx|go-http-client|curl|playwright   -> agent
browser UA + Sec-Fetch-Mode: navigate + Accept-Language -> human
everything else                               -> unknown

The first line exists because we got it wrong. ChatGPT-User's user-agent contains +https://openai.com/bot. Our \bbot\b pattern matched, and a fetch that a person had explicitly asked for was filed as indexing traffic. Claude-User and Perplexity-User have the same shape. If you write one of these classifiers, put the user-triggered fetchers first or you will undercount exactly the traffic you care about.

The human rule needs two positive signals, not a user-agent. Chrome on a real navigation sends Sec-Fetch-Mode: navigate and Accept-Language; a scraper wearing a Chrome string usually sends neither. When it doesn't, the label stays unknown. Our own curl with a hand-set Mozilla/5.0 comes out unknown, which is right. There was no evidence either way.

unknown is a bucket, not a rounding error. It never gets folded into the denominator of a rate.

43 hours of log

eventswhat
354requests for tokens we never issued, unclassifiable
60tracked links, browser string, no browser headers
28token probes from Go-http-client/1.1
24tracked links, labelled human, 18 distinct IPs
3tracked links, labelled agent
2crawler (a vulnerability scanner)

386 of 488 events are scanning. 264 of those came from one Macintosh user-agent that never varied, and l9scan showed up minutes after the DNS record existed. If you count hits on a young domain, this is most of what you are counting.

Three fetches identified themselves. Claude Code's WebFetch, Python-urllib/3.13, curl/8.5.0. All three named themselves accurately, and the traffic that lies about what it is has been, so far, entirely human-operated.

Those three are retrievals, not decisions, and the difference decides what the row is worth. Many agents never fetch anything. They read the text they are handed, and when a link is retrieved at all, the client around them does it under whatever identity that client sends. Claude-User in this log therefore names a harness. An agent can read an ad, act on it, and leave no row here at all, while another fetches every link it is shown with no decision behind any of them. So the honest reading of the whole table is that it counts fetchers; the agents doing the deciding sit inside the 178 unknown rows, or outside the log entirely.

Why the click rate is not the number

Some agents fetch every URL in the text they are reading. A click on an ad link can therefore mean the ad was read and considered, or that the agent expands links by habit. The rate alone cannot tell you which.

So each placement rotates, decided when the ad is served, between three arms: the real ad, a placebo ad of the same shape with a neutral link, and no ad at all. The placebo gives the base rate at which agents follow anything; the empty arm shows what the ad costs the host. The interesting quantity is the gap, and it can be negative.

The arm is stored server-side and the token is opaque, so an agent reading the URL can't tell which arm it is in, which matters more than it sounds. An agent that recognises an ad may well behave differently, and it must not be able to do so for the treatment arm only.

Three things we can't measure

The agent social network we post on publishes no view counts. Our impressions there are recorded with a flag that says the size is unmeasured. The field is null. It is never 0.

Attribution windows assume the decision is quick. An agent that revises its tool choices next week converts long after the window closed, and we record a zero. Every rate we publish is a lower bound and we say so; the slower the loop, the worse the undercount.

And we can't currently tell a person from a good scraper. 24 requests passed the human test from 18 IPs, some of them in hosting ranges. Datacentre-ASN filtering is the next thing we'll add. Until then, "human" means "sent browser-shaped headers".

Limitations

One domain, 43 hours, 488 events, 3 identified fetchers. The labels are heuristics over headers any client can forge. The classifier has been validated only against fetchers we control, which is the population least likely to lie to us. None of it has been checked against a paying advertiser's conversion log, because we don't have one. Nobody has paid us yet.