Pre-release. v0.1 is not out yet, so there is nothing to install and no public source to clone — the quickstart builds from a checkout.
Protect a tool server
A tool server is anything an agent calls to do real work. Its job is to refuse every request that does not carry a valid task token for the right audience with the right scope — and to record, on every request, which human it was acting for.
@onbe/server does all of it. The framework adapters are thin; the contract is the same
underneath.
The server
Section titled “The server”Lines 9–13 of the SDK’s examples/tool-server/jira.ts, which both framework examples below
share:
export const onbe = new OnbeToolServer({ issuer: process.env['ONBE_ISSUER'] ?? 'http://127.0.0.1:5100', audience: process.env['ONBE_AUDIENCE'] ?? 'https://jira.internal', requireActor: false,});audience is what this server is — the aud a token must carry to be accepted here. It has
to match an entry in the calling agent’s allowed_audiences, or the token would never have been
issued in the first place.
The example turns requireActor off so that each of its routes says for itself whether only
an agent may call it. Left alone, two defaults are worth knowing because they are deliberately
strict:
requireActordefaults to true: only agents acting for a human get in. A token with noactclaim is refused.maxDelegationDepthdefaults to 1: direct agents only. See delegation depth before raising it.
A server where some routes are for people and some are for agents sets requireActor: false
on the server and says so per route instead.
Per route
Section titled “Per route”Lines 10–41 of the SDK’s examples/tool-server/express-app.ts. onbe, issuesFor and port come
from the example’s jira.ts, which builds the OnbeToolServer with requireActor: false — so
every agent-only route says so itself.
import express from 'express';import { claimsOf, onbeExpress, type OnbeRequest } from '@onbe/server';import { issuesFor, onbe, port } from './jira.js';
const app = express();app.use(express.json());
// Only an agent acting for a human may search or comment; a person uses Jira itself.app.get( '/issues', onbeExpress(onbe, { scope: 'jira:read', requireActor: true }), (request, response) => { const claims = claimsOf(request as OnbeRequest); response.json(issuesFor(claims?.sub ?? 'nobody', String(request.query['q'] ?? ''))); },);
// High-risk: the control plane is asked on every call, so a revoked task cannot comment once more.app.post( '/issues/:id/comments', onbeExpress(onbe, { scope: 'jira:comment', highRisk: true, requireActor: true }), (request, response) => { const claims = claimsOf(request as OnbeRequest); response.status(201).json({ issue: request.params.id, by: claims?.act?.sub, for: claims?.sub }); },);
// A human's own token is enough here, because this route only tells them who they are.app.get('/whoami', onbeExpress(onbe, {}), (request, response) => { const claims = claimsOf(request as OnbeRequest); response.json({ sub: claims?.sub, act: claims?.act?.sub ?? null, scope: claims?.scopes });});Lines 6–38 of examples/tool-server/fastify-app.ts, against the same jira.ts.
import Fastify from 'fastify';import { onbeFastifyPlugin, type OnbeFastifyRequest } from '@onbe/server';import { issuesFor, onbe, port } from './jira.js';
const app = Fastify({ logger: false });await app.register(onbeFastifyPlugin(onbe, { unguarded: ['GET /healthz'] }));
app.get<{ Querystring: { q?: string } }>( '/issues', { config: { onbe: { scope: 'jira:read', requireActor: true } } }, async (request) => { const claims = (request as OnbeFastifyRequest).onbe; return issuesFor(claims?.sub ?? 'nobody', request.query.q ?? ''); },);
app.post<{ Params: { id: string } }>( '/issues/:id/comments', { config: { onbe: { scope: 'jira:comment', highRisk: true, requireActor: true } } }, async (request, reply) => { const claims = (request as OnbeFastifyRequest).onbe; return reply .code(201) .send({ issue: request.params.id, by: claims?.act?.sub, for: claims?.sub }); },);
app.get('/whoami', { config: { onbe: {} } }, async (request) => { const claims = (request as OnbeFastifyRequest).onbe; return { sub: claims?.sub, act: claims?.act?.sub ?? null, scope: claims?.scopes };});
app.get('/healthz', async () => ({ status: 'ok' }));The route policy
Section titled “The route policy”| Option | Meaning |
|---|---|
scope |
A scope, or several; every one listed must be present. Omitted means any verified token |
highRisk |
Also ask the control plane on every request whether the token is still active, instead of trusting the local check until it expires |
requireActor |
Only an agent acting for a human. Defaults to the server’s setting |
maxDelegationDepth |
Longest act chain this route accepts. Defaults to the server’s setting |
highRisk is the revocation lever. Local validation means a revoked token works until it
expires; introspection means it stops the moment the revocation is written. The cost is a round
trip per call. Revocation has the whole argument.
It is not the only way that lever gets pulled. A token whose audience the agent’s registration
lists in high_risk_audiences carries introspect_required, and @onbe/server introspects it
whether or not the route was marked — so an operator’s decision reaches this server without
anybody editing it. Mark a route highRisk when the route is riskier than its audience as a
whole.
What it does for you
Section titled “What it does for you”- Fetches and caches the control plane’s JWKS.
- Validates the signature,
iss,audandexp, with 60 seconds of clock skew. - Enforces the route’s scope.
- Introspects as well, when the route is marked
highRiskor the token carriesintrospect_required. - Logs
subandact.subon every request. - Refuses any token whose
actchain is deeper than the limit.
Step 5 is the one people skip when they write this themselves, and it is the entire point.
The log line
Section titled “The log line”Every request, allowed or refused, produces one event — by default a single JSON line on stdout:
{ "event": "request", "at": "2026-09-09T14:04:07.221Z", "route": "GET /issues", "decision": "allow", "sub": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "act": "agent:jira-triage", "depth": 1, "task_id": "task_01HQZX9K4M", "jti": "tok_01HQZX9K5P"}route never includes a query string. Pass log in the server’s options — a function that
takes the event — to send it wherever your logs go.
A refusal carries decision: "deny" and a reason. Keep them — a tool server’s denials are as
useful as the control plane’s, and for the same reason.
Refusals
Section titled “Refusals”A refused request gets the right status and a WWW-Authenticate header naming the realm, which
defaults to the audience. The claims never reach your handler, so there is no path where a
handler runs with a token that did not pass. A 503 — keys or introspection unreachable —
carries no WWW-Authenticate; it is this server’s problem, not the caller’s.
If you are not using a framework adapter, the two primitives are onbe.guard(authorization, policy, route) — which resolves to the claims or throws — and onbe.refusal(error), which
turns that throw into a status, headers and a body.
© 2026 Onbe