@beronel/agentgate
v0.3.2
Published
Beronel AgentGate SDK · verify AI-agent passports and gate sensitive actions from your platform backend.
Maintainers
Readme
@beronel/agentgate
Platform-side SDK for the Beronel AI-agent identity gateway. Verify passports, evaluate proposed actions against an agent's owner-approved constraints, and pause sensitive operations for human approval — all in under 15 lines.
Install
npm install @beronel/agentgate
# or
pnpm add @beronel/agentgate
# or
yarn add @beronel/agentgatePublished from the @beronel npm org with npm publish --provenance, so
npm view @beronel/agentgate shows a verified link from each tarball back
to the GitHub commit and workflow that built it.
Inside the Beronel monorepo, depend on the workspace copy instead:
// package.json
"dependencies": {
"@beronel/agentgate": "workspace:*"
}Compatibility
Each SDK release declares the gateway protocol versions it understands. On
construction, BeronelGate does a one-shot async probe of
GET /api/v1/version and warns (never throws) if the gateway is outside the
supported range.
| @beronel/agentgate | Gateway protocol | Notes |
|----------------------|------------------|----------------------------------------|
| 0.1.x | 1 | Initial public release. |
| 0.2.x | 1 | Argon2id-hashed platform API keys, scope-aware auth, BeronelAuthError, @beronel/agentgate/next adapter. |
| 0.2.1 | 1 | Namespace helpers (commerce.checkout etc.) on BeronelGate, requirePassport({ detect }) predicate, scripts/stress-smoke.mjs. |
| 0.3.0 | 1 | Breaking · /v1/passports/issue and /v1/challenges/respond no longer return the legacy passport / expires_at aliases. Read passport_token and expires_in instead. 0.2.0 callers must upgrade to 0.2.1+ before the gateway rolls 0.3. |
Out-of-range behavior:
- Gateway older than the SDK supports: SDK logs a warning · upgrade the gateway (or pin to an older SDK).
- Gateway newer than the SDK understands: SDK logs a warning and falls
back to its highest-known protocol envelope · upgrade
@beronel/agentgate. - Gateway unreachable at construction: silent · ordinary
evaluate()outage handling takes over once requests start flowing.
The probe is fire-and-forget · it never blocks construction and never throws.
Versioning policy
@beronel/agentgate follows Semantic Versioning.
- Patch (
0.1.x): bugfixes and internal changes only. - Minor (
0.x.0): additive features. Pre-1.0, minors may carry breaking changes; we keep a deprecation window of two minor versions before removal and document it in the CHANGELOG. - Major (
x.0.0): breaking changes. Anything indist/index.d.tsafter0.1.0is part of the public API · removing or changing the shape of an exported symbol is a major bump. - Protocol vs SDK: the gateway protocol version (
1,2, …) and the SDK semver are independent. TheCompatibilitytable above is the canonical mapping. A gateway protocol bump is always accompanied by a new SDK minor that widens the supported range.
Pre-flight checklist
Before pointing a load harness at the gateway, walk this list once · every item is something a stress run will surface as a confusing 401 / 403 / 502 if it is not satisfied.
- Owner identity verified. Every demo agent's owner must show
identityStatus: verifiedin the dashboard. Otherwise both/v1/passports/issueand/v1/challenges/respondreturn 403BERONEL_OWNER_UNVERIFIEDwithowner_statusset to the actual state. install_tokenbaked intoberonel.config.json. The Skill'sget_passport.jsreads the token from disk · no env var. Re-download the Skill ZIP if you rotated.- Platform API key has the
gateway:verifyscope./v1/challenges,/v1/passports/verify, and/v1/evaluateall enforce it. Missing scope surfaces asBeronelAuthError(kind: "insufficient_scope", HTTP 403). - JWKS reachable.
GET /api/.well-known/jwks.jsonfrom the platform host. The SDK caches for 5 to 15 minutes; a one-time fetch failure during boot is fine, but a totally blocked egress will cascade into everyverifyPassportcall. - Rate-limit headroom. See the table below · pre-warm enough API keys (or raise the harness concurrency in lockstep) so steady-state traffic stays under the per-key burst.
- Smoke script green. Run
pnpm --filter @beronel/agentgate run smokeend-to-end against the target environment before turning the harness on. The script exits non-zero on the first contract drift it finds. CI also runs this script against staging on every push tomainthat toucheslib/agentgate/**and on a daily schedule (see.github/workflows/smoke.yml) · a greensmokeworkflow onmainis the canonical guarantee that the SDK ↔ gateway contract has not drifted since the last release.
Rate-limit budget
The gateway protects the four hot paths with per-API-key sliding-window limiters. The SDK does not retry through a 429 · plan harness concurrency against these numbers and add API keys (or shard) when you need more headroom.
| Endpoint | Per-key window | Per-key requests | Notes |
|--------------------------|----------------|-------------------|------------------------------------------------|
| POST /v1/evaluate | 60 s | 600 | Main hot path · one call per gated action. |
| POST /v1/passports/issue | 60 s | 60 | Skill-side; cached for the passport's 30 min. |
| POST /v1/challenges | 60 s | 120 | One per first-time-no-passport request. |
| POST /v1/challenges/respond | 60 s | 120 | Mirrors the challenge create rate. |
Stress runs that exceed these get HTTP 429 with the standard rate-limit
headers · the SDK surfaces the response as a BeronelApiError with
status: 429 and the harness should back off, not retry tight.
Known limitations in 0.2.1
- No SDK-side audit-event POST · the platform must send any custom audit events directly to the dashboard. Tracked for v0.3.
- No transport retry-once on
evaluate()· the SDK retries safe GETs but never re-issues aPOST /v1/evaluateafter a transient failure. Tracked for v0.3.
Quickstarts
Three flavors, same SDK. Pick the one that matches your stack and copy-paste.
1 · Express (Node)
import express from "express";
import { BeronelGate, BeronelAuthError } from "@beronel/agentgate";
import { requirePassport } from "@beronel/agentgate/express";
const beronel = new BeronelGate({
apiKey: process.env.BERONEL_API_KEY!,
apiBaseUrl: "https://api.beronel.com",
audience: "shop.example.com",
});
const app = express().use(express.json());
app.post("/checkout", requirePassport(beronel), async (req, res) => {
try {
const decision = await beronel.commerce.checkout(req, req.body.cart);
if (!decision.allowed) return res.status(decision.statusCode).json(decision);
res.json({ ok: true });
} catch (err) {
if (err instanceof BeronelAuthError) {
return res.status(503).json({ error: "platform_misconfigured" });
}
throw err;
}
});2 · Next.js App Router
// app/api/checkout/route.ts
import { BeronelGate } from "@beronel/agentgate";
import { requirePassportRoute } from "@beronel/agentgate/next";
const beronel = new BeronelGate({
apiKey: process.env.BERONEL_API_KEY!,
apiBaseUrl: "https://api.beronel.com",
audience: "shop.example.com",
});
export const POST = requirePassportRoute(beronel, async (req) => {
const { cart } = await req.json();
const decision = await beronel.commerce.checkout(req, cart);
if (!decision.allowed) {
return Response.json(decision, { status: decision.statusCode });
}
return Response.json({ ok: true });
});3 · Next.js Pages Router
// pages/api/checkout.ts
import { BeronelGate } from "@beronel/agentgate";
import { requirePassportPages } from "@beronel/agentgate/next";
const beronel = new BeronelGate({
apiKey: process.env.BERONEL_API_KEY!,
apiBaseUrl: "https://api.beronel.com",
audience: "shop.example.com",
});
export default requirePassportPages(beronel, async (req, res) => {
const decision = await beronel.commerce.checkout(req, req.body.cart);
if (!decision.allowed) return res.status(decision.statusCode).json(decision);
res.json({ ok: true });
});Auth errors
The SDK throws BeronelAuthError on 401 / 403 from the gateway:
kind: "invalid_api_key"(HTTP 401) · key is missing, malformed, revoked, or rotated past its grace window. Page the operator and rotate the key.kind: "insufficient_scope"(HTTP 403) · key authenticates but does not have the required scope. Mint a new key with the higher scope.
Rotate keys without downtime in the dashboard · the predecessor stays valid for a configurable grace window (default 7 days). See the Authentication docs for the full lifecycle.
Detect predicate · agent-traffic-only enforcement
By default requirePassport() only issues a challenge when the request
header is missing. If you also want to challenge known agent user-agents
or platform-specific headers (the spec's "Detection should be flexible"
rule), pass an optional detect predicate:
app.post(
"/checkout",
requirePassport(beronel, {
detect: (req) => {
const ua = String(req.headers["user-agent"] ?? "");
return /AgentRuntime|ClaudeAgent|MCP-Client/.test(ua);
},
}),
handler,
);The predicate runs only when the passport header is missing. Returning
false lets the request through unchallenged · returning true issues a
challenge as if the request were a default-mode call.
15-line Express integration
import express from "express";
import { BeronelGate } from "@beronel/agentgate";
import { requirePassport } from "@beronel/agentgate/express";
const beronel = new BeronelGate({
apiKey: process.env.BERONEL_API_KEY!,
apiBaseUrl: "https://api.beronel.com",
audience: "shop.example.com",
});
const app = express().use(express.json());
app.post("/checkout", requirePassport(beronel), async (req, res) => {
const decision = await beronel.commerce.checkout(req, req.body.cart);
if (!decision.allowed) return res.status(decision.statusCode).json(decision);
return res.json(await charge(req.body.cart));
});What the SDK does
- Extracts the passport from
X-Beronel-Passport(preferred) orAuthorization: Bearer <jwt>. - Verifies it locally against
/.well-known/jwks.json(cached 5–15 min) · checksiss,aud,exp,jti. - Calls
POST /v1/evaluatewith a Universal Context Event and your platform API key. - Returns a typed decision:
allow,block,pending_approval,insufficient_context,passport_required, orerror. - Applies risk-based fail behavior when Beronel is unreachable (high-risk fail closed by default, medium-risk block, low-risk fail-soft
error).
The SDK never auto-allows a high-risk action when it cannot reach Beronel.
Context helpers
Each helper converts a domain object into the universal context schema:
await beronel.commerce.checkout(req, { total: 1200, currency: "USD", items });
await beronel.media.createPlaylist(req, { name: "Workout Mix", visibility: "private", trackCount: 75 });
await beronel.resource.change(req, { operation: "delete", resourceType: "file", name: "brand.png", reversibility: "hard" });
await beronel.communication.send(req, { channel: "email", recipient: "[email protected]", containsAttachment: true, dataSensitivity: "medium" });For one-off shapes, call beronel.evaluate(req, contextEvent) directly.
Decision shape
type DecisionResult = {
decision: "allow" | "block" | "pending_approval" | "insufficient_context" | "passport_required" | "error";
allowed: boolean;
reason: string;
statusCode: number; // 200 / 202 / 401 / 403 / 422 / 502 / 503
matchedRule?: string | null;
grant?: { token: string; expiresAt: string } | null;
approvalId?: string | null;
challengeId?: string | null; // set when decision === "passport_required"
nonce?: string | null;
constraintsSummary?: string | null;
error?: string; // set when decision === "error"
};Recommended response pattern:
if (decision.decision === "allow") return proceed();
if (decision.decision === "pending_approval") return res.status(202).json(decision); // poll /v1/approvals/:id
if (decision.decision === "passport_required") return res.status(401).json(decision); // agent must respond to challenge
return res.status(decision.statusCode).json(decision); // block / errorDecision reasons
decision.reason is human-readable, but the gateway uses a small set of stable
prefixes you can switch on programmatically. Most reasons echo the matched
rule name (allowed_domains, blocked_categories,
spending.max_transaction_usd, spending.approval_required_over_usd, …).
Two prefixes are special and worth surfacing distinctly in your UI:
| Prefix | Decision | Meaning |
| ------------------- | ------------------ | ----------------------------------------------------------------------- |
| purpose_misaligned| pending_approval | Action falls outside the agent's declared category bucket (commerce, media, …). The owner sees a "Purpose misaligned" badge in the approvals UI. (Added in 0.3.1.) |
| high_risk_default | pending_approval | Action is high-risk (purchase, delete, publish, payment_change, irreversible impact, …) and matched no explicit allow rule. (Added in 0.3.1 · tightens the previous implicit-allow default.) |
if (decision.reason.startsWith("purpose_misaligned")) {
// Surface a "drift" warning to the owner alongside the approval prompt.
}The verified passport carries the agent's declared purpose and category
(claims.agent.purpose / claims.agent.category, both optional) so platforms
can render the same context in their own UI without an extra round-trip.
apiBaseUrl notes
apiBaseUrl must be the host root of your Beronel deployment, not a path under it. The SDK appends /api/v1/... and /api/.well-known/jwks.json itself.
// ✅ correct
apiBaseUrl: "https://api.beronel.com"
// ❌ wrong · the SDK will request `/api/api/v1/evaluate`
apiBaseUrl: "https://api.beronel.com/api"Trailing slashes are stripped automatically.
Approval polling
When evaluate() returns decision: "pending_approval" with an approvalId, the agent polls GET /api/v1/approvals/:id with its install token (bin_…) until the status becomes approved (with a grant token), denied, or expired. The platform's main responsibility is just to surface pending_approval and approvalId back to the caller (the agent), which sendDecision() does for you.
For agent-side use (or testing), the SDK exposes pollApproval():
const result = await beronel.pollApproval(approvalId, agentInstallToken);
// ^ { status: "pending" } | { status: "approved", grant } |
// { status: "denied", deniedReason? } | { status: "expired" }pollApproval() requires the agent's bin_… install token (not the platform API key) · the SDK will throw BeronelConfigError if you pass the wrong token type. The call retries once on transient failures (timeout, network outage, 5xx).
Spec compliance notes
Two implementation choices intentionally diverge from the strictest reading of the protocol spec · both are documented here so platforms understand the trade-offs:
failClosedForHighRiskis opt-out, not hard-coded. The protocol spec says "high-risk operations fail closed when Beronel is unreachable." This SDK ships that behavior as the default (failClosedForHighRisk: true) so out-of-the-box behavior matches the spec exactly. The flag is exposed because the task contract requires it as a constructor option, and rare deployments (e.g. degraded-mode read-only platforms) need a documented escape hatch. Setting it tofalseroutes high-risk through the samelowRiskFailSoftpath · use only with explicit security review.Local replay rejection is opt-in, not always-on. Beronel's server-side
/v1/evaluateand grant-redemption paths are the authoritative replay-defense layer · they are consistent across every instance of your platform and every passport-holding agent. The SDK's in-processJtiSeenCacheis provided for niche single-instance use cases (e.g. CLI gates) and is OFF by default because the same passport is intentionally re-used for manyevaluate()calls within one agent session. If the SDK rejected the second use locally, valid agent flows would break. The SDK still always rejects passports that lack ajticlaim entirely.
Replay protection policy
Beronel passports carry a unique jti (JWT ID) per issuance. The SDK enforces that jti is present on every passport (verified passports without jti are rejected). Beyond that, replay rejection is enforced server-side by Beronel · the /v1/evaluate and grant-redemption paths track jti usage authoritatively across all your platform's instances.
The SDK ships an in-process replay cache (src/verifier.ts) but does not enable rejection by default, because:
- The same passport is intentionally re-used for many
evaluate()calls during a single agent session (search, browse, then one purchase). Local rejection on first re-use would break that flow. - Multi-instance deployments would each maintain their own cache · server-side enforcement is the correct, consistent layer.
If you have a use case where you want a single-instance platform to reject passport re-use locally (e.g. a one-shot CLI gate), you can flip the rejectReplay flag on the verifier directly. For most platforms, leave it off and rely on the Beronel server's enforcement.
Configuration
new BeronelGate({
apiKey: string; // required · platform API key (Bearer)
apiBaseUrl: string; // required · e.g. "https://api.beronel.com" (host root, no /api suffix)
audience?: string; // default "default" · expected `aud` claim and challenge audience
failClosedForHighRisk?: boolean; // default true · high-risk blocks (503) on outage; false falls back to lowRiskFailSoft
lowRiskFailSoft?: boolean; // default true · low-risk returns "error"/502 on outage; false blocks (503)
timeoutMs?: number; // default 2500
jwksTtlMs?: number; // default 600_000 (clamped 5–15 min)
issuer?: string; // default "https://api.beronel.com" · override for self-hosted
fetch?: typeof fetch; // for tests
logger?: (event) => void; // optional diagnostics callback
});Outage behavior matrix
When Beronel is unreachable:
| Risk | Default behavior | Tunable via |
|--------|----------------------------------------|--------------------------------------------------|
| High | Block (503) | failClosedForHighRisk: false → uses low-risk path |
| Medium | Block (503) | Not configurable · always blocks per spec |
| Low | decision: "error" (502) | lowRiskFailSoft: false → block (503) instead |
The default config (failClosedForHighRisk: true, lowRiskFailSoft: true) matches the Beronel protocol spec recommendation.
Frontend snippet rule
There is no frontend SDK. Frontend-only enforcement is not secure — the user can bypass any browser check. Always run BeronelGate server-side on the request handler that touches the resource.
Express middleware reference
import { requirePassport, withPassport, sendDecision } from "@beronel/agentgate/express";
// 1. As middleware: rejects requests without a passport, returns BERONEL_PASSPORT_REQUIRED + challenge.
app.post("/checkout", requirePassport(beronel), handler);
// 2. As a wrapper: same guard plus your handler, with async error forwarding.
app.post("/playlist", withPassport(beronel, async (req, res) => {
const decision = await beronel.media.createPlaylist(req, req.body);
if (sendDecision(res, decision)) return;
res.json(await createPlaylist(req.body));
}));Errors
All SDK errors extend BeronelError:
BeronelConfigError· missing/invalid constructor config.BeronelPassportError· local JWT verification failed (signature, iss, aud, exp).BeronelTimeoutError· API call exceededtimeoutMs.BeronelApiError· API returned a non-2xx response (carries.status,.body).
BeronelGate.evaluate() never throws on transport failures — it returns a DecisionResult whose decision reflects the configured fail behavior. It only throws for programmer errors (e.g. calling with null event).
Testing
The SDK ships with a two-layer safety net.
# 1. Vitest unit suite · runs on every workspace `pnpm typecheck` / CI build.
# Covers risk classification, the four context helpers, error classes,
# JWKS cache (TTL clamp/refresh/invalidate), JWT verifier (signature,
# iss/aud/exp/jti, replay), header extraction, BeronelGate constructor
# validation, the full `evaluate()` API-decision mapping (allow / block /
# pending_approval / insufficient_context with grant + matchedRule +
# constraintsSummary), `evaluate()` outage matrix (high/medium/low risk
# against `failClosedForHighRisk` and `lowRiskFailSoft` flags, for both
# network unreachability and 5xx responses), `pollApproval`, and the
# Express middleware (`requirePassport`, `withPassport`, `sendDecision`,
# `createChallenge`).
pnpm --filter @beronel/agentgate run test
# Watch mode for local development.
pnpm --filter @beronel/agentgate run test:watch
# 2. Pack-and-install smoke test · always runs as part of the workspace
# typecheck gate. Builds the SDK, runs `pnpm pack`, installs the
# resulting tarball into a temp project with plain `npm install`, then
# discovers every runtime export in `@beronel/agentgate` and
# `@beronel/agentgate/express` (both ESM `import` and CJS `require`) and
# asserts each is non-undefined and that the documented required set
# (`BeronelGate`, `extractPassport`, error classes, the four Express
# helpers) is callable.
pnpm --filter @beronel/agentgate run test:pack
# Or from the workspace root:
pnpm test:packBoth layers are wired into the root pnpm typecheck (which chains
check:openapi-sync → typecheck:libs → per-artifact typechecks → test → test:pack),
so any drift · type, behavior, or packaging · breaks the same gate that
runs in pre-merge CI.
Reporting vulnerabilities
Please do not open a public GitHub issue for a suspected vulnerability.
Email [email protected] with reproduction steps and the affected SDK
version (npm ls @beronel/agentgate). See the repository
SECURITY.md for
the full policy, supply-chain controls, and SLA.
Out of scope (v1)
- Frontend snippet (UX-only, not security)
- Next.js adapter (use
evaluate()from a Route Handler · adapter coming later) - Python / Go / Java / .NET SDKs
- DPoP / proof-of-possession
