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.
An agent
Section titled “An agent”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.
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}`);kidnames a key in the agent’s registered key set.privateKeyis the matching private key, as a PKCS#8 PEM or aCryptoKey.- The client signs with
RS256by default. It also takesalgorithm: '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:
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.
The session
Section titled “The session”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.
Refresh
Section titled “Refresh”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. onRefreshandonRefreshErrorlet you watch it happen:
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.
Narrowing mid-task
Section titled “Narrowing mid-task”A session can give up scope:
// 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.
Surviving a restart
Section titled “Surviving a restart”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:
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
undefinedwhen 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.
Errors
Section titled “Errors”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 leastretryAfterSecondswhen 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.AccessDeniedErrorfromexchangemeans no task was created. The person may be blocked or disabled, or the agent disabled.
What not to do
Section titled “What not to do”- 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
TransportErrorand stores nothing. - Do not wait for a
401to refresh. The session refreshes before the token expires.
© 2026 Nikola Živković PR Agencija za programerske usluge Novi Sad. Subact ID is its product.