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.

Build an agent

@subactid/client is the agent’s side. It signs the agent’s assertion, performs the exchange, stores the task grant, and refreshes the token before it expires. It needs only the platform: WebCrypto and fetch, on Node 20 or later.

@subactid/client lists every option and method.

The code on this page is from the SDK’s examples/agent/index.ts, which type-checks in its CI. env reads a required environment variable. start resumes a task or starts one, and FileGrantStore keeps task grants in files; both are covered below.

examples/agent/index.ts, lines 62–79
const client = new SubactIdClient({
issuer: env('SUBACTID_ISSUER'),
agentId: env('SUBACTID_AGENT_ID'),
kid: env('SUBACTID_AGENT_KID'),
privateKey: readFileSync(env('SUBACTID_AGENT_KEY_FILE'), 'utf8'),
instance: process.env['HOSTNAME'] ?? 'local',
grantStore: new FileGrantStore(process.env['GRANT_DIR'] ?? './grants'),
onRefresh: (session, token) =>
console.log(`${session.taskId}: next token lives ${token.expires_in}s`),
onRefreshError: (session, error) => console.error(`${session.taskId}: ${String(error)}`),
});
const session = await start();
const response = await fetch(`${jira}/issues?q=login`, {
headers: { authorization: `Bearer ${await session.accessToken()}` },
});
console.log(`search answered ${response.status}`);
  • kid names a key in the agent’s registered key set.
  • privateKey is the matching private key, as a PKCS#8 PEM or a CryptoKey.
  • The client signs with RS256 by default. It also takes algorithm: 'PS256' or 'ES256'. These are the three the control plane accepts.

When more than one copy of the agent runs at a time, pass instance. The example uses the host name:

examples/agent/index.ts, line 67
instance: process.env['HOSTNAME'] ?? 'local',

The control plane copies it into the token’s act.instance, so a tool server’s log can tell the copies apart. It is the agent’s own claim and is not checked against anything. A blank value is dropped. It can be at most 128 characters.

exchange returns a TaskSession. It stands for the task, not for one token:

Member What it gives you
await session.accessToken() A token with life left in it. Refreshes first if it needs to
session.scope What the task holds, which may be less than you asked for
session.expiresAt When the current token stops being usable: its own expiry, or the task’s end if sooner
session.taskExpiresAt When the task ends
session.taskId The task id, the same across refreshes
session.isEnded Whether the session is over
session.stop() Stop refreshing. The task stays alive at the control plane
await session.revoke() End the task at the control plane, then the session

Call accessToken() each time you need a token. Do not store its result.

The session refreshes each token at 60% of its life. You do not schedule anything.

  • If the control plane could not answer (or asked the client to slow down), the session retries with a doubling wait, up to a minute apart, until the task ends.
  • Any other refusal ends the session. The grant is removed from the store, and accessToken() throws the reason.
  • onRefresh and onRefreshError let you watch it happen:
examples/agent/index.ts, lines 69–71
onRefresh: (session, token) =>
console.log(`${session.taskId}: next token lives ${token.expires_in}s`),
onRefreshError: (session, error) => console.error(`${session.taskId}: ${String(error)}`),

The refresh timer does not keep the process alive by default. A long-running worker passes keepAlive: true.

A session can give up scope:

examples/agent/index.ts, lines 81–82
// Nothing here needs a comment. Give that scope up for the rest of the task.
await session.refresh('jira:read');

After this, the session refuses to ask for jira:comment again: a refresh naming it throws SubactIdError without a request. This is a rule of the client, not of the control plane. The grant still holds jira:comment. If the task must not have a scope, start the task without it.

Task grants live in memory by default. The example passes grantStore: new FileGrantStore(…), a TaskGrantStore that keeps each grant in an owner-only file. When SUBACTID_TASK_ID is set, it picks that task up again:

examples/agent/index.ts, lines 88–95
const taskId = process.env['SUBACTID_TASK_ID'];
if (taskId) {
const session = await client.resume(taskId);
if (session) {
return session; // still alive, scope and expiry intact
}
console.log(`task ${taskId} is gone; starting a new one`);
}

resume refreshes at once.

  • It returns undefined when the store has no such task, or the task has expired.
  • It throws when the control plane says the task is over, and removes the grant first.

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

examples/agent/index.ts, lines 97–112
try {
return await client.exchange({
subjectToken: env('SUBACTID_SUBJECT_TOKEN'),
resource: jira,
scope: 'jira:read jira:comment',
});
} catch (error) {
if (error instanceof InvalidScopeError) {
// The intersection was empty. Asking again will not help.
} else if (isRetryable(error)) {
// The control plane could not answer, asked to slow down, or could not be reached.
} else if (isTerminal(error)) {
// The task is over, or the agent is not allowed to do this.
}
throw error;
}

Every OAuth error code has its own class. @subactid/client lists them.

  • isRetryable(error) is true when the same request might succeed later: the control plane could not answer, asked you to slow down, or could not be reached. Back off, and wait at least retryAfterSeconds when the error carries it.
  • isTerminal(error) is true when the task is over, or will never start for this subject token. Tell whoever started the task. Do not retry.
  • AccessDeniedError from exchange means no task was created. The person may be blocked or disabled, or the agent disabled.
  • Do not log a token, a grant or a subject token. They are bearer credentials.
  • Do not hold on to accessToken()’s result. Call it each time.
  • Do not ask for more scope than the job needs. A task keeps its granted scope for its whole life. The client also refuses a token response that grants a scope it did not ask for: it throws TransportError and stores nothing.
  • Do not wait for a 401 to refresh. The session refreshes before the token expires.
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