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.
The server
Section titled “The server”Lines 9–13 of the SDK’s examples/tool-server/jira.ts, shared by both framework examples below:
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:
requireActoristrue: only an agent acting for a human gets in. A token with noactclaim is refused.maxDelegationDepthis1: 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.
Per route
Section titled “Per route”Lines 10–41 of the SDK’s examples/tool-server/express-app.ts. subactid, issuesFor and port
come from the example’s jira.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 });});Lines 6–38 of examples/tool-server/fastify-app.ts, with the same jira.ts.
import Fastify from 'fastify';import { subactIdFastifyPlugin, type SubactIdFastifyRequest } from '@subactid/server';import { issuesFor, subactid, port } from './jira.js';
const app = Fastify({ logger: false });await app.register(subactIdFastifyPlugin(subactid, { unguarded: ['GET /healthz'] }));
app.get<{ Querystring: { q?: string } }>( '/issues', { config: { subactid: { scope: 'jira:read', requireActor: true } } }, async (request) => { const claims = (request as SubactIdFastifyRequest).subactid; return issuesFor(claims?.sub ?? 'nobody', request.query.q ?? ''); },);
app.post<{ Params: { id: string } }>( '/issues/:id/comments', { config: { subactid: { scope: 'jira:comment', highRisk: true, requireActor: true } } }, async (request, reply) => { const claims = (request as SubactIdFastifyRequest).subactid; return reply .code(201) .send({ issue: request.params.id, by: claims?.act?.sub, for: claims?.sub }); },);
app.get('/whoami', { config: { subactid: {} } }, async (request) => { const claims = (request as SubactIdFastifyRequest).subactid; 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. 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.
What it checks
Section titled “What it checks”Section 9 of the spec lists what a tool server must do. @subactid/server does each step:
- Fetches and caches the control plane’s keys.
- Validates the signature,
iss,audandexp, with 60 seconds of clock skew. It also checks thatsubis not an agent. - Enforces the route’s scope.
- Introspects when the route sets
highRiskor the token carriesintrospect_required. It still validates the token locally first. - Logs
subandact.subon every request. - Refuses a token whose
actchain is deeper than the limit.
The log line
Section titled “The log line”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"}routenever includes the query string.- A refusal has
decision: "deny"and areason. - To send events elsewhere, pass
log, a function that takes the event, in the server’s options.
Refusals
Section titled “Refusals”A refused request gets a 401, 403 or 503, and your handler never runs.
- A
401or403carries aWWW-Authenticateheader. Its realm defaults to the audience. - A
503means the keys or introspection could not be reached. It carries noWWW-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.
© 2026 Nikola Živković PR Agencija za programerske usluge Novi Sad. Subact ID is its product.