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.

@subactid/client

@subactid/client is the agent’s side of the control plane. It signs the assertion, performs the exchange, stores the task grant, and refreshes the token before it expires.

  • ESM only, Node 20 or later.
  • No dependencies outside the platform: WebCrypto and fetch.
  • Not on npm yet.

Build an agent walks through it.

One client per agent, any number of tasks.

examples/long-running/index.ts, lines 66–75
const client = new SubactIdClient({
issuer: required('SUBACTID_ISSUER'),
agentId: required('SUBACTID_AGENT_ID'),
kid: required('SUBACTID_AGENT_KID'),
privateKey: readFileSync(required('SUBACTID_AGENT_KEY_FILE'), 'utf8'),
onRefresh: (session, token) =>
console.log(`${stamp()} refreshed ${session.taskId}: next token lives ${token.expires_in}s`),
onRefreshError: (session, error) =>
console.error(`${stamp()} refresh of ${session.taskId} failed: ${String(error)}`),
});
Option Type Default What it is
issuer string — The control plane’s issuer URL. Must be an absolute http or https URL
agentId string — The registered agent id
kid string — Which of the agent’s keys this is
privateKey string | CryptoKey — A PKCS#8 PEM, or a key already imported for signing
algorithm 'RS256' | 'PS256' | 'ES256' 'RS256' The signing algorithm; the three the control plane accepts
instance string — Which copy of the agent this is. At most 128 characters; a blank one is dropped
lifetimeSeconds number 60 Assertion lifetime, from 1 to 300
grantStore TaskGrantStore in memory Where task grants live
fetch typeof fetch the global For tests, or an instrumented client
now () => number Date.now For tests
onRefresh (session, token) => void — Called after every successful refresh of any session
onRefreshError (session, error) => void — Called when a refresh fails, or the store fails to save or remove a grant
keepAlive boolean false Whether refresh timers keep the process alive
Method Returns What it does
discover() Promise<Discovery> Fetches the discovery document once. Refuses it unless it names this issuer and every endpoint is on the issuer’s origin
exchange(request) Promise<TaskSession> Starts a task: the user’s token in, a live session out
resume(taskId) Promise<TaskSession | undefined> Refreshes a stored task at once. See below
refresh(grant, resource, scope) Promise<TokenResponse> One refresh request, as the sessions make it
revoke(token, hint?) Promise<void> Revokes a task grant or a task token. hint is 'access_token' or 'refresh_token'

exchange takes { subjectToken, resource, scope }: the user’s access token, the audience of the task, and the scopes wanted, space-separated. The task gets the intersection of those scopes with what the user and the agent hold, which may be less.

resume(taskId):

  • returns undefined when the store has no such task, or the task has expired;
  • throws when the control plane says the task is over, after removing the grant from the store.

revoke resolves the same way whether or not anything was revoked. The control plane answers 200 to a token it does not know, or one issued to another agent.

keepAlive is off so that a finished script exits. Turn it on for a long-running service, and call stop() when the work is done.

One task. It hands out tokens with life left in them.

Member Type What it gives you
await accessToken() Promise<string> A token with life left in it; refreshes first if it needs to
await refresh(scope?) Promise<TokenResponse> Refreshes now, optionally narrowing the scope
await revoke() Promise<void> Revokes the task at the control plane, then ends the session
stop() void Stops refreshing. The task stays alive, and the grant stays in the store
toStored() StoredTaskGrant What a store needs to resume this task later
scope string What the task holds
resource string The audience of the task
expiresAt Date When the current token stops being usable: its own expiry, or the task’s end if sooner
taskExpiresAt Date When the task ends
taskId string The task id, the same across refreshes
isEnded boolean Whether the session is over: stopped, revoked, or the task ended

Call accessToken() at the point of use. Do not store its result.

refresh(scope) only narrows, and the narrowing lasts for the life of the session. A later refresh for a scope this session dropped throws SubactIdError without a request. This is the client’s rule, not the control plane’s: the grant keeps its full scope from the exchange, and the control plane would issue a token for it. To be sure a task never uses a scope, start the task without it.

revoke() waits for a refresh already under way, then revokes the grant. If the control plane refuses or cannot be reached, the session is left as it was and the error is thrown.

Constant Value Meaning
refreshFraction 0.6 A token is renewed at 60% of its life
minimumRemainingMs 5000 accessToken() refreshes first when the token has less than this left
  • A token that already lasts until the task’s end is not refreshed.
  • A task with less than five seconds left ends the session with TaskEndedError, without a request.
  • A retryable failure is retried after 1 second, doubling each time, capped at 60 seconds and never past the task’s end. When the control plane sent Retry-After, the wait is at least that long.
  • Any other failure ends the session: the grant leaves the store, and accessToken() throws the reason.
packages/client/src/store.ts, lines 18–22
export interface TaskGrantStore {
save(record: StoredTaskGrant): Promise<void>;
load(taskId: string): Promise<StoredTaskGrant | undefined>;
remove(taskId: string): Promise<void>;
}

A StoredTaskGrant is { taskId, grant, resource, scope, taskExpiresAt }.

MemoryTaskGrantStore is the default. It keeps grants in this process only. Implement the interface against durable storage, and a restarted agent can call resume(taskId) instead of asking the user again.

A stored grant is a credential. Keep it wherever you keep your other secrets.

Every failure is an SubactIdError. An error message never contains a token, a grant or a key.

The control plane’s OAuth errors are OAuthError subclasses. Each carries error, errorDescription and status.

Class Code
InvalidRequestError invalid_request
InvalidClientError invalid_client
InvalidGrantError invalid_grant
InvalidScopeError invalid_scope
InvalidTargetError invalid_target
AccessDeniedError access_denied
UnsupportedGrantTypeError unsupported_grant_type
UnsupportedTokenTypeError unsupported_token_type
TemporarilyUnavailableError temporarily_unavailable
SlowDownError slow_down
UnknownOAuthError anything else

TemporarilyUnavailableError and SlowDownError also carry retryAfterSeconds, from the response’s Retry-After, when it was sent.

Two more classes:

  • TransportError: the control plane could not be reached, or answered outside the contract. This includes a discovery document whose endpoints are not the issuer’s, and a token response that grants a scope that was not asked for. Such a response is never stored.
  • TaskEndedError: the session is over. It was stopped or revoked, or the task expired.

Two helpers:

Helper True for Meaning
isRetryable(error) TemporarilyUnavailableError, SlowDownError, TransportError, UnknownOAuthError The same request might succeed later
isTerminal(error) AccessDeniedError, InvalidGrantError, TaskEndedError The task is over, or will not start

An error in neither group, such as InvalidScopeError or InvalidTargetError, is a refusal of this request that a retry will not change. Errors explains each code.

  • AssertionSigner signs the agent’s assertion on its own, for a client that does the rest itself.
  • decodeJwtPayload reads a token’s claims without verifying anything. Use it for logging and debugging, never for a decision.
  • The types Discovery, ExchangeRequest, TokenResponse, StoredTaskGrant and TaskGrantStore.
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