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.

Your first exchange

The exchange is the only endpoint that matters. Everything else in the control plane exists to make its answer trustworthy.

It is RFC 8693 token exchange, posted as a form:

POST /oauth2/token
Content-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&subject_token=<the user's access token>
&subject_token_type=urn:ietf:params:oauth:token-type:access_token
&actor_token=<the agent's private_key_jwt assertion>
&actor_token_type=urn:ietf:params:oauth:token-type:jwt
&requested_token_type=urn:ietf:params:oauth:token-type:access_token
&resource=https://jira.internal
&scope=jira:read jira:comment

Two identities go in. The subject is the person: their access token, validated against your identity provider’s JWKS, checking iss, aud, exp and the signature. The actor is the agent: a short-lived assertion signed with the agent’s own key, checked against the public keys in its registration — held by the control plane, or fetched from the JWKS URL it names.

resource is the audience the token is for, per RFC 8707. scope is what the agent is asking for — not what it will get.

In order:

  1. The agent is who it says. The assertion verifies against one of the agent’s registered keys, its jti has never been seen, and it is not more than five minutes old. Anything else is invalid_client.

  2. The subject token is real. From the configured provider, unexpired, correctly signed, with the expected audience. Anything else is invalid_grant.

    If the keys needed for either check cannot be fetched, the answer is temporarily_unavailable, never a yes.

  3. The audience is allowed. resource must appear in the agent’s allowed_audiences, or the answer is invalid_target.

  4. The scope narrows. The effective scope is the intersection of three sets:

    effective = user_scopes ∩ agent.allowed_scopes ∩ requested_scopes

    An empty intersection is invalid_scope. It is never a token with nothing in it, because a token with no scope is a token whose refusal was postponed.

Whatever it decides, allow or deny, is appended to the audit ledger before the answer goes out.

200 OK
{
"access_token": "eyJhbGciOi…",
"issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
"token_type": "Bearer",
"expires_in": 300,
"scope": "jira:read jira:comment",
"refresh_token": "task_grant_8f2c…",
"task_id": "task_01HQZX9K4M",
"task_expires_at": "2026-09-09T14:32:00Z"
}
Field What it is
access_token The task token. Send it as a bearer token to the audience you named
expires_in Seconds. Never longer than what is left of the task
scope What you actually got, which may be less than you asked for
refresh_token Not an ordinary refresh token. See below
task_id The task this token belongs to; it stays the same across refreshes
task_expires_at When the whole task ends, and with it every token under it

refresh_token is a task grant. It looks like an OAuth refresh token and it is used at the same endpoint, but it is bound to this task and this agent:

  • It cannot widen scope. A refresh asking for more than the task was granted is invalid_scope, even if the user and the agent would both allow it.
  • It cannot change audience. A refresh naming a different resource is invalid_target.
  • It dies when the task expires or is revoked, whichever comes first.
  • Presented by any other agent, it does not exist.

That is what makes a long-running task safe: the agent holds something that can only ever produce the same small token again, for as long as the task is alive and no longer.

Decode access_token and you get:

{
"iss": "https://onbe.internal.example.com",
"sub": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"aud": "https://jira.internal",
"exp": 1757426520,
"iat": 1757426220,
"jti": "tok_01HQZX9K5P",
"scope": "jira:read jira:comment",
"client_id": "agent:jira-triage",
"act": {
"sub": "agent:jira-triage",
"depth": 1
},
"task": {
"id": "task_01HQZX9K4M",
"exp": 1757428020,
"sponsor": "f47ac10b-58cc-4372-a567-0e02b2c3d479"
}
}

sub is the human. Not the agent, not a service account, not a synthetic identity that stands in for the agent — the same subject identifier your identity provider issued for the person who asked. The agent is in act, and a tool server that sees no act claim knows a human called it directly.

task carries the task’s own id, its expiry, and the sponsor — the human the whole task is being run for. When you later ask the audit ledger what an agent did on behalf of a person, the sponsor is the field you filter on.

act.instance names which copy of the agent is acting — a pod name, a container id. The agent puts it in its own assertion and the control plane copies it through, so it is signed by the agent and attributed to it, and checked against nothing. It decides nothing; it tells you which of forty replicas did this. The token above has none because that agent asserted none.

introspect_required: true appears when the audience is one the agent’s registration lists in high_risk_audiences. It tells the tool server to ask the control plane about this token on every call instead of validating it locally, and both server SDKs honour it without being configured to — see Revocation for what that buys you.

Send it as Authorization: Bearer <access_token> to the audience in aud. On the other side, a tool server validates it — see Protect a tool server — and logs sub and act.sub on every request.

Do not wait for a 401 to refresh. Renew at about sixty per cent of expires_in; the SDKs do this for you, and hand-rolling it is the most common source of mid-task failures.

Delegation, not impersonation is why the claim set is shaped the way it is.

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

© 2026 Onbe