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.

@onbe/client

The agent’s side of the control plane: it signs the assertion, performs the exchange, stores the task grant, and refreshes before the token expires. The package is @onbe/client: ESM only, Node 20 or later, and nothing outside the platform — WebCrypto and fetch. It is published from a release tag; it is not on npm yet.

Build an agent is the narrative version; this is the surface.

One client per agent, any number of tasks.

const client = new OnbeClient({
issuer: 'https://onbe.internal.example.com',
agentId: 'jira-triage',
kid: 'agent-key-1',
privateKey: readFileSync('/etc/onbe/agent.key', 'utf8'),
});
Option Type Default What it is
issuer string The control plane’s issuer 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 three the control plane accepts
instance string Which copy of the agent this is; at most 128 characters
lifetimeSeconds number 60 Assertion lifetime; at most the 300 the control plane accepts
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 After every successful refresh of any session
onRefreshError (session, error) => void When a refresh fails, or the grant 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> The discovery document, fetched once and checked: it must name this issuer, and every endpoint in it must be the issuer’s own, or a credential would be posted somewhere else
exchange(request) Promise<TaskSession> Starts a task: a user’s token in, a live session out
resume(taskId) Promise<TaskSession | undefined> Refreshes a stored task at once. undefined when the store has no such task or it has already expired; throws (and drops the grant) when the control plane says it is over
refresh(grant, resource, scope) Promise<TokenResponse> One refresh on the wire, as the sessions call it. scope must never be wider than the task holds

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

keepAlive is off because a refresh timer that keeps a process alive turns a finished script into a hung one. Turn it on for a long-lived service and call stop() when the work is done.

The task, not a token. This is the part that matters: a session hands out tokens that have life left in them, and the token it hands out is never one it is about to have to replace.

Member Type What it gives you
await accessToken() Promise<string> A token with life left in it; refreshes if it needs to
await refresh(scope?) Promise<TokenResponse> Refreshes now, optionally narrowing the scope
scope string What the task actually holds
expiresAt Date When the current token stops being usable: its own expiry, or the task’s end if that is sooner
taskExpiresAt Date When the whole task ends
taskId string Stable across refreshes
isEnded boolean Whether the task is over
stop() void Stop refreshing
toStored() StoredTaskGrant What a store needs to resume this task later

Always call accessToken() at the point of use. Reading it once into a variable and holding it is the same mistake as holding a static key, in miniature.

refresh(scope) only narrows, and the narrowing is one-way for the life of the session: a later refresh asking for a scope this session dropped throws OnbeError without going to the wire. That is this client’s rule, not the control plane’s. The control plane checks a refresh against the grant, which is fixed at the exchange, so it would hand the full granted scope back to a client that asked. To enforce less, start the task with less. Narrowing mid-task is the useful direction — finish the reads, give up the write scope.

Two exported constants say when a refresh happens, because a number you can read beats a number you have to infer: refreshFraction is 0.6, so a token is renewed at sixty per cent of its life, and minimumRemainingMs is 5000, under which accessToken() refreshes before handing anything out.

A refresh the control plane could not answer is retried with a doubling wait from one second, capped at a minute and never past the task’s end. Any other refusal ends the session: the grant leaves the store and accessToken() throws the reason.

interface TaskGrantStore {
save(record: StoredTaskGrant): Promise<void>;
load(taskId: string): Promise<StoredTaskGrant | undefined>;
remove(taskId: string): Promise<void>;
}

MemoryTaskGrantStore is the default and holds grants in this process only — fine for a task that does not outlive it. Implement the interface against something durable for a task that does, and a restarted agent can resume(taskId) instead of asking the human again.

A stored grant is a credential. It belongs wherever your other secrets are.

Every failure is an OnbeError. The OAuth ones carry the control plane’s code:

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

Each OAuthError carries error, errorDescription and status, and its message is the code and the description; never a token, grant or key.

Plus TransportError for a control plane that could not be reached, did not answer, or answered something outside the contract — including a discovery document whose endpoints are not the issuer’s, and a token response granting a scope that was not asked for. A widened scope is refused, never stored. And TaskEndedError for a session whose task is over.

Two helpers save you a switch. isTerminal(error) is true for the three that say the task is finished rather than unlucky: AccessDeniedError, InvalidGrantError and TaskEndedError. isRetryable(error) is true for the three that might come good — TemporarilyUnavailableError, TransportError and UnknownOAuthError — because only a control plane that could not answer, or could not be reached, is worth asking twice. Errors explains what each code means on the wire.

AssertionSigner is the assertion on its own, for a client that does everything else itself. decodeJwtPayload reads a token’s claims without verifying anything — for logging and debugging, never for a decision.

OnbePre-release. v0.1 is not out yet.

© 2026 Onbe