Is Unknown an error or a result?
When you start classifying interactions as Human, Human + AI, Agent or Unknown, the Unknown bucket is often larger than people expect. It is tempting to treat that as a gap to hide, either by folding Unknown into Human or by treating it as suspicious. Both choices cause problems.
Unknown means the available evidence does not support a reliable determination. A request from a client that runs no JavaScript, a call to an endpoint where you collect no signals, a session that ended after one page: all of these can be perfectly legitimate, and none of them gives you enough to say who or what was acting.
Reporting Unknown honestly is what keeps the other classes trustworthy. If your system never says Unknown, every Human result is partly a guess, and you cannot tell which part. Our post on why Unknown is a valid result when classifying AI traffic sets out the reasoning in more depth.
This guide covers how to handle Unknown in practice: explicit per-route fallbacks, the rules that keep it from leaking into other classes, how to monitor it, and how to reduce it where it matters.
Step 1: Never default to Human
The most common mistake is a classification function that returns Human whenever it finds no automation signal. It looks reasonable and it is wrong. Absence of a bot signal is not presence of a human signal.
// Wrong: silence becomes Human
function classifyWrong(e: Evidence) {
if (e.signatureValid) return { actor: "agent", basis: "verified" };
if (e.declaredAgent) return { actor: "agent", basis: "declared" };
return { actor: "human", basis: "inferred" };
}
// Right: Human requires positive evidence, otherwise Unknown
function classify(e: Evidence) {
if (e.signatureValid) return { actor: "agent", basis: "verified" };
if (e.declaredAgent) return { actor: "agent", basis: "declared" };
if (e.interactiveSession) return { actor: "human", basis: "inferred" };
return { actor: "unknown", basis: "unknown" };
}Apply the same rule everywhere results are produced: timeouts, errors, stale cache entries and missing telemetry all produce Unknown. Write a test for each of these paths so the behavior cannot regress quietly. The example below uses the built-in Node.js test runner.
import { test } from "node:test";
import assert from "node:assert/strict";
test("missing evidence is unknown, not human", () => {
const r = classify({ signatureValid: false, declaredAgent: false, interactiveSession: false });
assert.equal(r.actor, "unknown");
});Step 2: Give every route an explicit fallback
Unknown needs a defined behavior, and the right behavior depends on the route. Decide it per route, write it down, and keep it in configuration. For how these fallbacks fit a wider policy model, see designing app-level policies for humans and AI agents.
| Route type | Example | Sensible fallback for Unknown |
|---|---|---|
| Public content | Marketing pages, docs | Allow, standard response |
| Read-only data | Catalog, search API | Allow with the standard rate limit |
| Account creation | Signup | Keep existing checks, optional review queue |
| Sensitive change | Password, email, billing details | Existing re-authentication, no relaxation |
| High-value action | Bulk export, admin operations | Existing strong authorization |
The pattern is simple. For low-risk routes, Unknown behaves like normal traffic. For high-risk routes, Unknown gets the same protection the route already has. In neither case does the actor result make things worse for an unclassified but legitimate user.
type Fallback = "allow" | "standard_limit" | "existing_checks" | "review";
const unknownFallback: Record<string, Fallback> = {
"GET /docs/*": "allow",
"GET /api/catalog": "standard_limit",
"POST /signup": "existing_checks",
"POST /account/password": "existing_checks",
"POST /exports": "review",
};Step 3: Treat timeouts and errors the same way
Actor evaluation sits on the request path, so it must have a strict time budget. When the budget is exceeded, produce Unknown and apply the route fallback. Never block a request because evaluation was slow.
async function withBudget<T>(work: Promise<T>, ms: number, onTimeout: T): Promise<T> {
let timer: NodeJS.Timeout;
const timeout = new Promise<T>((resolve) => {
timer = setTimeout(() => resolve(onTimeout), ms);
});
try {
return await Promise.race([work, timeout]);
} finally {
clearTimeout(timer!);
}
}
const result = await withBudget(evaluateActor(req), 150, { actor: "unknown", basis: "unknown" });Record why a result is Unknown: no evidence, timeout, evaluation error or stale evidence. Those reasons tell you what to fix.
Step 4: Do not treat Unknown as suspicious
Unknown is not a risk score. Many legitimate clients will always be Unknown on some routes: command-line tools, server-side integrations without signatures, accessibility tools, privacy-focused browsers and simple feed readers. Even large AI operators mostly identify their fetchers by user agent and published IP ranges today. OpenAI's crawler documentation, for example, lists a user agent string and a published IP address file for each of its bots as of November 2026.
If you want to act on suspicious behavior, base that on behavior you can describe, such as request rates, failed login patterns or abuse reports, with its own evidence. Keep that logic separate from the actor class so neither distorts the other.
Step 5: How do you monitor your unknown rate?
The unknown rate is the share of interactions classified Unknown. Track it per route, per integration context and over time. It is one of the most useful health metrics you have.
type Row = { route: string; actor: string };
export function unknownRate(rows: Row[]) {
const byRoute = new Map<string, { total: number; unknown: number }>();
for (const r of rows) {
const s = byRoute.get(r.route) ?? { total: 0, unknown: 0 };
s.total += 1;
if (r.actor === "unknown") s.unknown += 1;
byRoute.set(r.route, s);
}
return [...byRoute].map(([route, s]) => ({ route, rate: s.unknown / s.total, total: s.total }));
}What the numbers tell you:
- A high, stable rate on a route usually means you are missing a signal there. A page that never loads your browser integration, or an API that never sees signed requests, will stay mostly Unknown.
- A sudden rise often means something changed in your stack, for example a release that dropped a header, a CDN rule that strips signature headers, or a frontend change that stopped sending your app's declarations.
- A sudden fall is worth checking too. If Unknown drops while Human rises, confirm that new evidence arrived rather than a fallback quietly changing.
Alert on sharp changes, not on absolute levels. Every application has its own normal.
Step 6: How do you reduce Unknown where it matters?
You do not need to drive Unknown to zero. You need enough evidence on the routes where decisions matter. Work through them in order of impact:
- Add evidence on high-impact routes first. If account changes are mostly Unknown, that is where a browser integration or a server-side check pays off.
- Invite automated clients to identify themselves. Publish documentation for your API that explains how agents can sign requests with HTTP Message Signatures and the IETF Web Bot Auth conventions, and give verified clients a better experience, such as higher limits or structured responses. Clients that can verify will start doing so.
- Use your own application knowledge. If users interact with your built-in assistant, record that as a declaration. Your app often knows more than the network does.
- Fix plumbing. Make sure proxies forward the headers you rely on and that evaluation has enough time budget to complete.
For a practical look at the signals that move traffic out of Unknown, read how to tell if website traffic comes from AI agents.
Step 7: Show Unknown honestly in dashboards
Display Unknown as its own series with the same visual weight as the other classes. Do not hide it in an "other" bucket, and do not color it like an alert. When stakeholders see a large Unknown share, the right reaction is "where do we need more evidence", not "where are the bots".
Step 8: Review Unknown with outcomes, not assumptions
Every few weeks, take a sample of Unknown interactions from your most important routes and look at what actually happened. Did the session complete a purchase, create a project, read three articles, or fail repeatedly at login? Outcomes tell you what kind of traffic sits in the bucket without forcing a premature label onto it.
Group what you find into three practical categories:
- Legitimate and low risk. Feed readers, link previews, monitoring tools and people with privacy settings. Keep the route fallback as it is.
- Legitimate automation that could identify itself. Integrations and agents that would gladly use a structured path or sign requests if you offered one. Publish guidance and reach out where you can.
- Behavior that needs its own control. Credential stuffing or scraping patterns, both catalogued in the OWASP Automated Threats to Web Applications project. Address these with behavior-based controls that have their own evidence, such as login failure rates, and keep them separate from the actor class.
This review keeps your fallbacks grounded in your real traffic and gives you a clear list of where to add evidence next.
Common questions
Should Unknown count toward rate limits? Use your standard limits. Rate limiting by class is fine for verified agents, who can earn higher limits, but Unknown should get the normal limit, not a punitive one.
Can we challenge Unknown users? Only where the route already challenges everyone in that situation. Adding a challenge purely because a result is Unknown penalizes legitimate users whose clients send fewer signals.
Will Unknown shrink as standards mature? As more automated clients sign requests, more agent traffic moves from Unknown or declared to verified. Your monitoring will show that shift route by route.
Where Proof of Actor fits
Proof of Actor reports Unknown as a first-class result, with the reason, and never converts it into Human. Your application chooses the fallback for each route, and the analytics show where coverage ends so you can add evidence where it counts.
To set this up on a production application with our team, apply for early access. To understand exactly what the evidence can establish, read Detection & Limitations.
Sources
- RFC 9421: HTTP Message Signatures: the standard automated clients can use to sign requests
- draft-ietf-webbotauth-httpsig-protocol: IETF Web Bot Auth draft for signed automated traffic (work in progress as of November 2026)
- OpenAI: Overview of OpenAI Crawlers: user agent strings and published IP address files for OpenAI's bots
- OWASP Automated Threats to Web Applications: taxonomy of automated threats such as credential stuffing and scraping
- Node.js test runner: the
node:testmodule used in the example