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.

The v0.1 API surface

Working spec. Everything here is standards-based: RFC 8693 (token exchange), RFC 7662 (introspection), RFC 7009 (revocation), RFC 8707 (resource indicators).

Core invariant, which every rule below serves:

A task token can never carry more authority than the human who invoked it, and every action traces back to that human.


The control plane is its own OIDC issuer.

GET /.well-known/openid-configuration
{
"issuer": "https://onbe.internal.example.com",
"token_endpoint": "https://onbe.internal.example.com/oauth2/token",
"introspection_endpoint": "https://onbe.internal.example.com/oauth2/introspect",
"revocation_endpoint": "https://onbe.internal.example.com/oauth2/revoke",
"jwks_uri": "https://onbe.internal.example.com/.well-known/jwks.json",
"grant_types_supported": [
"urn:ietf:params:oauth:grant-type:token-exchange",
"refresh_token"
],
"token_endpoint_auth_methods_supported": ["private_key_jwt"]
}

No client_secret_post. Agents authenticate with private_key_jwt. Static shared secrets are the thing this project exists to kill.

mTLS (tls_client_auth) is intended and is not in v0.1; it is not advertised until there is code behind it, because a client that reads this document to choose an authentication method must not be offered one that does not work.


Admin API. In a GitOps setup this is driven by YAML in a repo; the CRD in v0.4 produces the same objects.

POST /admin/agents
{
"agent_id": "jira-triage",
"display_name": "Jira triage agent",
"sponsor_required": true,
"allowed_scopes": ["jira:read", "jira:comment", "confluence:read"],
"allowed_audiences": ["https://jira.internal", "https://confluence.internal"],
"max_task_ttl": "PT30M",
"max_token_ttl": "PT5M",
"max_delegation_depth": 2,
"high_risk_audiences": ["https://db.internal"],
"jwks_uri": "https://onbe.internal.example.com/agents/jira-triage/jwks.json"
}

Field notes:

  • sponsor_required means no token is ever issued without a human subject token. In v0.1 it must be true, and a registration with false is rejected: every exchange requires a subject token, so false would describe a grant the server does not have. Scheduled agents with an explicit service sponsor recorded in the audit trail — never an anonymous one — come later.
  • max_task_ttl is the lifetime of the whole task. max_token_ttl is the lifetime of each individual token, and no token ever outlives the task it belongs to. This split is what makes long-running tasks safe: a six-hour job never holds a six-hour credential. Both are optional; a registration that names neither is given Onbe:Tokens:DefaultTaskTtl and Onbe:Tokens:DefaultTokenTtl, and one that names only a task lifetime shorter than the default token lifetime has its token cut to the task rather than refused. A registration that names them is not narrowed again by those settings — they are defaults, not a second ceiling. Both are checked against the server-wide bounds Onbe:Agents:MinTaskTtl, MaxTaskTtl, MinTokenTtl and MaxTokenTtl, which is where an operator holds every agent to something shorter.
  • max_delegation_depth is the longest act chain a token issued to this agent may carry. In v0.1 the control plane issues depth: 1 only (section 4), so any value from 1 to 5 is accepted and none of them changes what is issued yet. It is registered now so a registration does not have to be rewritten when sub-agent delegation lands.
  • high_risk_audiences names the audiences whose tokens must be checked by introspection on every call rather than validated locally until they expire. Slower, instantly revocable. A token minted for one of them carries introspect_required: true (section 4), which is how the decision reaches the tool server that has to act on it: the operator states it once, here, rather than stating it again in every tool server’s configuration and keeping the two in step by hand.
  • jwks_uri names where the agent publishes the public keys it authenticates with. A registration may instead carry them inline as jwks, an RFC 7517 key set with public members only; the control plane then serves them at GET /agents/{agent_id}/jwks.json and fetches nothing. Exactly one of the two is set. A key carrying any private member is refused per field.

The main endpoint. Standard RFC 8693.

POST /oauth2/token
Content-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&subject_token=<user access token from Keycloak>
&subject_token_type=urn:ietf:params:oauth:token-type:access_token
&actor_token=<agent 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

Effective scope is the intersection of three sets:

effective = user_scopes ∩ agent.allowed_scopes ∩ requested_scopes

Empty intersection is invalid_scope, not an empty token. The audience must appear in agent.allowed_audiences or the answer is invalid_target.

The subject token is validated against the upstream IdP’s JWKS (Keycloak in v0.1), checking iss, exp, aud and signature.

{
"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"
}

