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
An agent gets a task token by exchanging a user’s access token at POST /oauth2/token. This
page walks through the request, the checks, the response and the token.
The request
Section titled “The request”The request is an RFC 8693 token exchange, sent 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:commentIt carries two identities:
- The subject is the person: their access token from your identity provider.
- The actor is the agent: a short-lived assertion signed with the agent’s own key.
resource is the audience the token is for (RFC 8707). scope is what the agent asks for; it
may get less.
What the server checks
Section titled “What the server checks”The server checks, in this order:
-
The agent. The assertion must verify against one of the agent’s registered keys, name the control plane as
aud, expire at most five minutes ahead, and carry ajtinot seen before. Otherwise the answer isinvalid_client. A disabled agent isaccess_denied. -
The subject token. It must be signed by the configured identity provider, unexpired, with the expected
issandaud. Otherwise the answer isinvalid_grant. -
The person. A person who is blocked, or disabled or deleted at the identity provider, is
access_denied. See connect your identity provider for how Subact ID learns this. -
The audience.
resourcemust be in the agent’sallowed_audiences. Otherwise the answer isinvalid_target. -
The scope. The effective scope is the intersection of three sets:
effective = user_scopes ∩ agent.allowed_scopes ∩ requested_scopesAn empty intersection is
invalid_scope, never a token with no scope.
If the keys or status needed for a check cannot be fetched, the answer is
temporarily_unavailable, never a token.
Every decision, allow or deny, is written to the audit ledger. A token is issued only together with its audit record.
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 until the token expires. Never more than what is left of the task. |
scope |
The scope granted, which may be less than you asked for. |
refresh_token |
A task grant, 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 task ends, and with it every token issued under it. |
The task grant
Section titled “The task grant”refresh_token is a task grant. It is used at the same endpoint as an OAuth refresh token,
but it is bound to this task and this agent:
- A refresh cannot widen scope. Asking for more than the task holds is
invalid_scope, even if the user and the agent would both allow it. - A refresh cannot change audience. A different
resourceisinvalid_target. - The grant stops working when the task expires or is revoked.
- Another agent presenting it gets the same answer as for a grant that does not exist.
So a long-running task can only ever renew the same token, and only while the task lasts.
The token itself
Section titled “The token itself”Decoded, access_token holds these claims:
{ "iss": "https://subactid.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 person: the subject identifier your identity provider issued. The agent is in
act, never in sub. A tool server that sees no act claim knows a person called it directly.
client_id and act.sub name the agent. In v0.1, act.depth is always 1.
task holds the task’s id, its expiry and its sponsor: the person the task runs for. To ask the
audit ledger what agents did for a person, filter on the sponsor.
Optional claims
Section titled “Optional claims”act.instancenames which copy of the agent is acting, such as a pod name. The agent puts aninstanceclaim in its assertion, at most 128 characters, and the control plane copies it. Nothing checks it. The token above has none because the agent sent none.introspect_required: trueappears when the audience is in the agent’shigh_risk_audiences. The tool server must then introspect the token on every call instead of validating it locally.@subactid/serverand@subactid/mcpdo this without configuration. See Revocation.
What to do with it
Section titled “What to do with it”Send it as Authorization: Bearer <access_token> to the audience in aud. The tool server
validates it and logs sub and act.sub on every request. See
Protect a tool server.
Renew the token at about 60% of expires_in; do not wait for a 401. @subactid/client does this
for you.
Delegation, not impersonation explains why the claims are shaped this way.
© 2026 Nikola Živković PR Agencija za programerske usluge Novi Sad. Subact ID is its product.