npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@kyalabs/agent-radar

v1.1.5

Published

AI agent detection for merchant storefronts. 21 behavioral, environment, and network signals. Zero dependencies, zero CWV impact.

Downloads

66

Readme

@kyalabs/agent-radar

npm version bundle size zero dependencies types node license

AI agent detection for merchant storefronts. Identifies automated visitors (GPT-4o, Claude, Gemini, Browser Use, Playwright, Puppeteer) through 21 behavioral, environment, and network signals. Three lines of code, zero Core Web Vitals impact, zero runtime dependencies.

Written in TypeScript — full type definitions ship in the package, no @types/* install needed.

Install

npm install @kyalabs/agent-radar

Requirements

| Runtime | Minimum | Notes | |---|---|---| | Node.js | 22.0 | ESM-first; CJS interop requires Node 22's auto-interop path | | React | 18.0 | Optional peer dep — only needed for /client exports | | Next.js | 13.4 | App Router only. Pages Router is not supported. | | Express | 4.x / 5.x | Both supported | | Cloudflare Workers | — | Requires nodejs_compat flag in wrangler.toml | | Hydrogen (Remix on CF Workers) | 2024.x+ | See Hydrogen reference implementation below |

A backend is required — Express, Next.js, Hydrogen, Cloudflare Worker, or Vercel Edge. The server middleware cannot run in a pure static site. If you don't have a traditional backend, the Hydrogen reference implementation covers the Cloudflare Workers path end to end.

Radar requires BOTH the client provider AND the server middleware. The client collects behavioral signals; the server middleware runs network checks, issues guest passes, and forwards events to kya. Deploying only the client means detection events never reach kya, guest passes never get issued, and returning-visitor tracking silently breaks.

Radar is the detection layer of the kya trust platform. It detects agents, issues temporary identity (guest passes), and feeds the kyaScore reputation system. Merchants see their agent traffic via the Radar dashboard or the Stats API.


Quick start

0. Get your keys

kyaLabs is in concierge onboarding today. Email [email protected] with your domain and stack and we'll provision your merchant ID and mk_live_* API key within one business day. A self-serve signup form will live at kyalabs.io shortly.

Once provisioned, you'll receive:

  • Merchant ID — a UUID (e.g. 00000000-0000-0000-0000-000000000000). Set as KYA_MERCHANT_ID.
  • API keymk_live_* prefix, 64-character hex body. Set as KYA_API_KEY.

The mk_live_* key is publishable, like a Stripe publishable key — it's safe to expose in client-side code. It's scoped to your merchant and can't do destructive things. The full key is only shown once at creation; if you lose it, ask us for a new one.

You'll also get a dashboard login at kyalabs.io/radar/dashboard. The in-dashboard Setup tab shows both values with copy buttons and mirrors the code examples in this README — if anything drifts between them, the README is the source of truth.

1. Check your site is compatible

Before you install, run these three commands in your project root. All three should succeed. If any fail, the fix is in the row next to it.

# 1. Node version — need 22.0 or higher
node -v
# Expected: v22.x.x or higher.
# If lower: upgrade Node (nvm install 22).

# 2. React version — need 18 or higher
grep '"react"' package.json
# Expected: "react": "^18.0.0" (or higher).
# If missing: Radar's /client exports won't work. Server-only installs
# are still possible — see Express/Node.js below.

# 3. Next.js App Router (Next.js users only)
test -d app && echo "App Router: yes" || echo "App Router: NO"
# Expected: "App Router: yes".
# If "NO": you're on Pages Router — not supported. Migrate to App Router
# or contact [email protected] for a Pages Router workaround.

Not on Next.js? Hydrogen and Express are both first-class — see Reference implementations below.

2. Set your environment variables

Store both values as environment variables. The SDK reads them by name. Never hard-code API keys in source.

Next.js (App Router):

# .env.local
KYA_MERCHANT_ID=your-merchant-uuid
KYA_API_KEY=mk_live_your_key

# Next.js requires the NEXT_PUBLIC_ prefix for any var exposed to
# client-side code. Set both — the client provider needs the public
# versions, and the server middleware needs the unprefixed versions.
NEXT_PUBLIC_KYA_MERCHANT_ID=your-merchant-uuid
NEXT_PUBLIC_KYA_API_KEY=mk_live_your_key

Hydrogen / Cloudflare Workers:

# Secrets — injected into env at runtime, not committed to source.
wrangler secret put KYA_MERCHANT_ID
wrangler secret put KYA_API_KEY

# Add both to your Env interface in env.d.ts so TypeScript sees them:
#   KYA_MERCHANT_ID: string;
#   KYA_API_KEY: string;

Express / Node.js:

# .env (use dotenv or your process manager to load it)
KYA_MERCHANT_ID=your-merchant-uuid
KYA_API_KEY=mk_live_your_key

3. Install the server middleware (required)

This step is not optional. Without the server middleware, the client provider has nowhere to send events — guest passes never get issued, returning-visitor tracking breaks, and the Radar dashboard shows empty data.

Next.js (App Router):

// middleware.ts
import { handleRadarRequest } from "@kyalabs/agent-radar/server";
import { NextResponse } from "next/server";

export async function middleware(request) {
  await handleRadarRequest(
    {
      merchantId: process.env.KYA_MERCHANT_ID!,
      apiKey: process.env.KYA_API_KEY!,
      endpoint: "https://www.kyalabs.io",
    },
    request,
  );
  return NextResponse.next();
}

export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};

The endpoint MUST be https://www.kyalabs.io (with the www subdomain) — that's the kya backend. This is not a placeholder for your own domain. The apex kyalabs.io 307-redirects and will break the forward path.

Express / Connect:

import express from "express";
import {
  radarMiddleware,
  radarReportHandler,
  liteEnrollHandler,
} from "@kyalabs/agent-radar/server";

const app = express();

const radarConfig = {
  merchantId: process.env.KYA_MERCHANT_ID!,
  apiKey: process.env.KYA_API_KEY!,
  endpoint: "https://www.kyalabs.io",
};

// Detection middleware — runs on every request.
app.use(radarMiddleware(radarConfig));

// Signal report route — client POSTs signal batches here.
app.post("/kya/report", radarReportHandler(radarConfig));

// LiteBadge enrollment route — agent accepts negotiation.
app.post("/api/radar/lite-enroll", liteEnrollHandler(radarConfig));

Hydrogen (Remix on Cloudflare Workers): see the full reference implementation below — Hydrogen has enough moving parts that we ship it as a complete, working example instead of a snippet.

4. Add the /kya/report route handler (Next.js)

The client library in Step 5 POSTs behavioral signals to /kya/report on your own domain. This route forwards those signals to the kya backend for classification and writes guest passes back to the browser. Without it, the client library will 404 and you'll see detection events but no full session data.

// app/kya/report/route.ts
import { handleRadarReport } from "@kyalabs/agent-radar/server";

export async function POST(request: Request) {
  return handleRadarReport(
    {
      merchantId: process.env.KYA_MERCHANT_ID!,
      apiKey: process.env.KYA_API_KEY!,
      endpoint: "https://www.kyalabs.io",
    },
    request,
  );
}

Express users: the app.post("/kya/report", radarReportHandler(radarConfig)) line in Step 3 already covers this. Skip to Step 5.

5. Install the client provider

Wrap your app with RadarProvider. Detection starts automatically. RadarProvider is a client component (declared with "use client") so it's safe to import directly from a server component in the Next.js App Router.

// app/layout.tsx
import { RadarProvider } from "@kyalabs/agent-radar/client";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <RadarProvider
          merchantId={process.env.NEXT_PUBLIC_KYA_MERCHANT_ID!}
          apiKey={process.env.NEXT_PUBLIC_KYA_API_KEY!}
          endpoint="/kya/report"
        >
          {children}
        </RadarProvider>
      </body>
    </html>
  );
}

The client endpoint is /kya/report (a relative path to your own domain) — NOT https://www.kyalabs.io. The server middleware's endpoint is https://www.kyalabs.io. These are intentionally different. The client POSTs to your own site, your server route handler forwards to kya. This keeps all client-visible traffic on your own origin for CSP compatibility.

Integration health check: If the provider detects an agent but the _kya_gp cookie is not set within 30 seconds, it logs a loud console.error pointing at the server-middleware setup guide. This fires during local development too — it's designed to be the first thing you notice when your integration is incomplete.

6. Read detection state

import { useRadar } from "@kyalabs/agent-radar/client";

function ProductPage() {
  const { isAgent, agentType, confidence, guestPassId } = useRadar();
  // isAgent: true if confidence >= 0.60
  // agentType: "GPT-4o" | "Claude" | "unknown-automated" | null
  // confidence: 0.0-1.0
  // guestPassId: guest pass token if issued (requires server middleware)
}

Reference implementations

Complete, working integrations ship inside this npm package under examples/. They're not documentation snippets — they're the actual code that runs on real stores. When this README and the reference code disagree, the reference code is right.

| Framework | Status | Path | |---|---|---| | Hydrogen (Remix on Cloudflare Workers) | ✅ Production | examples/hydrogen-agenticdepot/ | | Next.js (App Router on Vercel) | 🚧 Coming soon | — | | Express (Node.js) | 🚧 Coming | — | | Remix (standalone, not Hydrogen) | 🚧 Coming | — |

Hydrogen — full reference implementation

The canonical Hydrogen integration lives at examples/hydrogen-agenticdepot/ in this package. It deploys to Cloudflare Workers, runs all 21 signals, and passes end-to-end detection tests. The files below are the load-bearing pieces. Copy them verbatim, change the env var values, deploy.

server.ts (the Worker entry point)

import * as serverBuild from "virtual:react-router/server-build";
import { createRequestHandler, storefrontRedirect } from "@shopify/hydrogen";
import { createHydrogenRouterContext } from "~/lib/context";
import type { RadarMiddlewareConfig } from "@kyalabs/agent-radar/shared";

/** Cookie name must match the SDK constant. */
const GUEST_PASS_COOKIE = "_kya_gp";
/** 90 days in seconds. */
const GUEST_PASS_MAX_AGE = 7776000;

