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. It must refuse every request without a valid task token for its audience and the right scope. On every request, it must also log the human the agent acted for.

@subactid/server does this. @subactid/server lists every option.

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

examples/tool-server/jira.ts
export const subactid = new SubactIdToolServer({
issuer: process.env['SUBACTID_ISSUER'] ?? 'http://127.0.0.1:5100',
audience: process.env['SUBACTID_AUDIENCE'] ?? 'https://jira.internal',
requireActor: false,
});

audience is what this server is: the aud a token must carry. It must be in the calling agent’s allowed_audiences, or the control plane never issues the token.

Two server defaults are strict on purpose:

  • requireActor is true: only an agent acting for a human gets in. A token with no act claim is refused.
  • maxDelegationDepth is 1: direct agents only. Read delegation depth before raising it.

The example sets requireActor: false on the server, so each route says whether it is for agents only. Do the same when some routes are for people and some for agents.

Lines 10–41 of the SDK’s examples/tool-server/express-app.ts. subactid, issuesFor and port come from the example’s jira.ts.

examples/tool-server/express-app.ts
import express from 'express';
import { claimsOf, subactIdExpress, type SubactIdRequest } from '@subactid/server';
import { issuesFor, subactid, 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',
subactIdExpress(subactid, { scope: 'jira:read', requireActor: true }),
(request, response) => {
const claims = claimsOf(request as SubactIdRequest);
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',
subactIdExpress(subactid, { scope: 'jira:comment', highRisk: true, requireActor: true }),
(request, response) => {
const claims = claimsOf(request as SubactIdRequest);
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', subactIdExpress(subactid, {}), (request, response) => {
const claims = claimsOf(request as SubactIdRequest);
response.json({ sub: claims?.sub, act: claims?.act?.sub ?? null, scope: claims?.scopes });
});
Option Meaning
scope A scope, or several. The token must carry every one. Omit it to accept any valid token
highRisk Also introspect at the control plane on every request
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

Without introspection, a revoked token is accepted until it expires. With it, the token is refused as soon as the revocation is written, at the cost of one request to the control plane per call. Revocation covers the trade-off.

A token carrying introspect_required is introspected whether or not the route sets highRisk. The control plane adds that claim when the audience is in the agent’s high_risk_audiences. Set highRisk on a route that is riskier than its audience as a whole.

Section 9 of the spec lists what a tool server must do. @subactid/server does each step:

  1. Fetches and caches the control plane’s keys.
  2. Validates the signature, iss, aud and exp, with 60 seconds of clock skew. It also checks that sub is not an agent.
  3. Enforces the route’s scope.
  4. Introspects when the route sets highRisk or the token carries introspect_required. It still validates the token locally first.
  5. Logs sub and act.sub on every request.
  6. Refuses a token whose act chain is deeper than the limit.

Every request, allowed or refused, produces one event. By default it is one 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 the query string.
  • A refusal has decision: "deny" and a reason.
  • To send events elsewhere, pass log, a function that takes the event, in the server’s options.

A refused request gets a 401, 403 or 503, and your handler never runs.

  • A 401 or 403 carries a WWW-Authenticate header. Its realm defaults to the audience.
  • A 503 means the keys or introspection could not be reached. It carries no WWW-Authenticate.

@subactid/server lists every reason.

Without a framework adapter, call subactid.guard(authorization, policy, route). It resolves to the claims or throws. subactid.refusal(error) turns what it throws into a status, headers and a body.

Subact ID Pre-release. v0.1 is not out yet.

© 2026 Nikola Živković PR Agencija za programerske usluge Novi Sad. Subact ID is its product.

LegalTermsPrivacy