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.
1. Discovery
Section titled “1. Discovery”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.
2. Agent registration
Section titled “2. Agent registration”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_requiredmeans no token is ever issued without a human subject token. In v0.1 it must betrue, and a registration withfalseis rejected: every exchange requires a subject token, sofalsewould 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_ttlis the lifetime of the whole task.max_token_ttlis 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 givenOnbe:Tokens:DefaultTaskTtlandOnbe: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 boundsOnbe:Agents:MinTaskTtl,MaxTaskTtl,MinTokenTtlandMaxTokenTtl, which is where an operator holds every agent to something shorter.max_delegation_depthis the longestactchain a token issued to this agent may carry. In v0.1 the control plane issuesdepth: 1only (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_audiencesnames 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 carriesintrospect_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_urinames where the agent publishes the public keys it authenticates with. A registration may instead carry them inline asjwks, an RFC 7517 key set with public members only; the control plane then serves them atGET /agents/{agent_id}/jwks.jsonand fetches nothing. Exactly one of the two is set. A key carrying any private member is refused per field.
3. Token exchange
Section titled “3. Token exchange”The main endpoint. Standard RFC 8693.
POST /oauth2/tokenContent-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:commentAuthorization decision
Section titled “Authorization decision”Effective scope is the intersection of three sets:
effective = user_scopes ∩ agent.allowed_scopes ∩ requested_scopesEmpty 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.
Response
Section titled “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"}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.
4. Task token claims
Section titled “4. Task token claims”{ "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.
Sub-agent delegation — not in v0.1
Section titled “Sub-agent delegation — not in v0.1”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.
5. Refresh for long-running tasks
Section titled “5. Refresh for long-running tasks”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.
6. Revocation
Section titled “6. Revocation”POST /oauth2/revoke # RFC 7009, revokes one token or a task grantDELETE /admin/tasks/{id} # kills the task and every token under itDELETE /admin/agents/{id}/tasks # kills every live task for an agentPOST /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.
7. Audit record
Section titled “7. Audit record”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.
7.0 Summary records
Section titled “7.0 Summary records”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.
7.1 Delivery to a sink
Section titled “7.1 Delivery to a sink”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.
7.2 Query
Section titled “7.2 Query”The query that sells the product:
GET /audit?sponsor=f47ac10b-...&from=2026-09-01&to=2026-09-30Authorization: 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.
8. Errors
Section titled “8. Errors”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.
9. What a tool server must do
Section titled “9. What a tool server must do”The SDK middleware does all of this; it is written out so the contract is clear:
- Fetch and cache JWKS from the control plane.
- Validate signature,
iss,aud,exp. - Enforce required scope for the route.
- If the route is high-risk, introspect instead of validating locally.
- Log
sub(the human) andact.sub(the agent) on every request. - Reject any token whose
actchain is deeper than the server’s own limit.
Step 5 is the one people skip, and it is the entire point.
© 2026 Onbe