@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
Maintainers
Readme
@kyalabs/agent-radar
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-radarRequirements
| 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 asKYA_MERCHANT_ID. - API key —
mk_live_*prefix, 64-character hex body. Set asKYA_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_keyHydrogen / 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_key3. 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
endpointis/kya/report(a relative path to your own domain) — NOThttps://www.kyalabs.io. The server middleware'sendpointishttps://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 deployIf 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 inwrangler.tomlor 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:
- Hit
POST /kya/reporton your deployed URL with a dummy payload. A400 Bad Requestmeans your route handler is wired up (it's rejecting the dummy payload). A404means the route isn't deployed. - Check that the
handleRadarRequestserver middleware is running on every page request — not just on a specific path. - If you're on Hydrogen, confirm
compatibility_flags = ["nodejs_compat"]is inwrangler.tomlAND that you rannpm run buildbeforewrangler 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_period—periodquery param not in{7d, 14d, 30d}401— missing or invalid bearer token403 tier_gate— requested period exceeds your plan's retention window. Response includesretention_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
- Server middleware runs first — inspects headers, IP, UA for network signals (N1-N5)
- Client detection runs after page load — behavioral signals (B1-B11), environment probes (P1-P5)
- Feedback loop — client POSTs signals to
/kya/report, server route forwards to kya, merges with cached network classification - Guest pass — if merged confidence >= 0.60, a guest pass (
_kya_gpcookie) is issued automatically - 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
- 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/reporta 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 setsSet-Cookieonresdirectly. - Next.js users — no, not strictly. Cookies get set automatically on the
/kya/reportroute response (viahandleRadarReport), which runs a few seconds after the first page load when the client library POSTs. For first-page cookie attachment, readhandleRadarRequest's return value in yourmiddleware.tsand set it on theNextResponseviaresponse.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.
