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

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 is an RFC 8693 token exchange, sent 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

It 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.

The server checks, in this order:

  1. 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 a jti not seen before. Otherwise the answer is invalid_client. A disabled agent is access_denied.

  2. The subject token. It must be signed by the configured identity provider, unexpired, with the expected iss and aud. Otherwise the answer is invalid_grant.

  3. 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.

  4. The audience. resource must be in the agent’s allowed_audiences. Otherwise the answer is invalid_target.

  5. The scope. The effective scope is the intersection of three sets:

    effective = user_scopes ∩ agent.allowed_scopes ∩ requested_scopes

    An 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.

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 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.

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 resource is invalid_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.

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.

  • act.instance names which copy of the agent is acting, such as a pod name. The agent puts an instance claim 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: true appears when the audience is in the agent’s high_risk_audiences. The tool server must then introspect the token on every call instead of validating it locally. @subactid/server and @subactid/mcp do this without configuration. See Revocation.

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.

Subact ID Pre-release. v0.1 is not out yet.

© 2026 Nikola Živković PR Agencija za programerske usluge Novi Sad. Subact ID is its product.

LegalTermsPrivacy