Skip to content

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.

Lines 9–13 of the SDK’s examples/tool-server/jira.ts, which both framework examples below share:

examples/tool-server/jira.ts
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:

  • requireActor defaults to true: only agents acting for a human get in. A token with no act claim is refused.
  • maxDelegationDepth defaults 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.

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.

examples/tool-server/express-app.ts
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 });
});
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.

  1. Fetches and caches the control plane’s JWKS.
  2. Validates the signature, iss, aud and exp, with 60 seconds of clock skew.
  3. Enforces the route’s scope.
  4. Introspects as well, when the route is marked highRisk or the token carries introspect_required.
  5. Logs sub and act.sub on every request.
  6. Refuses any token whose act chain is deeper than the limit.

Step 5 is the one people skip when they write this themselves, and it is the entire point.

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.

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.

OnbePre-release. v0.1 is not out yet.

© 2026 Onbe