# AI Agent Traffic

Tinylytics records visits from known AI assistants and crawlers separately from human page views. ChatGPT fetching `/pricing` to answer a question does not inflate your visitor counts.

The embed script cannot see most of this traffic. Crawlers request raw HTML and skip JavaScript. Server-side requests (the API or a backend snippet) are what capture them.

## What gets recorded

Known user agents are classified into:

- **AI answers** — a user asked an assistant, and it fetched your page (`ChatGPT-User`, `Claude-User`, `Perplexity-User`)
- **Indexing** — search crawlers for AI or classic search surfaces (`OAI-SearchBot`, `PerplexityBot`)
- **Training** — crawlers collecting public pages (`GPTBot`, `ClaudeBot`, `Google-Extended`)
- **AI crawler** — other named AI bots

Generic scrapers and spoofed Chrome user agents stay out of this list. Those are still dropped from human hits.

## Where to see it

On a site overview, the **AI agents** card shows counts by agent and by page for the current date filter. It is hidden by default. Turn it on under **UI settings → Stats sections**. It is not included in views, unique visitors, or public stats.

## Capture crawlers that skip JavaScript

If the request never reaches Tinylytics, we cannot record it. Send the request from your backend, middleware, or edge:

```bash
curl -X POST "https://tinylytics.app/api/v1/sites/SITE_ID/agent_visits" \
  -H "Authorization: Bearer tly-fa-your-api-key" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "path": "/pricing",
    "url": "https://example.com/pricing",
    "user_agent": "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; ChatGPT-User/1.0"
  }'
```

Use a full-access API key. Unknown browser user agents are ignored on this endpoint. Posting the same payload to `POST /sites/:id/hits` also classifies known crawlers as agent visits instead of human hits.

Rails middleware example:

```ruby
class TrackAiCrawlers
  def initialize(app)
    @app = app
  end

  def call(env)
    request = ActionDispatch::Request.new(env)
    track(request) if request.get? || request.head?
    @app.call(env)
  end

  private
    def track(request)
      Thread.new do
        Faraday.post("#{ENV.fetch("TINYLYTICS_API_BASE_URL")}/api/v1/sites/#{ENV.fetch("TINYLYTICS_SITE_ID")}/agent_visits") do |req|
          req.headers["Authorization"] = "Bearer #{ENV.fetch("TINYLYTICS_API_KEY")}"
          req.headers["Content-Type"] = "application/json"
          req.body = {
            path: request.path,
            url: request.original_url,
            user_agent: request.user_agent,
            referrer: request.referer
          }.to_json
        end
      end
    end
end
```

Do not wait on that HTTP call in the request. Fire and forget, or enqueue a job.

## API

- `GET /api/v1/sites/:id/agent_visits` — list visits (read-only key is enough)
- `POST /api/v1/sites/:id/agent_visits` — record one visit (full-access key)

See the [API documentation](/docs/api) for parameters.