refresh_token here is a task grant: it is bound to task_id, cannot widen scope, and dies when the task expires or is revoked.


{
"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",
"instance": "pod-7f9c4b",
"depth": 1
},
"task": {
"id": "task_01HQZX9K4M",
"exp": 1757428020,
"sponsor": "f47ac10b-58cc-4372-a567-0e02b2c3d479"
}
}

The important detail: sub remains the human. This is delegation, not impersonation. The agent appears in act, never in sub. A tool server that sees no act claim knows a human called it directly.

act.instance says which copy of the agent is acting. It is the agent’s own claim about itself, taken from an instance claim in its client assertion and copied through, so it is signed by the agent and attributed to it but never checked against anything. It is written only when the agent asserted a non-blank one, and is at most 128 characters; a longer one is invalid_client.

introspect_required is written, as true, only when the audience is one the agent’s registration lists in high_risk_audiences. A tool server that sees it must introspect on every call instead of trusting the local check until the token expires. It is absent otherwise, so an ordinary token is exactly what it always was.

The control plane issues depth: 1 only. An exchange validates its subject_token against the upstream identity provider, so a task token presented as one is invalid_grant. max_delegation_depth is checked on every exchange, against a depth that is always 1.

What follows is the shape a sub-agent exchange takes when it lands, and it is already the shape a tool server must be able to read — a chain can reach one from anywhere, and @onbe/server and @onbe/mcp enforce their own limit on it. Nothing should be built against the control plane producing one yet.

A sub-agent exchange takes the parent’s task token as subject_token. The new actor becomes the outermost act, the previous actor nests inside it, per RFC 8693 §4.1:

"act": {
"sub": "agent:db-reader",
"instance": "pod-3a1f88",
"depth": 2,
"act": { "sub": "agent:jira-triage", "instance": "pod-7f9c4b", "depth": 1 }
}

Two hard rules: scope can only narrow on each hop, and depth may not exceed max_delegation_depth. Both are server-enforced, never client-asserted.


POST /oauth2/token
grant_type=refresh_token
&refresh_token=task_grant_8f2c...
&resource=https://jira.internal
&scope=jira:read
&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
&client_assertion=<agent private_key_jwt assertion>

The grant is bound to the agent it was issued to, so a refresh authenticates the agent the same way an exchange does (RFC 7523 section 2.2). A grant presented by any other agent does not exist.

Server checks, in order: grant exists, task not revoked, task.exp not passed, task.exp far enough off to be worth a token, the sponsor still active at the upstream IdP, requested scope ⊆ previously granted scope, then the policy check again against the agent’s current registration. Then issues a fresh token with the same task_id and a new jti.

A task with less than five seconds left is access_denied with the reason task_ending, rather than a token that expires before its holder can spend it. That is not expiry: the task has not ended, and a client that sees it should stop rather than retry.

The sponsor check asks the IdP’s admin API and reuses the answer for no longer than the lifetime of the token being issued, so a user disabled or deleted at the IdP has every task fail its next refresh within one expires_in. A disabled or deleted user is access_denied; an IdP that cannot answer is temporarily_unavailable, never treated as active.

Clients should renew proactively at ~60% of expires_in rather than on 401. The SDKs do this automatically; it is the single most common source of mid-task failures when hand-rolled.


POST /oauth2/revoke # RFC 7009, revokes one token or a task grant
DELETE /admin/tasks/{id} # kills the task and every token under it
DELETE /admin/agents/{id}/tasks # kills every live task for an agent

POST /oauth2/revoke takes the RFC 7009 token and optional token_type_hint, plus the agent’s client_assertion as in section 5. A task grant revokes its task and everything delegated from it; a task token is revoked by jti. Only the agent a token was issued to can revoke it; anything else, including a token that does not exist, is answered with the same empty 200, so the endpoint cannot be used to probe tokens. Revoking twice is one revocation.

Revocation is eventual for locally-validated tokens, bounded by max_token_ttl. For audiences listed in high_risk_audiences, tool servers call introspection per request and revocation is immediate. The token says so itself: one minted for such an audience carries introspect_required: true (section 4), and a tool server that honours it needs no configuration of its own to match. Document this tradeoff plainly — operators need to choose it knowingly.

POST /oauth2/introspect # RFC 7662
{
"active": false,
"revoked_at": "2026-09-09T14:05:11Z",
"revocation_reason": "operator_kill_switch"
}