/**
 * Build Radar server config from environment variables.
 * The server config points to the kya backend for guest pass API calls.
 */
function buildRadarConfig(env: Env): RadarMiddlewareConfig {
  return {
    merchantId: env.KYA_MERCHANT_ID,
    apiKey: env.KYA_API_KEY,
    endpoint: "https://www.kyalabs.io",
    apiBasePath: "/api",
    debug: process.env.NODE_ENV !== "production",
  };
}

/**
 * Parse a named cookie from a Cookie header string.
 */
function getCookie(cookieHeader: string | null, name: string): string | null {
  if (!cookieHeader) return null;
  const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`));
  if (!match) return null;
  try {
    return decodeURIComponent(match[1]);
  } catch {
    return null;
  }
}

/**
 * Lazy-load Radar server functions.
 * @kyalabs/agent-radar/server imports node:crypto at the top level, which
 * fails during Vite's SSR module resolution in the MiniOxygen dev runtime.
 * Dynamic import defers this to request time when Workerd can resolve it.
 *
 * This is a temporary workaround — a future SDK release will move the
 * node:crypto binding behind an internal lazy import and remove the need
 * for this pattern. Until then, every Hydrogen integration needs it.
 */
let radarServer: typeof import("@kyalabs/agent-radar/server") | null = null;
async function getRadarServer() {
  if (!radarServer) {
    radarServer = await import("@kyalabs/agent-radar/server");
  }
  return radarServer;
}

export default {
  async fetch(
    request: Request,
    env: Env,
    executionContext: ExecutionContext,
  ): Promise<Response> {
    try {
      const url = new URL(request.url);
      const radarConfig = buildRadarConfig(env);

      // ── Radar signal report route ────────────────────────────────
      // Client-side reporter POSTs signal batches here.
      // handleRadarReport merges client + server signals, may issue guest pass.
      if (url.pathname === "/kya/report" && request.method === "POST") {
        let drainForwardQueue: (() => Promise<void>) | null = null;
        try {
          const radar = await getRadarServer();
          drainForwardQueue = radar.drainForwardQueue;
          return await radar.handleRadarReport(radarConfig, request);
        } catch (e) {
          // Fail open — Radar errors must never break the storefront.
          console.error("[Radar] report handler error:", e);
          return new Response('{"error":"internal"}', {
            status: 500,
            headers: { "Content-Type": "application/json" },
          });
        } finally {
          // Always drain pending forwards, even on error. The drain fn must
          // be captured BEFORE the try block so the finally can still reach
          // it if the dynamic import itself throws.
          if (drainForwardQueue) executionContext.waitUntil(drainForwardQueue());
        }
      }

      // ── Radar lite-enroll route ─────────────────────────────────
      // Agent called window.__kya_negotiation.accept() — proxy to kya backend.
      if (url.pathname === "/api/radar/lite-enroll") {
        let drainLiteEnroll: (() => Promise<void>) | null = null;
        try {
          const radar = await getRadarServer();
          drainLiteEnroll = radar.drainForwardQueue;
          return await radar.handleLiteEnroll(radarConfig, request);
        } catch (e) {
          console.error("[Radar] lite-enroll handler error:", e);
          return new Response('{"error":"internal"}', {
            status: 500,
            headers: { "Content-Type": "application/json" },
          });
        } finally {
          if (drainLiteEnroll) executionContext.waitUntil(drainLiteEnroll());
        }
      }

      // ── Radar server-side detection ──────────────────────────────
      // Runs on every page request. Inspects headers, IP, UA for N1-N5.
      let radarResult: Awaited<
        ReturnType<typeof import("@kyalabs/agent-radar/server").handleRadarRequest>
      > | null = null;

      let drainDetection: (() => Promise<void>) | null = null;
      try {
        const radar = await getRadarServer();
        drainDetection = radar.drainForwardQueue;
        radarResult = await radar.handleRadarRequest(radarConfig, request);
      } catch (e) {
        // Fail open — detection errors must never block the page.
        console.error("[Radar] detection error:", e);
      } finally {
        if (drainDetection) executionContext.waitUntil(drainDetection());
      }

      const hydrogenContext = await createHydrogenRouterContext(
        request,
        env,
        executionContext,
      );

      const handleRequest = createRequestHandler({
        build: serverBuild,
        mode: process.env.NODE_ENV,
        getLoadContext: () => hydrogenContext,
      });

      const response = await handleRequest(request);

      if (hydrogenContext.session.isPending) {
        response.headers.append("Set-Cookie", await hydrogenContext.session.commit());
      }

      // ── Guest pass cookie ────────────────────────────────────────
      // handleRadarRequest returns guestPassId but does NOT set cookies.
      // We must construct Set-Cookie manually. A future SDK release will
      // attach this directly to the response; until then, every non-Express
      // integration needs this block.
      if (radarResult) {
        const existingGp = getCookie(
          request.headers.get("cookie"),
          GUEST_PASS_COOKIE,
        );
        const newGpId = radarResult.classification.guestPassId;

        if (newGpId && newGpId !== existingGp) {
          response.headers.append(
            "Set-Cookie",
            `${GUEST_PASS_COOKIE}=${encodeURIComponent(newGpId)}; Path=/; SameSite=Lax; Max-Age=${GUEST_PASS_MAX_AGE}; Secure`,
          );
        }
      }

      if (response.status === 404) {
        return storefrontRedirect({
          request,
          response,
          storefront: hydrogenContext.storefront,
        });
      }

      return response;
    } catch (error) {
      console.error(error);
      return new Response("An unexpected error occurred", { status: 500 });
    }
  },
};

app/root.tsx (the loader pattern)

The client provider needs KYA_MERCHANT_ID and KYA_API_KEY at render time, but Hydrogen env vars only exist on the server. The loader reads from env, passes them down through useRouteLoaderData, and the root component spreads them into <RadarProvider>.

import { RadarProvider } from "@kyalabs/agent-radar/client";
import { useRouteLoaderData, Outlet } from "react-router";
import type { Route } from "./+types/root";

export async function loader(args: Route.LoaderArgs) {
  const { env } = args.context;

  // ... your existing critical/deferred data loads ...

  return {
    // ... your existing return fields ...

    // kya Radar — client-side detection config.
    // mk_live_* keys are publishable (safe for client-side).
    // The client `endpoint` is the relative path to your own /kya/report
    // route handler — NOT https://www.kyalabs.io.
    radarConfig: {
      merchantId: env.KYA_MERCHANT_ID,
      apiKey: env.KYA_API_KEY,
      endpoint: "/kya/report",
      debug: process.env.NODE_ENV !== "production",
    },
  };
}

export default function App() {
  const data = useRouteLoaderData<typeof loader>("root");

  if (!data) return <Outlet />;

  return (
    <RadarProvider {...data.radarConfig}>
      {/* your app tree */}
      <Outlet />
    </RadarProvider>
  );
}

wrangler.toml

name = "your-worker-name"
main = "dist/server/index.js"
compatibility_date = "2025-04-01"
compatibility_flags = ["nodejs_compat"]

[assets]
directory = "dist/client"

nodejs_compat is required — the server middleware uses node:crypto for guest pass signing. Without it the Worker fails to start.

Deploy

# Set secrets once per environment — they live in CF, not in .env files.
wrangler secret put KYA_MERCHANT_ID
wrangler secret put KYA_API_KEY

# ALWAYS build before deploy — wrangler won't do it for you.
npm run build
wrangler deploy

If you skip npm run build, Wrangler deploys whatever was in dist/ last, which is a silent regression. Always build first.

Verify the install

# 1. Hit the Worker URL with a known-bot UA. Should return 200.
curl -A "GPTBot/1.0" https://your-worker.workers.dev/

# 2. POST a dummy signal report. Should return 200 or 400 — NOT 404.
# A 404 means your /kya/report route isn't wired up.
curl -X POST https://your-worker.workers.dev/kya/report \
  -H "Content-Type: application/json" \
  -d '{"signals":{},"classification":{"classification":"unknown","confidence":0}}'

# 3. Check the dashboard at kyalabs.io/radar/dashboard within a few
# minutes of real traffic. The "Top agents" and "Top pages" panels
# should populate.

Configuration

RadarConfig

| Option | Type | Default | Description | |--------|------|---------|-------------| | merchantId | string | required | Your merchant ID from kyaLabs | | apiKey | string | required | Publishable API key (mk_live_*) — safe for client-side | | endpoint | string | "" | Client-side: relative path to your own /kya/report route. Server-side: https://www.kyalabs.io. Never https://yourdomain.com — that's a placeholder. | | apiBasePath | string | "/api" | Base path prefix for kya API routes. All internal requests use ${endpoint}${apiBasePath}/.... You rarely need to change this; the Hydrogen reference impl sets it explicitly only as a self-documenting marker. | | batchInterval | number | 5000 | Client signal batch interval (ms) | | debug | boolean | false | Enable console.warn debug logging. Recommended pattern: debug: process.env.NODE_ENV !== "production" |

RadarMiddlewareConfig (extends RadarConfig)

| Option | Type | Default | Description | |--------|------|---------|-------------| | extraUaPatterns | Array<{pattern, agentType}> | [] | Additional UA regex patterns to match as agents | | extraCidrRanges | Array<{provider, ranges}> | [] | Additional CIDR ranges to match as agent IPs | | secureCookies | boolean | true | Set Secure flag on cookies. Set false for local HTTP dev. | | forwardPolicy | "agent_only" \| "agent_and_gray" \| "full" | "agent_only" | Controls which detection tiers are forwarded. agent_only forwards confirmed agents (>= 0.60). agent_and_gray adds sampled uncertain events (0.40-0.59). full adds anonymous hourly visit counts. | | grayZoneSampleRate | number | 0.1 | Fraction of gray-zone events to forward. Only applies when forwardPolicy includes gray zone. |


After you deploy: which URL to test

Your worker or server has two URLs that matter. Test the right one.

  • *.workers.dev (or your Vercel preview URL) — the default deploy URL. Every merchant gets one automatically. This is what you hit first to verify the install works. No DNS setup required.
  • Your custom domain (e.g. store.example.com) — bound in wrangler.toml or your Vercel project dashboard. This is the URL your real customers use. Agent traffic only shows up in the dashboard once real visitors hit this URL.

If you don't see data in the dashboard:

  1. Hit POST /kya/report on your deployed URL with a dummy payload. A 400 Bad Request means your route handler is wired up (it's rejecting the dummy payload). A 404 means the route isn't deployed.
  2. Check that the handleRadarRequest server middleware is running on every page request — not just on a specific path.
  3. If you're on Hydrogen, confirm compatibility_flags = ["nodejs_compat"] is in wrangler.toml AND that you ran npm run build before wrangler deploy.

Testing your install: what lands where

Radar splits declared cooperative crawlers (HTTP fetchers that identify in User-Agent) from shopping-class agent sessions (merged client + server signals). They use different tables and dashboard lanes.

| You did... | What the backend stores | Where it shows up | |---|---|---| | curl -A "GPTBot/1.0" https://yoursite/ (middleware has endpoint + apiKey) | Row in radar_crawler_events via POST /api/radar/detect with tier: crawler (N1 — network signals). Guest pass may still issue separately if confidence crosses the threshold. | Radar dashboard AI crawlers card, Stats API crawler_distribution | | Real browser visit (or Playwright with a normal browser UA) | Shopping pipeline: client batch to /kya/report, merged classification, tier: agent forward when eligible → radar_events. Guest pass if HIGH. | Agent Traffic %, model distribution, Top Pages, Top Agents | | Browser automation spoofing a crawler UA (e.g. Playwright + GPTBot) | Crawler row from middleware as above; signal-report does not also emit shopping-class tier: agent when the cached server class is a declared crawler (avoids duplicate shopping rows). | Crawler lane + (if applicable) guest pass path |

The practical implication: a curl -A GPTBot test proves the server middleware is forwarding the crawler lane. A normal browser test proves the client provider + /kya/report route for shopping-class analytics. Both pass = full install.

Signal report canary

If your site is quiet (dev environment, new store), you can self-test the /kya/report route without waiting for real agent traffic:

# On a wired-up install: returns 400 (rejects the dummy payload shape).
# On a broken install: returns 404.
curl -X POST https://your-site.example.com/kya/report \
  -H "Content-Type: application/json" \
  -d '{"empty":true}'

A 400 means the route handler is deployed and the signal-report code is running. A 404 means you missed Step 4.


API Reference

GET /api/radar/stats

Pull your merchant's aggregated detection stats programmatically. Same data the dashboard uses.

Request:

curl -H "Authorization: Bearer mk_live_your_key" \
  "https://www.kyalabs.io/api/radar/stats?period=7d"

Query parameters:

| Param | Type | Default | Allowed | |---|---|---|---| | period | string | 7d | 7d, 14d, 30d. Free tier is capped at 14 days; 30d requires upgrade. |

Authentication: Bearer token with your mk_live_* API key. Returns 401 if missing, 403 if the key doesn't have dashboard scope.

Response 200:

{
  // ── Traffic totals ──
  "total_events": 14523,          // all events in window (agents + humans + gray zone)
  "agent_events": 1847,           // events classified as agents (confidence >= 0.60)
  "total_visits": 12104,          // unique visits (dedupes rapid-fire events)
  "agent_traffic_pct": 12.7,      // agent_events / total_events * 100
  "guest_passes_issued": 1612,    // count of _kya_gp cookies issued
  "returning_agent_pct": 23.4,    // percent of agent sessions seen on 2+ distinct days
  "guest_pass_coverage": 0.87,    // fraction of agent events that carried a guest_pass_id
                                  // at ingest time. Below 0.10 → server middleware missing.

  "unique_agents": 342,           // distinct agent_type + fingerprint combos
  "sessions_delta_7d": 14,        // percent change in agent sessions vs prior period

  // ── Funnel ──
  "funnel": {
    "homepage": 1847,
    "product": 912,
    "cart": 403,
    "checkout": 84
  },
  "funnel_conversion": 4.5,       // checkout / homepage * 100, or null if no traffic

  // ── Breakdowns ──
  "model_distribution": [
    { "agent_type": "GPTBot", "count": 842 },
    { "agent_type": "ClaudeBot", "count": 611 },
    { "agent_type": "PerplexityBot", "count": 394 }
  ],
  "classification_breakdown": [
    { "classification": "high", "count": 1847 },
    { "classification": "probable", "count": 203 },
    { "classification": "possible", "count": 89 }
  ],
  "top_pages": [
    { "page_url": "/", "count": 523 },
    { "page_url": "/products/widget", "count": 287 }
  ],

  // ── Time series ──
  "daily_time_series": [
    { "day": "2026-04-02", "total_events": 2103, "agent_events": 267, "total_visits": 1890 }
    // ...
  ],
  // Optional — present only on hourly-granularity tiers (Pro+).
  "time_series": [
    { "hour_bucket": "2026-04-08T14:00:00Z", "total_events": 87, "agent_events": 12, "guest_passes_issued": 11, "returning_agents": 3, "total_visits": 78 }
    // ...
  ],

  // ── Metadata ──
  "period": "7d",
  "stats_granularity": "daily"    // "daily" | "hourly" | "raw" — depends on your tier
}

Error responses:

  • 400 invalid_periodperiod query param not in {7d, 14d, 30d}
  • 401 — missing or invalid bearer token
  • 403 tier_gate — requested period exceeds your plan's retention window. Response includes retention_days.
  • 429 — rate limited (per-merchant + per-endpoint limits apply)
  • 500 internal_error — backend RPC failure

Rate limits: 60 requests per minute per merchant on the free tier, higher on paid tiers. Check the X-RateLimit-* response headers.


Detection signals

21 signals across four layers:

Behavioral (B1-B8): Pointer absence, scroll depth, dwell time, click precision, WebDriver flags, automation artifacts, form fill speed, CTA blindness

Environment (P1-P5): Permissions API contradiction, SpeechSynthesis void, AudioContext hash, CSS preference void, hardware geometry anomaly

Shopping (B9-B11): Image engagement blindness, mouse teleportation, hyper-velocity carting

Network (N1-N5, server-side): Known agent UA patterns, known agent IP ranges (CIDR), JA4+ fingerprint, header anomalies, cooperative identity headers (Kya-Token, X-UCP-Agent-ID, X-TAP-Signature)

Execution budget: <15ms main thread for client signals. Server signals add zero latency (header inspection only).


How it works

  1. Server middleware runs first — inspects headers, IP, UA for network signals (N1-N5)
  2. Client detection runs after page load — behavioral signals (B1-B11), environment probes (P1-P5)
  3. Feedback loop — client POSTs signals to /kya/report, server route forwards to kya, merges with cached network classification
  4. Guest pass — if merged confidence >= 0.60, a guest pass (_kya_gp cookie) is issued automatically
  5. Tiered forwarding — events are forwarded to the kya backend based on confidence: agent events (>= 0.60) as full rows, gray-zone events (0.40-0.59) as sampled lean payloads for detection tuning, and likely-human events (< 0.40) as anonymous hourly aggregate counts
  6. Identity ladder — guest pass can upgrade to LiteBadge (agent opts in) then full Badge (user enrolls)

SDK exports

Client exports (@kyalabs/agent-radar/client)

| Export | Description | |---|---| | RadarProvider | React context provider. Starts detection on mount, cleans up on unmount. | | useRadar() | Hook returning { isAgent, agentType, confidence, classification, guestPassId } | | shouldNegotiate() | Check if LiteBadge negotiation should be offered to a returning agent | | injectNegotiation() | Inject the LiteBadge DOM element and window.__kya_negotiation global | | removeNegotiation() | Clean up LiteBadge DOM element |

Server exports (@kyalabs/agent-radar/server)

Express-style handlers:

| Export | Description | |---|---| | radarMiddleware(config) | Express/Connect middleware — runs N1-N5, classifies, issues guest passes | | radarReportHandler(config) | Express handler for client signal reports (feedback loop) | | liteEnrollHandler(config) | Express handler for the LiteBadge enrollment route |

Web API (fetch-style) handlers:

| Export | Description | |---|---| | handleRadarRequest(config, request) | Same as radarMiddleware but returns { classification } instead of mutating a res object. Use in Next.js middleware, Hydrogen server.ts, Vercel Edge. | | handleRadarReport(config, request) | Fetch-style version of radarReportHandler. Returns a Response. | | handleLiteEnroll(config, request) | Fetch-style version of liteEnrollHandler. |

Queue management:

| Export | Description | |---|---| | drainForwardQueue() | Awaits all pending forward requests. Required on Cloudflare Workers — call in ctx.waitUntil(drainForwardQueue()) inside a finally block so forwards complete after the response is sent. Without it, events silently drop. |

Low-level (rarely needed):

| Export | Description | |---|---| | issueGuestPass(config) / sendHeartbeat(config) | Direct guest pass API calls | | classifyServer() / mergeClassification() | Classification helpers | | handleSignalReport() | Low-level signal report processor | | Signal and forward helpers | See src/server/signals.ts, src/server/forward.ts |

Shared exports (@kyalabs/agent-radar/shared)

Types (Classification, RadarConfig, RadarMiddlewareConfig, SignalBag, SignalId, etc.) and the signal registry.


Integration patterns

These patterns come from the Hydrogen reference impl above. They apply to any Cloudflare Workers or edge-runtime deployment.

Fail-open wrapping

Radar errors must never break the storefront. Every handler in the reference impl is wrapped:

try {
  const radar = await getRadarServer();
  return await radar.handleRadarReport(radarConfig, request);
} catch (e) {
  console.error("[Radar] report handler error:", e);
  return new Response('{"error":"internal"}', { status: 500 });
} finally {
  // drain regardless of success/failure
}

Use the [Radar] log prefix so errors are greppable.

Lazy-loading @kyalabs/agent-radar/server

The server entry point imports node:crypto at the top level, which fails during Vite's SSR module resolution in the MiniOxygen dev runtime. Workaround: a memoized dynamic import.

let radarServer: typeof import("@kyalabs/agent-radar/server") | null = null;
async function getRadarServer() {
  if (!radarServer) {
    radarServer = await import("@kyalabs/agent-radar/server");
  }
  return radarServer;
}

A future SDK release will move node:crypto behind an internal lazy binding and remove the need for this pattern. Until then, every Hydrogen / MiniOxygen integration needs it.

Capturing drainForwardQueue before try

With the lazy-load pattern, drainForwardQueue is only accessible after await import() resolves. If the import itself throws, your finally block has no drain function to call.

Right:

let drainForwardQueue: (() => Promise<void>) | null = null;
try {
  const radar = await getRadarServer();
  drainForwardQueue = radar.drainForwardQueue;  // capture before any throw
  return await radar.handleRadarReport(radarConfig, request);
} finally {
  if (drainForwardQueue) executionContext.waitUntil(drainForwardQueue());
}

Wrong:

try {
  const radar = await getRadarServer();
  return await radar.handleRadarReport(radarConfig, request);
} finally {
  // BUG: if getRadarServer() throws, `radar` is undefined here
  executionContext.waitUntil(radar.drainForwardQueue());
}

Manual guest pass cookie construction

handleRadarRequest returns { classification: { guestPassId } } but does NOT set the Set-Cookie header on your response. You must attach it yourself:

if (radarResult) {
  const existingGp = getCookie(request.headers.get("cookie"), "_kya_gp");
  const newGpId = radarResult.classification.guestPassId;
  if (newGpId && newGpId !== existingGp) {
    response.headers.append(
      "Set-Cookie",
      `_kya_gp=${encodeURIComponent(newGpId)}; Path=/; SameSite=Lax; Max-Age=7776000; Secure`,
    );
  }
}

Cookie max-age is 7,776,000 seconds (90 days).

Who needs this manual step:

  • Hydrogen / Cloudflare Workers / Vercel Edge using handleRadarRequest — yes, for the same-request cookie attachment pattern shown in the reference impl. Without it, the first page response has no cookie; the client library fires signals to /kya/report a few seconds later and the cookie gets set on that response instead. Manual construction is an optimization that attaches the cookie on the first page response.
  • Express users on radarMiddleware — no. The Express handler sets Set-Cookie on res directly.
  • Next.js users — no, not strictly. Cookies get set automatically on the /kya/report route response (via handleRadarReport), which runs a few seconds after the first page load when the client library POSTs. For first-page cookie attachment, read handleRadarRequest's return value in your middleware.ts and set it on the NextResponse via response.cookies.set("_kya_gp", ...).

A future SDK release will attach Set-Cookie directly to the handleRadarRequest return value so this manual step goes away.

Recommended debug flag

debug: process.env.NODE_ENV !== "production"

Verbose logs in dev, silent in prod. The Hydrogen reference impl uses this exact pattern.


Privacy

Radar collects detection signals only. No direct personal identifiers (names, emails, payment details) are intentionally collected.

Collected on the merchant edge: Behavioral signal results (boolean flags + timing), classification band, confidence score, page URLs, hashed IP (SHA-256), user-agent string, guest pass token.

Forwarded to the kya backend (tiered by confidence):

  • Agent events (>= 0.60): Classification, confidence, agent type, page URL, guest pass ID. No raw signal data, no user-agent string.
  • Gray-zone events (0.40-0.59): Confidence and top 3 signal IDs only. Sampled at 10%. No page URL, no IP hash, no full signal data. Retained 30 days.
  • Likely-human events (< 0.40): Anonymous hourly aggregate count per merchant. No page URL (would expose merchant checkout volume), no individual-level detail.

Not collected or forwarded: Names, emails, addresses, payment info, browsing history, cross-site tracking data, cookies beyond _kya_gp.

Note: URLs, user-agent strings, and IP-derived hashes may constitute personal data in some jurisdictions. Avoid passing sensitive identifiers in URL query parameters or custom headers.

The _kya_gp cookie is a guest pass token — an opaque string that cannot be reversed to identify a person. It expires after 90 days.

Merchant disclosure: Merchants deploying Radar should disclose that behavioral detection runs on all visitors. Detection signals are collected client-side on every visitor to make classification decisions — only the persistence to the kya backend is tiered by confidence.

Can I run Radar without sending anything to kya? No. Forwarding detection events to the kya backend is core to the product — that's how merchants get the dashboard, how returning visitors are tracked, and how the kyaScore reputation system learns. If you need fully self-hosted detection, contact [email protected] about an enterprise license.

See kyalabs.io/privacy for the full privacy policy.


Dashboard

View your agent traffic at kyalabs.io/radar/dashboard. The in-dashboard Setup tab shows your merchant ID, API key, and copy-pasteable integration snippets — mirror of the Quick Start above.

For programmatic access, see API Reference above.


Security

Found a vulnerability? See SECURITY.md for our disclosure policy. Report to [email protected] — we aim to acknowledge within 2 business days.

Commercial use

This package is free to use under the terms in LICENSE for integrating Radar into your own web properties. For enterprise terms, volume pricing, or redistribution rights, contact [email protected].

License

Proprietary. See LICENSE for terms.