windrose
Dashboard Sign in
Docs / Getting started

Connect Windrose to your agent

Windrose runs as a hosted MCP server. You add it once to your coding agent, then ask questions in plain language. The agent calls the right tool and gets a compact answer back, so you never leave your editor.

1. Add the server

Run this in your project. It registers Windrose over HTTP transport under the name windrose.

claude mcp add --transport http windrose https://windrose.sh/mcp

Claude Desktop / claude.ai: Settings → Connectors → Add custom connector → https://windrose.sh/mcp. Any other MCP client (Cursor, VS Code, Codex): add an HTTP server entry to its MCP config:

{ "mcpServers": { "windrose": { "type": "http", "url": "https://windrose.sh/mcp" } } }

2. Authenticate

On first call the agent opens a browser to sign in with GitHub and mint a token scoped to your sites. The token lives in your agent config, not in your codebase.

3. Ask

Ask in plain language, or call a tool directly. Every response is summarized and token efficient, built to fit an agent context window rather than a spreadsheet.

> get_ai_traffic({ site: "windrose.sh", period: "30d" })

  crawlers   18,340   ClaudeBot, GPTBot, PerplexityBot
  referrals     602   chatgpt.com, perplexity.ai, claude.ai

4. Optional: install the skill

A thin skill that teaches your agent the workflows: weekly traffic review, anomaly triage, and the AEO action loop, plus the reading rules (crawlers vs referrals, capture honesty).

mkdir -p ~/.claude/skills/windrose-analytics && curl -s https://windrose.sh/skill.md -o ~/.claude/skills/windrose-analytics/SKILL.md

Available tools

check_ai_readiness Full AI setup audit for any public domain: llms.txt, per-crawler robots policy, sitemap, non-JS content legibility, and exact fixes.
list_sites Your sites with first-seen/last-seen and event counts. Start here when the site is not specified.
create_server_ingest_key Create or rotate the server-side capture bearer key for a verified site. Returned once; store it as WINDROSE_INGEST_KEY in your middleware environment.
get_collection_health Browser and server collection freshness, crawler identity verification, crawl failures, and spoof suspects with honest coverage guidance.
get_evidence_timeline Deploy annotations with equal-window before and after evidence, overlap warnings, sample quality, and directional outcomes.
record_deployment Record an idempotent deployment annotation from an agent or CI run so Windrose can measure what followed.
get_opportunities Review open, accepted, and dismissed opportunities ranked by impact, confidence, effort, and risk.
decide_opportunity Accept or dismiss a current opportunity and retain the exact evidence snapshot behind that decision.
get_change_package Build a review-only branch, PR body, change hints, verification, rollback, and target metric from an accepted opportunity.
deliver_change_package Explicitly deliver an accepted package to the configured webhook, with deduplication and untrusted evidence clearly separated.
get_summary Compact site overview: visitors, pageviews, top movers, and notable changes for any period. Call after checking collection health.
query_metric Ask for one metric, filtered and grouped: signups by referrer, pageviews by country, bounce rate for a single path.
compare_periods This week vs last, pre-deploy vs post-deploy. Returns deltas with significance hints, so the agent knows what is noise.
detect_anomalies Scans for spikes and drops and attaches a probable cause: a vanished referrer, a broken page, a crawler wave.
get_ai_traffic AI crawler traffic by inferred purpose, referral traffic, same-day custom-event conversions, and currency-separated attributed revenue. Verified crawler failures stay separate from unverifiable claims.
list_top Top pages, referrers, countries, or devices, ranked with share of total and week over week movement built in.
get_recommendations Current evidence-backed actions: verified crawler failures, AI-referred landing drop-off, and leading AI-referred landing pages. Each carries its evidence and a concrete action.

Track conversions and revenue

The browser beacon exposes window.windrose.track. Send a named event for a conversion, or include a non-negative value and three-letter currency for revenue:

windrose.track('signup')
windrose.track('purchase', { value: 49.90, currency: 'EUR' })

get_ai_traffic attributes later same-day events to the first AI-referred landing for that visitor-day. Values are totaled separately by currency. This is directional attribution, not proof that the AI source caused the conversion.

Crawler rows and totals classify canonical bots as training, indexing, retrieval, active-agent, or unknown from their documented User-Agent behavior. This purpose is inferred. IP verification confirms vendor identity, not the intent of an individual request.

Capture server-side crawlers (GPTBot, ClaudeBot, and friends)

