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.
SubactIdClient
Section titled “SubactIdClient”One client per agent, any number of tasks.
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
undefinedwhen 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.
TaskSession
Section titled “TaskSession”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.
When a refresh happens
Section titled “When a refresh happens”| 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.
Storing grants
Section titled “Storing grants”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.
Errors
Section titled “Errors”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.
Other exports
Section titled “Other exports”AssertionSignersigns the agent’s assertion on its own, for a client that does the rest itself.decodeJwtPayloadreads a token’s claims without verifying anything. Use it for logging and debugging, never for a decision.- The types
Discovery,ExchangeRequest,TokenResponse,StoredTaskGrantandTaskGrantStore.
© 2026 Nikola Živković PR Agencija za programerske usluge Novi Sad. Subact ID is its product.