Introspection is answered from storage, so a revocation shows the moment it is written. A token is active only if it verifies, has not expired, has not been revoked by jti, its task is still active and unexpired, and its agent is still enabled. An active token returns its claims, sub, act and scope among them, plus task_id. Anything else is active: false, with revoked_at and revocation_reason only when the token or its task was revoked, revocation_reason: "agent_disabled" when its agent was disabled, and nothing else, so nothing can be learned from a token that is not a live token of this control plane. An expired token or task is plain active: false; expiry is not a revocation. The token_type_hint is ignored. Holding the token is the only credential the endpoint asks for in v0.1: a task token is unforgeable and readable by its bearer, so introspection reveals nothing the bearer does not already have except the revocation state.


Append-only, and hash-chained. The ledger is a fixed number of chains rather than one: each record names the chain it belongs to, links to the record before it in that chain, and each chain is independently ordered and independently verifiable. A reader following links groups by chain first. chain is absent on a record written before a ledger was sharded; those are one chain of their own and verify exactly as they did.

{
"seq": 10428,
"chain": 6,
"ts": "2026-09-09T14:03:41.882Z",
"event": "token.issued",
"task_id": "task_01HQZX9K4M",
"agent_id": "jira-triage",
"sponsor": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"audience": "https://jira.internal",
"scope": "jira:read jira:comment",
"jti": "tok_01HQZX9K5P",
"delegation_depth": 1,
"decision": "allow",
"reason": null,
"prev_hash": "9c1f...a20b",
"hash": "4e77...c913"
}

hash = sha256(canonical_json(record minus hash) || prev_hash).

chain is not part of the canonical JSON and so not part of the hash, for the same reason count is absent from it: a record hashes exactly as it did before chains existed, so a ledger written by an earlier version still verifies. Moving a record from one chain to another is still caught, because it breaks the links either side of it in both.

A record is placed on a chain by the human it names, so everything recorded against one person is one chain and can be shown complete. A record naming no human is placed by the agent, and one naming neither is spread, so that a flood of anonymous denials does not land on a single chain. The number of chains is Onbe:Audit:Chains, sixteen by default and one at the least, which is a single chain.

A caller that presents no credential can be denied as fast as it can ask, and every denial is a record in a table that refuses DELETE. So a denial that names nobody — no agent_id, no sponsor, no task_id, no jti — is recorded once per reason per window, and the rest of that window is counted:

  • the first occurrence of a reason is written as it happens, a record like any other, so the ledger answers within one request of the event;
  • every further occurrence in the window is counted, not written;
  • at the window’s close one summary record is written carrying count, the number of further occurrences. A reason that happened once produces no summary.

A denial that can be attributed is never summarised, at any volume: those are the records the ledger exists for and they stay one per event.

count is present only on a summary record and is absent everywhere else; a reader treats an absent count as one. It is absent from the canonical JSON too, not written as "count":null, which is the one exception to writing nulls explicitly: a record without a count hashes exactly as it did before summaries existed, so a ledger written by an earlier version still verifies.

The window is Onbe:Audit:Aggregation:Window, one minute by default, and Onbe:Audit:Aggregation:Enabled turns summarising off. Counts are held in memory until the window closes, so they are approximate at the edges and a crash loses at most one window of them; what was written through is unaffected. Each instance counts its own, so n instances write up to n summaries per reason per window.

Appends are serialised with a transaction-scoped advisory lock per chain, so any number of instances write one coherent chain and writers on different chains do not queue behind each other; the table refuses UPDATE, DELETE and TRUNCATE by trigger. Onbe.Server audit-verify [seq:hash,...] walks every chain from its first record and recomputes every link, reporting the sequence number and kind of the first fault any chain found: a broken link (a record missing, inserted or replaced) or a record that no longer hashes to its stored hash. Sequence numbers may have holes, since a rolled-back append consumes one without touching the chain. The walk alone cannot tell a cut tail from a short ledger, so each run prints the head of every chain as a comma-separated seq:hash list; pass the list from an earlier run and the command also confirms each chain still reaches its head unchanged, keeping that list somewhere the database cannot reach. It exits 0 when every chain is intact, 3 when one is not, 1 when the ledger could not be read, and 2 for a malformed head argument.

Events: token.issued, token.denied, token.refreshed, token.revoked, task.created, task.revoked, task.expired, agent.registered, agent.updated, agent.deleted and admin.denied.

tool.called is reserved and not produced in v0.1: there is no endpoint to report a call to and no SDK sends one. A tool server records its own calls in its own log.