Windrose's JavaScript beacon (a.js) only sees crawlers that run JavaScript. Most AI crawlers (GPTBot, ClaudeBot, PerplexityBot, and friends) just fetch your HTML and leave, so they never show up in a JS-only tracker, exactly the traffic you want to measure. Drop one small piece of middleware into your app and Windrose captures recognized AI-crawler hits server-side. Integrations that observe the final response also include its HTTP status, surfacing fixable crawl errors such as a 404 on /docs.

The middleware forwards bot hits to POST https://windrose.sh/api/collect/server, fire-and-forget, so it never blocks your response. Create a site-specific ingest key in the Windrose dashboard (or ask your agent to call create_server_ingest_key), save it as WINDROSE_INGEST_KEY, and send it as a bearer token. Windrose stores only its SHA-256 hash; rotating the key immediately invalidates the old one. Only recognized AI-crawler User-Agents are retained, and crawler IPs are used transiently for verification.

The snippets also forward the crawler's source IP. A User-Agent is a claim anyone can make; Windrose checks the IP against the claimed vendor's published ranges (OpenAI, Anthropic, Perplexity, Google) and records a verdict: verified, spoof suspect, or unverifiable. The IP itself is used transiently for that check and never stored.

Next.js (App Router, edge middleware) middleware.ts

// middleware.ts
import { NextResponse, type NextRequest, type NextFetchEvent } from 'next/server'

// config (SITE defaults to the request host when left empty):
const WINDROSE_ENDPOINT = 'https://windrose.sh/api/collect/server'
const WINDROSE_INGEST_KEY = process.env.WINDROSE_INGEST_KEY ?? ''
const SITE = '' // leave empty to default to the request host

const BOT_RE =
  /GPTBot|ChatGPT-User|OAI-SearchBot|ClaudeBot|Claude-User|Claude-SearchBot|Claude-Web|anthropic-ai|PerplexityBot|Perplexity-User|Google-Extended|Applebot-Extended|Bytespider|CCBot|cohere-ai|Meta-ExternalAgent|Meta-ExternalFetcher|DuckAssistBot/i

export function middleware(request: NextRequest, event: NextFetchEvent) {
  const ua = request.headers.get('user-agent') ?? ''

  if (BOT_RE.test(ua)) {
    const url = new URL(request.url)
    const payload = {
      site: SITE || url.host,
      path: url.pathname,
      ua, // the crawler's UA, from the incoming request
      referrer: request.headers.get('referer'),
      ip: request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? null, // for crawler IP verification; never stored
      ts: new Date().toISOString(),
    }

    // fire-and-forget: never block the response, always swallow errors
    event.waitUntil(
      fetch(WINDROSE_ENDPOINT, {
        method: 'POST',
        headers: { 'content-type': 'application/json', authorization: `Bearer ${WINDROSE_INGEST_KEY}` },
        body: JSON.stringify(payload),
      }).catch(() => {}),
    )
  }

  return NextResponse.next()
}

// run on real page/content routes, skip static assets
export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}

Cloudflare Workers (reads the origin status, works in front of any origin)

// src/worker.ts (or the module entry configured in wrangler.toml)

// config (SITE defaults to the request host when left empty):
const WINDROSE_ENDPOINT = 'https://windrose.sh/api/collect/server'
const SITE = '' // leave empty to default to the request host

const BOT_RE =
  /GPTBot|ChatGPT-User|OAI-SearchBot|ClaudeBot|Claude-User|Claude-SearchBot|Claude-Web|anthropic-ai|PerplexityBot|Perplexity-User|Google-Extended|Applebot-Extended|Bytespider|CCBot|cohere-ai|Meta-ExternalAgent|Meta-ExternalFetcher|DuckAssistBot/i

type Env = { WINDROSE_INGEST_KEY: string }

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    // pass through to origin (replace with your own handler if this Worker IS the app)
    const response = await fetch(request)

    const ua = request.headers.get('user-agent') ?? ''
    if (BOT_RE.test(ua)) {
      const url = new URL(request.url)
      const payload = {
        site: SITE || url.host,
        path: url.pathname,
        ua, // the crawler's UA, from the incoming request
        status: response.status, // final origin status
        referrer: request.headers.get('referer'),
        ip: request.headers.get('cf-connecting-ip'), // for crawler IP verification; never stored
        ts: new Date().toISOString(),
      }

      // fire-and-forget: waitUntil lets it finish after the response is sent, catch swallows errors
      ctx.waitUntil(
        fetch(WINDROSE_ENDPOINT, {
          method: 'POST',
          headers: { 'content-type': 'application/json', authorization: `Bearer ${env.WINDROSE_INGEST_KEY}` },
          body: JSON.stringify(payload),
        }).catch(() => {}),
      )
    }

    return response
  },
}

