Server API (TypeScript)
Every function, type and adapter of the TypeScript server half, with its options.
The server half reads the headers of one request. It runs on Node.js 22.12 or later, Cloudflare Workers, Vercel, Deno and Bun. The functions come from botscent/server. Each adapter runs the server half inside one framework. For the Python server half, see Server API (Python).
inspect
Returns the request's own verdict. Reads headers only, and makes no network request.
function inspect(request: RequestLike, options?: InspectOptions): Promise<Verdict>
type RequestLike =
| Request
| {
headers: Headers | { get(name: string): string | null } | Record<string, string | string[] | undefined>
method?: string
url?: string
}
type InspectOptions = {
cf?: CloudflareHints | null
now?: number
debug?: boolean | ((line: string) => void)
}
type CloudflareHints = {
verifiedBotCategory?: string | null
botManagement?: { verifiedBot?: boolean | null } | null
}import { inspect } from 'botscent/server'
export async function POST(request: Request) {
const verdict = await inspect(request)
// => { type: 'agent', agent_name: 'curl', reasons: ['ua.declared-agent-token'] }
return Response.json(verdict)
}Parameters
| Name | Type | Default | Description |
|---|---|---|---|
request | RequestLike | (required) | A Fetch Request, or an object with headers and an optional method and url. |
options.cf | CloudflareHints | (none) | Cloudflare's request.cf, read for its verified-bot field. |
options.now | number | Date.now() | The request time in milliseconds since the epoch, for tests and replays. |
options.debug | boolean | ((line: string) => void) | (none) | Logs each decision with console.debug, or sends each line to the function. |
Returns
Promise<Verdict>. See The verdict. The function is asynchronous because WebCrypto verifies signatures asynchronously.
Notes
inspectreads the request headers, the method, the URL when given, and the platform hints. It reads no body and no page report.inspectnever throws. On an internal failure, it returns the verdict of the evidence so far, at worst{ type: 'human', reasons: [] }.inspectverifies signatures against signer keys bundled in the release. It makes no network request.- If
debugis unset,BOTSCENT_DEBUG=1inprocess.envturns on debug output. Each line has the prefix[botscent]and shows tokens, signatures, hints and the verdict. - In Nuxt, pass
event.node.req. In SvelteKit, passevent.request.
readReport
Parses a page report. Returns a verdict whose reasons carry the page. prefix, or null.
function readReport(value: string | Verdict | null | undefined): Verdict | nullimport { inspect, readReport } from 'botscent/server'
export async function POST(request: Request) {
const own = await inspect(request)
const report = readReport(request.headers.get('botscent-report'))
// => { type: 'agent', reasons: ['page.browser.webdriver-flag'] }
return Response.json({ request: own, report })
}Parameters
| Name | Type | Default | Description |
|---|---|---|---|
value | unknown | (required) | The Botscent-Report header, the form's botscent field, or a verdict object from verdict. |
Returns
Verdict | null. The value is null for anything malformed, longer than 256 bytes, or of an unknown major version.
Notes
- Every reason of a parsed report gets the
page.prefix. A report therefore never passesisVerified. - A verdict object must follow the same grammar as the wire string.
- A person's page sends no report, so
readReportreturnsnullfor the missing header.
Warning: Do not grant access on a page report. Scripts on the page can forge it. Use
isVerifiedon the verdict frominspect.
combine
Adds a page report's evidence to the request's own verdict.
function combine(request: Verdict, report: Verdict | null | undefined): Verdictimport { combine, inspect, readReport } from 'botscent/server'
export async function POST(request: Request) {
const own = await inspect(request)
const report = readReport(request.headers.get('botscent-report'))
return Response.json({ request: own, report, combined: combine(own, report) })
}Parameters
| Name | Type | Default | Description |
|---|---|---|---|
request | Verdict | (required) | The request's own verdict from inspect. |
report | Verdict | null | undefined | (required) | The page report from readReport. |
Returns
Verdict. The combined verdict is an agent when either verdict is an agent. Its reasons are the request's reasons, then the report's reasons.
Notes
- The
agent_nameis the request's name when it has one. Otherwise it is the report's name. - A missing or empty report returns the request verdict unchanged.
- A combined verdict is for measurement and for changes to the interface. It is never for access.
isVerified
Returns true when the request itself was verified. With a name, returns true only when a verified signature names that agent.
function isVerified(verdict: Verdict | null | undefined, name?: AgentName): booleanimport { inspect, isVerified } from 'botscent/server'
export async function POST(request: Request) {
const verdict = await inspect(request)
if (!isVerified(verdict, 'chatgpt')) return new Response('Forbidden', { status: 403 })
return Response.json({ ok: true })
}Parameters
| Name | Type | Default | Description |
|---|---|---|---|
verdict | Verdict | null | undefined | (required) | The request's own verdict from inspect. |
name | AgentName | (none) | The agent name that a verified signature must give. |
Returns
boolean. Without name, the value is true when reasons contains signer.web-bot-auth.verified or signer.edge-verified-bot without a prefix. With name, it is true when reasons contains signer.web-bot-auth.verified, agent_name is name, and no reason has the page. prefix.
Notes
- Only
inspectproduces the two verified reasons. - The platform's verified-bot field never satisfies a check with a name. The field says that some bot was verified, not which bot.
- To let one agent through, use
isVerified(verdict, 'chatgpt'). The checkisVerified(verdict) && verdict.agent_name === 'chatgpt'can pair a verified bot with a name that it only declared. - Signer keys are pinned in each release. A key that a signer removes still verifies until the installation upgrades. See Versions and stability.
- Verification authenticates the operator's infrastructure, not the person. See The trust model.
Warning: Do not use
agent_namealone to allow access. Anyone can send the headers that produce a name. UseisVerified(verdict, name).
VERSION
Holds the version of the library.
const VERSION: stringimport { VERSION } from 'botscent/server'
console.log(VERSION)botscent/names.json
Gives every agent name as data, with its display name, vendor and kind.
Record<string, { display: string; vendor: string; kind: string }>import names from 'botscent/names.json'
console.log(names['chatgpt'])
// => { display: 'ChatGPT', vendor: 'OpenAI', kind: 'computer-use-agent' }Notes
- The keys are the
agent_namevalues. The Agents page lists them. kindis one ofcomputer-use-agent,automation,fetcher,previewer,crawlerorclient.
Types
botscent/server exports these types. botscent exports AgentName, Reason, Verdict, Diagnostics and StartOptions.
type Verdict = {
readonly type: 'agent' | 'human'
readonly agent_name?: AgentName
readonly reasons: readonly Reason[]
}
type Reason = KnownReason | (string & {})
type AgentName = KnownAgentName | (string & {})
type TransportMode = 'auto' | 'always' | 'never'
// also: CloudflareHints, InspectOptions, RequestLike (see inspect)KnownReason and KnownAgentName list every id in this release. A reason or a name from a newer server still type-checks.
What every adapter does
Every adapter below runs inspect for each request. Most adapters keep the request's own verdict where the framework keeps per-request state. An adapter also does these things:
- It keeps status codes, redirects, cookies, streaming responses, request bodies and the application's own exceptions unchanged.
- It never turns a statically rendered route into a dynamic route.
- It removes inherited
botscententries fromServer-Timing, and keeps all other entries. The Next.js proxy and Vercel Routing Middleware cannot remove an entry that the response itself carries. - If its transport is on, it adds the
botscententry andCache-Control: no-storeto an agent's document navigation. - On its own failure, it gives no evidence and passes the request on.
The transport option controls the Server-Timing entry. 'always' states that no shared cache stores the HTML. See From the server to the page.
botscent/next
Runs the server half as a Next.js proxy.
const proxy: NextProxy
function withBotscent(existing?: NextProxy, options?: BotscentNextOptions): NextProxy
function withBotscent(options: BotscentNextOptions): NextProxy
type Result = Response | NextResponse | null | undefined | void
type NextProxy = (request: NextRequest, event: NextFetchEvent) => Result | Promise<Result>
type BotscentNextOptions = { transport?: TransportMode; debug?: boolean | ((line: string) => void) }import { withBotscent } from 'botscent/next'
export const proxy = withBotscent({ transport: 'auto', debug: true })Parameters
| Name | Type | Default | Description |
|---|---|---|---|
existing | NextProxy | (none) | The app's own proxy, which withBotscent wraps and keeps. |
options.transport | 'auto' | 'always' | 'never' | 'auto' | Sends the entry on Vercel only with 'auto', always with 'always', and never with 'never'. |
options.debug | boolean | ((line: string) => void) | (none) | Logs each decision, as in inspect. |
Notes
- With no proxy in the app,
proxy.tsis one line:export { proxy } from 'botscent/next'. - Before Next.js 16, the file is
middleware.ts, withexport { proxy as middleware } from 'botscent/next'orexport const middleware = withBotscent(existingMiddleware). - The proxy does not pass on the verdict. Route handlers call
inspect(request). - The wrapped proxy's response is kept. Only
Server-Timingand, for agents,Cache-Controlchange. - A proxy cannot see headers that the route sets later. On an agent navigation that it decorates, its
Server-Timingreplaces one that the route set.
Examples
Wrap an existing proxy
Pass the app's own proxy to withBotscent:
import { NextResponse } from 'next/server'
import { withBotscent } from 'botscent/next'
function existingProxy() {
return NextResponse.next()
}
export const proxy = withBotscent(existingProxy)botscent/vercel
Runs the server half as Vercel Routing Middleware, for projects that are not Next.js.
declare const middleware: VercelMiddleware
export default middleware
function withBotscent(existing?: VercelMiddleware, options?: BotscentVercelOptions): VercelMiddleware
function withBotscent(options: BotscentVercelOptions): VercelMiddleware
type Result = Response | null | undefined | void
type VercelMiddleware = (request: Request, context?: unknown) => Result | Promise<Result>
type BotscentVercelOptions = { transport?: TransportMode; debug?: boolean | ((line: string) => void) }export { default } from 'botscent/vercel'Parameters
| Name | Type | Default | Description |
|---|---|---|---|
existing | VercelMiddleware | (none) | The project's own middleware, which withBotscent wraps. |
options.transport | 'auto' | 'always' | 'never' | 'auto' | Sends the entry with 'auto' and 'always', and never with 'never'. |
options.debug | boolean | ((line: string) => void) | (none) | Logs each decision, as in inspect. |
Notes
- The middleware does not pass on the verdict. It continues to the project.
- Routing Middleware runs before the response exists. It cannot remove an entry that the response itself carries.
- Vercel adds the middleware's
Server-Timingto the response's own.
botscent/workers
Runs the server half around a Cloudflare Worker's fetch handler or a Netlify Edge Function.
function withBotscent<Env = unknown>(
handler: (request: Request, env: Env, ctx: Context, verdict: Verdict) => Response | Promise<Response>,
options?: BotscentWorkersOptions,
): (request: Request, env: Env, ctx: Context) => Promise<Response>
type Context = { waitUntil(promise: Promise<unknown>): void; passThroughOnException?(): void }
type BotscentWorkersOptions = { transport?: TransportMode; debug?: boolean | ((line: string) => void) }import { withBotscent } from 'botscent/workers'
export default {
fetch: withBotscent(async (request, env, ctx, verdict) => fetch(request)),
}Parameters
| Name | Type | Default | Description |
|---|---|---|---|
handler | FetchWithVerdict<Env> | (required) | The Worker's handler, which gets the request's verdict as its fourth argument. |
options.transport | 'auto' | 'always' | 'never' | 'auto' | Sends the entry with 'auto' and 'always', and never with 'never'. |
options.debug | boolean | ((line: string) => void) | (none) | Logs each decision, as in inspect. |
Notes
- The adapter passes Cloudflare's
request.cftoinspect. - If the Worker stores HTML with the Cache API, set
transport: 'never'. - Behind Workers Cache, cached pages carry no entry.
- On Netlify,
withBotscent<Context>((request, context) => context.next())continues to the site, withContextfrom@netlify/edge-functions.
botscent/hono
Runs the server half as Hono middleware.
function botscent(options?: BotscentHonoOptions): MiddlewareHandler<{ Variables: { botscent: Verdict } }>
type BotscentHonoOptions = { transport?: TransportMode; debug?: boolean | ((line: string) => void) }import { Hono } from 'hono'
import { botscent } from 'botscent/hono'
const app = new Hono().use(botscent())
app.get('/verdict', (c) => c.json(c.get('botscent')))
export default appParameters
| Name | Type | Default | Description |
|---|---|---|---|
options.transport | 'auto' | 'always' | 'never' | 'auto' | Sends the entry on Cloudflare Workers only with 'auto', always with 'always', and never with 'never'. |
options.debug | boolean | ((line: string) => void) | (none) | Logs each decision, as in inspect. |
Notes
- The request's verdict is
c.get('botscent'). Chain.use(botscent())onnew Hono()to type it. - On Cloudflare Workers, the middleware passes
request.cftoinspect.
botscent/express
Runs the server half as Express or Connect middleware.
function botscent(options?: BotscentExpressOptions): (req: IncomingMessage, res: ServerResponse, next: (error?: unknown) => void) => void
type BotscentExpressOptions = { transport?: 'always' | 'never'; debug?: boolean | ((line: string) => void) }const express = require('express')
const { botscent } = require('botscent/express')
const app = express()
app.use(botscent())
app.get('/verdict', (req, res) => res.json(req.botscent))
app.listen(3000)Parameters
| Name | Type | Default | Description |
|---|---|---|---|
options.transport | 'always' | 'never' | 'never' | Sends the entry with 'always', and never with 'never'. |
options.debug | boolean | ((line: string) => void) | (none) | Logs each decision, as in inspect. |
Notes
- The request's verdict is
req.botscentin every handler after the middleware. - Express runs at the origin, which cannot see a CDN in front of it. The transport is therefore off by default.
botscent/astro
Adds both halves to an Astro site as one integration.
export default function botscent(options?: BotscentAstroOptions): AstroIntegration
type BotscentAstroOptions = { transport?: 'always' | 'never'; debug?: boolean }import { defineConfig } from 'astro/config'
import botscent from 'botscent/astro'
export default defineConfig({
integrations: [botscent()],
})Parameters
| Name | Type | Default | Description |
|---|---|---|---|
options.transport | 'always' | 'never' | 'never' | Sends the entry with 'always', and never with 'never'. |
options.debug | boolean | false | Logs each decision of the server half with console.debug. |
Notes
- The integration adds
botscent/autoto every page. - On routes rendered on demand, the request's verdict is
Astro.locals.botscent. - Prerendered pages have no request to inspect, so the server half does not run for them.