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

@onbe/client is the agent side. It signs the assertion, performs the exchange, stores the task grant, and refreshes before the token expires so your code never handles a 401 it could have avoided.

Its only runtime dependencies are the platform’s: WebCrypto and fetch. Node 20 or later.

agent.ts
import { readFileSync } from 'node:fs';
import { OnbeClient } from '@onbe/client';
const client = new OnbeClient({
issuer: 'https://onbe.internal.example.com',
agentId: 'jira-triage',
kid: 'agent-key-1',
privateKey: readFileSync('/etc/onbe/agent.key', 'utf8'),
});
const session = await client.exchange({
subjectToken: userAccessToken,
resource: 'https://jira.internal',
scope: 'jira:read jira:comment',
});
const response = await fetch('https://jira.internal/issues?q=login', {
headers: { authorization: `Bearer ${await session.accessToken()}` },
});

kid is the key identifier in the agent’s registered key set, and privateKey is the matching private key. @onbe/client signs with RS256 by default, and takes algorithm: 'PS256' or 'ES256' — the three the control plane accepts.

Pass instance as well when more than one copy of the agent runs at a time:

const client = new OnbeClient({
// …
instance: process.env.HOSTNAME ?? 'local',
});

It goes into the agent’s assertion and the control plane copies it into the task token’s act.instance, so a tool server’s log can tell one replica from another. It is the agent’s own claim about itself, checked against nothing, and it decides nothing. A blank instance — an unset variable read straight into the options — is dropped rather than asserted.

exchange returns a TaskSession, which is the task, not a token:

Member What it gives you
await session.accessToken() A token with life left in it. Refreshes if it needs to
session.scope What the task actually 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 that is sooner
session.taskExpiresAt When the whole task ends
session.taskId The task id, stable across refreshes
session.isEnded Whether the task is over
session.stop() Stop refreshing. Call it when the work is done

Always call accessToken() at the point of use. Do not read it once into a variable and hold it — that is the same mistake as holding a static key, in miniature.

The client renews at sixty per cent of each token’s life. There is nothing to schedule and no 401 to catch.

A refresh the control plane could not answer is retried with a growing wait, up to a minute apart, until the task ends. Any other refusal ends the session: the grant leaves the store and accessToken() throws the reason. onRefreshError sees each failure either way. If you want to see it happening:

const client = new OnbeClient({
issuer: 'https://onbe.internal.example.com',
agentId: 'jira-triage',
kid: 'agent-key-1',
privateKey,
onRefresh: (session, token) =>
console.log(`${session.taskId}: next token lives ${token.expires_in}s`),
onRefreshError: (session, error) => console.error(`${session.taskId}: ${String(error)}`),
});

By default the refresh timer does not keep the process alive. A long-running worker that should stay up for its tasks passes keepAlive: true.

A session can give back scope mid-task:

await session.refresh('jira:read');

From then on this session will not ask for jira:comment again; a refresh naming it throws OnbeError without a round trip. That is the client keeping its word, not the control plane enforcing it: the grant still holds jira:comment, and the control plane would answer a refresh for it. Asking for more than the grant ever held is invalid_scope from the control plane. To make less the rule rather than the habit, start the task with less.

Task grants live in memory by default. Give the client a store and a task outlives the process that started it:

import { OnbeClient, type TaskGrantStore } from '@onbe/client';
const client = new OnbeClient({ issuer, agentId, kid, privateKey, grantStore: myStore });
const session = await client.resume(taskId);
if (session) {
// still alive, scope and expiry intact
}

resume refreshes at once. It returns undefined when the store has no such task or the task has expired, and throws — after dropping the grant — when the control plane says the task is over. Whatever you implement TaskGrantStore against, the grant is a secret: it is the thing that produces tokens.

import { isRetryable, isTerminal, InvalidScopeError } from '@onbe/client';
try {
await client.exchange({ subjectToken, resource, scope });
} catch (error) {
if (error instanceof InvalidScopeError) {
// The intersection was empty. Asking again will not help.
} else if (isRetryable(error)) {
// temporarily_unavailable, or the network. Back off and try again.
} else if (isTerminal(error)) {
// The task is over, or the agent is not allowed to do this.
}
}

Every OAuth error has its own type — InvalidGrantError, InvalidClientError, InvalidScopeError, InvalidTargetError, AccessDeniedError, TemporarilyUnavailableError — so you can tell “ask differently” from “wait” from “stop”.

A retryable error is worth a backoff. A terminal one means the task is finished and the right move is to tell whoever started it, not to loop.

One access_denied needs its own handling: with the reason task_ending, it means the task is about to end — under five seconds left — and a token would expire before it could be spent. Stop; do not retry.

  • Do not log a token, a grant, or a subject token. Not at debug level, not in a crash handler. They are bearer credentials.
  • Do not hold accessToken()’s result. Call it each time.
  • Do not ask for more scope than the job needs on the theory that it saves a round trip later. It does not; refresh can narrow but never widen, so the wide scope is simply what the task carries for its whole life. The client will not even accept a wider scope than it asked for: a token response granting one is refused as TransportError and never stored.
  • Do not treat a 401 as the signal to refresh. By then a request that mattered has already failed.
OnbePre-release. v0.1 is not out yet.

© 2026 Onbe