Same one-file pattern for the other frameworks:

SvelteKit · src/hooks.server.ts
// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit'

// config (SITE defaults to the request host when left empty):
const WINDROSE_ENDPOINT = 'https://windrose.sh/api/collect/server'
const WINDROSE_INGEST_KEY = process.env.WINDROSE_INGEST_KEY ?? ''
const SITE = '' // leave empty to default to the request host

const BOT_RE =
  /GPTBot|ChatGPT-User|OAI-SearchBot|ClaudeBot|Claude-User|Claude-SearchBot|Claude-Web|anthropic-ai|PerplexityBot|Perplexity-User|Google-Extended|Applebot-Extended|Bytespider|CCBot|cohere-ai|Meta-ExternalAgent|Meta-ExternalFetcher|DuckAssistBot/i

export const handle: Handle = async ({ event, resolve }) => {
  const response = await resolve(event)

  const ua = event.request.headers.get('user-agent') ?? ''
  if (BOT_RE.test(ua)) {
    const payload = {
      site: SITE || event.url.host,
      path: event.url.pathname,
      ua, // the crawler's UA, from the incoming request
      status: response.status, // final response status is known here
      referrer: event.request.headers.get('referer'),
      ip: event.getClientAddress(), // for crawler IP verification; never stored
      ts: new Date().toISOString(),
    }

    const send = fetch(WINDROSE_ENDPOINT, {
      method: 'POST',
      headers: { 'content-type': 'application/json', authorization: `Bearer ${WINDROSE_INGEST_KEY}` },
      body: JSON.stringify(payload),
    }).catch(() => {}) // fire-and-forget, swallow errors

    // if your adapter exposes a waitUntil (e.g. Cloudflare), let it keep the request alive;
    // otherwise the un-awaited promise above is enough
    event.platform?.context?.waitUntil?.(send)
  }

  return response
}
Nuxt 3 · server/middleware/windrose.ts
// server/middleware/windrose.ts
import { defineEventHandler, getRequestHeader, getRequestIP } from 'h3'

// config (SITE defaults to the request host when left empty):
const WINDROSE_ENDPOINT = 'https://windrose.sh/api/collect/server'
const WINDROSE_INGEST_KEY = process.env.WINDROSE_INGEST_KEY ?? ''
const SITE = '' // leave empty to default to the request host

const BOT_RE =
  /GPTBot|ChatGPT-User|OAI-SearchBot|ClaudeBot|Claude-User|Claude-SearchBot|Claude-Web|anthropic-ai|PerplexityBot|Perplexity-User|Google-Extended|Applebot-Extended|Bytespider|CCBot|cohere-ai|Meta-ExternalAgent|Meta-ExternalFetcher|DuckAssistBot/i

export default defineEventHandler((event) => {
  const ua = getRequestHeader(event, 'user-agent') ?? ''
  if (!BOT_RE.test(ua)) return // ignore humans and unknown UAs

  const host = getRequestHeader(event, 'host') ?? ''
  const path = event.path ?? event.node.req.url ?? '/'

  const referrer = getRequestHeader(event, 'referer') ?? null
  const ip = getRequestIP(event, { xForwardedFor: true }) ?? null

  // Send after the response finishes so its final HTTP status is available.
  event.node.res.once('finish', () => {
    const payload = {
      site: SITE || host,
      path: path.split('?')[0],
      ua,
      status: event.node.res.statusCode,
      referrer,
      ip, // for crawler IP verification; never stored
      ts: new Date().toISOString(),
    }
    void fetch(WINDROSE_ENDPOINT, {
      method: 'POST',
      headers: { 'content-type': 'application/json', authorization: `Bearer ${WINDROSE_INGEST_KEY}` },
      body: JSON.stringify(payload),
    }).catch(() => {})
  })
})
Astro · src/middleware.ts
// src/middleware.ts
import { defineMiddleware } from 'astro:middleware'

// config (SITE defaults to the request host when left empty):
const WINDROSE_ENDPOINT = 'https://windrose.sh/api/collect/server'
const WINDROSE_INGEST_KEY = process.env.WINDROSE_INGEST_KEY ?? ''
const SITE = '' // leave empty to default to the request host

