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.
OnbeClient
Section titled “OnbeClient”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.
TaskSession
Section titled “TaskSession”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.
Storing grants
Section titled “Storing grants”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.
Errors
Section titled “Errors”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.
Odds and ends
Section titled “Odds and ends”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.
© 2026 Onbe