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.
The request
Section titled “The request”It is RFC 8693 token exchange, posted as a form:
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:commentTwo 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.
What the server decides
Section titled “What the server decides”In order:
-
The agent is who it says. The assertion verifies against one of the agent’s registered keys, its
jtihas never been seen, and it is not more than five minutes old. Anything else isinvalid_client. -
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. -
The audience is allowed.
resourcemust appear in the agent’sallowed_audiences, or the answer isinvalid_target. -
The scope narrows. The effective scope is the intersection of three sets:
effective = user_scopes ∩ agent.allowed_scopes ∩ requested_scopesAn 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.
The response
Section titled “The response”{ "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 |
The field that surprises people
Section titled “The field that surprises people”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
resourceisinvalid_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.
The token itself
Section titled “The token itself”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.
Two claims that are there only sometimes
Section titled “Two claims that are there only sometimes”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.
What to do with it
Section titled “What to do with it”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.
© 2026 Onbe