task.expired is written once per task by the expiry sweeper when it marks the task terminal and revokes its grants; expiry is not a revocation and is never written as one. task.revoked is written once per task in a revoked tree, with parent_revoked as the reason on descendants. token.revoked is written when a single token is revoked by jti through POST /oauth2/revoke.

The ledger is the record; a sink is a copy. When Onbe:Audit:Sink:Url is set, every append also queues the record in audit_outbox in the same transaction, and a drain on each instance posts queued records to that URL as POST with body {"records": [...]}, each record in the shape above with the hashes as lowercase hex, Onbe:Audit:DrainBatchSize (default 100) records per request in sequence order, every Onbe:Audit:DrainInterval (default 5 seconds). Onbe:Audit:Sink:BearerToken, when set, is sent as a bearer token, and then the URL must be https. Any answer other than 2xx, or no answer within 10 seconds, is a failed delivery: each record in the batch is marked with the error and tried again after a wait that doubles with that record’s failures, from one second to a cap of five minutes. Nothing about the sink is on the path of a token request: the endpoint only queues, and a slow, failing or absent sink changes nothing about what the endpoint answers. Entries are claimed with a skip-locked read for the length of the drain’s transaction, so any number of instances drain the one outbox and no record is delivered twice by design; a record is delivered at least once, since a crash between the sink accepting and the mark committing repeats the delivery. Requests from several instances, or a retried batch behind a newer one, may arrive out of order, so a sink treats seq as the identity and orders by it. Without a sink URL nothing is queued.

The query that sells the product:

GET /audit?sponsor=f47ac10b-...&from=2026-09-01&to=2026-09-30
Authorization: Bearer <Onbe:Admin:ApiKey>

“Every action any agent took on behalf of this person last month.”

Filters, all optional, combined with AND: sponsor, agent_id, task_id, from (inclusive), to (exclusive) and decision (allow or deny). A time is an ISO 8601 timestamp or a bare date meaning midnight UTC; a bare date in to covers that whole day, so the example above is all of September. Records come back oldest first, limit (default 100, at most 1000) per page:

{
"records": [ { "seq": 10428, "ts": "2026-09-09T14:03:41.882Z", "event": "token.issued", "...": "..." } ],
"next_cursor": "NjM5..."
}

Each record is the audit record above with the hashes as lowercase hex. next_cursor is opaque; pass it back as cursor for the next page, and it is null on the last one. A bad filter answers 400 with per-field errors. The endpoint sits behind the admin API key; a missing or wrong key answers 401 and writes admin.denied. The sponsor and agent filters are served from the (sponsor, ts, seq) and (agent_id, ts, seq) indexes, and a query with neither from (ts, seq), so a page of one person’s month is one index range in page order, never a scan of the ledger.


Standard OAuth error bodies. The ones that matter:

Error When
invalid_grant subject token expired, invalid, or from an untrusted issuer
invalid_scope scope intersection empty, or a widening attempt on refresh
invalid_target audience not in allowed_audiences
access_denied agent disabled, task revoked, sponsor no longer active, too little of the task left to issue a token for, or delegation depth exceeded
invalid_client client authentication failed: no assertion, a bad signature, an unknown agent, a replayed jti; answered as 401
invalid_request the request is missing a parameter, repeats one, or carries one outside its limits
temporarily_unavailable an upstream this decision depends on could not be reached: the identity provider’s keys, or its answer about the sponsor. Never read as a yes
slow_down too many requests from this source; answered as 429 with Retry-After

slow_down is admission control, not an authorization decision: it is answered before the request is read, so it names nothing about the caller and is recorded as a summary (section 7.0) under the reason rate_limited. The limit is per source and per instance, configured by Onbe:RateLimit:PermitsPerMinute and Onbe:RateLimit:Burst; Onbe:RateLimit:TrustedProxies names the networks whose X-Forwarded-For is believed, without which a service behind a proxy counts every caller as one. Liveness and readiness are never limited.

Always include a human-readable error_description. Every denial is written to the audit ledger with decision: "deny" and a machine-readable reason — denials are the most useful thing in the log and must never be dropped.


The SDK middleware does all of this; it is written out so the contract is clear:

  1. Fetch and cache JWKS from the control plane.
  2. Validate signature, iss, aud, exp.
  3. Enforce required scope for the route.
  4. If the route is high-risk, introspect instead of validating locally.
  5. Log sub (the human) and act.sub (the agent) on every request.
  6. Reject any token whose act chain is deeper than the server’s own limit.

Step 5 is the one people skip, and it is the entire point.

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

© 2026 Onbe