Guards
Guards for a public /api/chat: functions that run before the model and refuse a request with the statuses the chat widget already explains (403, 429 rate_limited, 503).
Guards for a public /api/chat: functions that run before the model and refuse a request with the statuses the chat widget already explains (403, 429 rate_limited, 503). Reach for them when you assemble the handler yourself; createVercelChatRoute already composes them. Only POST is limited: GET is the capability check and OPTIONS a preflight.
Import
import {
budgetGuard,
clientIp,
composeGuards,
originGuard,
rateLimitGuard,
} from "@webmcp-agents/vercel-api";
import type { Guard } from "@webmcp-agents/vercel-api";
Example
How createVercelChatRoute wires them:
import { createChatHandler } from "@webmcp-agents/chat/server";
import {
budgetGuard,
composeGuards,
createLimiter,
originGuard,
rateLimitGuard,
} from "@webmcp-agents/vercel-api";
const env = process.env;
// How createVercelChatRoute wires them.
export const handler = createChatHandler({
env,
model: "anthropic/claude-sonnet-5-5",
// Before anything else: refuse a stranger or a burst.
guard: composeGuards(
originGuard(),
rateLimitGuard(
createLimiter({
env,
prefix: "shop:minute",
limit: 30,
window: "1 m",
failOpen: true,
}),
),
),
// After validation, so malformed requests never use the budget.
beforeModel: budgetGuard(
createLimiter({
env,
prefix: "shop:global",
limit: 3000,
window: "1 d",
failOpen: false,
}),
),
});
Options
A Guard is (req: Request) => Promise<Response | undefined> | Response | undefined: it returns a Response to refuse the request, or undefined to let it through.
| Name | Type | Default | Description |
|---|---|---|---|
composeGuards(...guards) |
Guard[] |
required | Runs the guards in order; the first refusal wins. |
originGuard() |
none | none | Takes no arguments. |
rateLimitGuard(...limiters) |
Limiter[] |
required | Each limiter is asked with the client IP as key. List a per-minute and a per-day limiter. |
budgetGuard(limiter) |
Limiter |
required | The limiter that counts model calls across all visitors; asked with the key "global". |
clientIp(req) |
Request |
required | The incoming request. |
Returns
| Name | Type | Description |
|---|---|---|
composeGuards |
Guard |
The first refusing guard’s response, else undefined. |
originGuard |
Guard |
Refuses with 403 { "error": "forbidden_origin" } when a POST has no Origin or one that differs from the request URL’s origin. |
rateLimitGuard |
Guard |
Refuses with 429 { "error": "rate_limited" } and a retry-after header in seconds (at least 1), when any limiter refuses. |
budgetGuard |
Guard |
Refuses with 503 { "error": "unavailable", "reason": "daily_budget" } past the cap, or "reason": "busy" when storage failed (degraded). |
clientIp |
string |
The first x-forwarded-for entry, else x-real-ip, else "anon" (one shared bucket). |
Refusals are JSON with cache-control: no-store.
Notes
originGuardis not real protection on its own: any script can send a fakeOrigin. It stops casual scripts; the rate limits and the budget are the protection. The chat handler already refuses a differentOrigin; this guard adds “no Origin, no chat”.- When several limiters refuse,
rateLimitGuardreports the longest wait, so a refused daily window reads as “tomorrow”, not “in a minute”. - Use
budgetGuardas the handler’sbeforeModelhook, notguard, so only validated requests that reach the model count. clientIpreadsx-forwarded-foras set by Vercel’s proxy. Requests with neither header share the"anon"bucket.
See also
- Deploy
createLimiter, the counters the guards usecreateVercelChatRoutecreateChatHandler, which takesguardandbeforeModel