const BOT_RE =
  /GPTBot|ChatGPT-User|OAI-SearchBot|ClaudeBot|Claude-User|Claude-SearchBot|Claude-Web|anthropic-ai|PerplexityBot|Perplexity-User|Google-Extended|Applebot-Extended|Bytespider|CCBot|cohere-ai|Meta-ExternalAgent|Meta-ExternalFetcher|DuckAssistBot/i

export const onRequest = defineMiddleware(async (context, next) => {
  const response = await next()

  const ua = context.request.headers.get('user-agent') ?? ''
  if (BOT_RE.test(ua)) {
    const payload = {
      site: SITE || context.url.host,
      path: context.url.pathname,
      ua, // the crawler's UA, from the incoming request
      status: response.status, // final response status is known here
      referrer: context.request.headers.get('referer'),
      ip: context.clientAddress, // for crawler IP verification; never stored
      ts: new Date().toISOString(),
    }

    const send = fetch(WINDROSE_ENDPOINT, {
      method: 'POST',
      headers: { 'content-type': 'application/json', authorization: `Bearer ${WINDROSE_INGEST_KEY}` },
      body: JSON.stringify(payload),
    }).catch(() => {}) // fire-and-forget, swallow errors

    // on an edge adapter (e.g. Cloudflare) keep the request alive; otherwise the
    // un-awaited promise above suffices
    ;(context.locals as any)?.runtime?.ctx?.waitUntil?.(send)
  }

  return response
})
Express / Node · windrose.js
// windrose.js

// config (SITE defaults to the request host when left empty):
const WINDROSE_ENDPOINT = 'https://windrose.sh/api/collect/server'
const WINDROSE_INGEST_KEY = process.env.WINDROSE_INGEST_KEY ?? ''
const SITE = '' // leave empty to default to the request host

const BOT_RE =
  /GPTBot|ChatGPT-User|OAI-SearchBot|ClaudeBot|Claude-User|Claude-SearchBot|Claude-Web|anthropic-ai|PerplexityBot|Perplexity-User|Google-Extended|Applebot-Extended|Bytespider|CCBot|cohere-ai|Meta-ExternalAgent|Meta-ExternalFetcher|DuckAssistBot/i

function windrose(req, res, next) {
  const ua = req.headers['user-agent'] || ''

  if (BOT_RE.test(ua)) {
    // fire after the response is fully sent, so status is final and nothing blocks
    res.on('finish', () => {
      const payload = {
        site: SITE || req.headers.host,
        path: (req.originalUrl || req.url || '/').split('?')[0],
        ua, // the crawler's UA, from the incoming request
        status: res.statusCode, // final response status
        referrer: req.headers.referer || null,
        ip: req.ip || null, // for crawler IP verification; never stored
        ts: new Date().toISOString(),
      }

      // fire-and-forget: not awaited, errors swallowed
      fetch(WINDROSE_ENDPOINT, {
        method: 'POST',
        headers: { 'content-type': 'application/json', authorization: `Bearer ${WINDROSE_INGEST_KEY}` },
        body: JSON.stringify(payload),
      }).catch(() => {})
    })
  }

  next()
}

module.exports = windrose

// wire it up before your routes

const express = require('express')
const windrose = require('./windrose')

const app = express()
app.use(windrose)
// ... your routes

Verify it in one line. Hit any page as a crawler with curl -A GPTBot https://yoursite.com/, then open the AI traffic panel in your dashboard and look for the hit.

Record deployments from CI

Annotate each deploy so Windrose can compare equal pre/post evidence windows around it. CI systems post to POST https://windrose.sh/api/collect/deployment with the same WINDROSE_INGEST_KEY bearer token the middleware uses. The commit SHA makes a good idempotency_key: retries with identical content are deduped, reusing it with different content returns a 409.

curl -fsS -X POST https://windrose.sh/api/collect/deployment \
  -H "content-type: application/json" \
  -H "authorization: Bearer $WINDROSE_INGEST_KEY" \
  -d '{"site":"example.com","idempotency_key":"'"$GITHUB_SHA"'","revision":"'"$GITHUB_SHA"'","environment":"production"}'

The full contract, a ready-to-paste GitHub Actions workflow, and the error table are in the CI deployments guide. Annotations are directional evidence, not causal proof.

Windrose's analytics tracker is cookieless and data-minimizing: raw visitor IPs are processed transiently, never stored in the analytics dataset, and the daily visitor identifier rotates every 24 hours. Your site's disclosure and lawful-basis requirements still depend on its setup and jurisdiction. The Privacy Kit documents the exact data flow and provides a copyable disclosure template.