Pre-release. v0.1 is not out yet.
Let an agent act for a person, not instead of one.
Onbe is an agent identity and delegation control plane. It issues short-lived, scoped tokens that let an AI agent act on behalf of a specific human, and keeps a hash-chained record of every decision it made.
A task token can never carry more authority than the human who invoked it, and every action traces back to that human.
- RFC 8693
- token exchange, nothing proprietary
- 5 min
- default token life, refreshed as it goes
- sub ≠ act
- the human is the subject, the agent the actor
- allow · deny
- both written to a hash-chained ledger
One person, three agents, one gate
- token.issuedjira:read jira:commentjira.internal
- token.refreshedsame scope, 5 more minutesgithub.internal
- token.deniedinvalid_targetdb.internal
What the first lane issuedexpires in 5 min
{
"sub": "f47ac10b-58cc-4372-…", ← the human
"act": { ← the agent
"sub": "agent:jira-triage",
"depth": 1
},
"scope": "jira:read jira:comment",
"aud": "https://jira.internal"
}act, never in sub — a tool server that sees no act knows a human called it directly.- 5invariants
- Rules the server keeps, not a document that says it does. Each one is a test.
- 12adversarial conformance cases
- Run against a live instance in CI through its public surface only, each checking the refusal reached the ledger.
- 4RFCs, no protocol of its own
- Token exchange, introspection, revocation and resource indicators, used as written.
- 0shared secrets
- An agent signs its own assertion with its own key. There is no client secret to copy, leak or rotate.
The problem
An agent with a static key is a user with no name and no limit.
This is not a hypothetical about the future. It is how almost every agent in production is wired today, including the ones running inside otherwise careful organisations.
The key never expires
An agent ships with a static credential in an environment variable. It works on the first day and on the thousandth. Rotating it means finding every place it was copied to, and nobody knows how many places that is.
ONBE_API_KEY=sk_live_…
It can do more than the person who asked
The agent holds the key’s authority, not the caller’s. Someone with read access asks it to summarise a ticket, and the agent could have closed the project — because the key could, and nothing narrowed it to the person.
user: read · agent key: read, write, admin
The log says the agent did it
When the audit is read back, every line names the service account. Which human was behind any given action is a question answered by guesswork, timestamps and chat history, if it is answered at all.
actor=svc-automation sub=?
How it works
One exchange, and the authority only ever gets smaller.
It is RFC 8693 token exchange with the rules that make delegation safe written into the server rather than left to each client to remember.
Two things go in
The user’s access token from your identity provider, and the agent’s own assertion signed with its private key. No shared secret exists to be copied, and no request is anonymous.
The server narrows
Effective scope is the intersection of what the user has, what the agent is allowed, and what was asked for. An empty intersection is an error, never an empty token. The audience must be one the agent is registered for.
A small token comes out
It names the human in sub and the agent in act, lasts minutes rather than hours, and never outlives its task. Whatever the server decided — allow or deny — is appended to a hash-chained ledger before the answer is returned.
Where the narrowing actually happens
A scope has to survive all three sets to reach the token. One of these is dropped because the agent may not use it, and one because nobody asked for it.
The user has
- jira:read
- jira:comment
- jira:admin
- confluence:read
The agent may
- jira:read
- jira:comment
- not held: jira:admin
- confluence:read
The request asked for
- jira:read
- jira:comment
- jira:admin
- not held: confluence:read
Effective scope
- jira:read
- jira:comment
- jira:admin — dropped, not one this agent may use
- confluence:read — dropped, not asked for
jira:read and jira:comment reach the token. An empty intersection is an error, not a token with nothing in it — and the same rule runs again on every delegation hop, so a sub-agent can only ever come away with less than the one that called it.
The code
Two dependencies, and the hard parts are already decided.
One SDK gets a token and keeps it fresh. The other refuses a call that arrives without one. Neither asks the person writing an agent to remember a security rule.
@onbe/client
An agent that acts for someone
The user’s token and the agent’s key go in once. Every call after that carries a token that is minutes old, refreshed before it expires without the agent doing anything about it.
const client = new OnbeClient({
issuer: required('ONBE_ISSUER'),
agentId: required('ONBE_AGENT_ID'),
kid: required('ONBE_AGENT_KID'),
privateKey: readFileSync(required('ONBE_AGENT_KEY_FILE'), 'utf8'),
// … onRefresh and onRefreshError
});
const session = await client.exchange({
subjectToken: required('ONBE_SUBJECT_TOKEN'),
resource: required('ONBE_RESOURCE'),
scope: required('ONBE_SCOPE'),
});
// … then, for as long as the task has left to run:
while (Date.now() < end) {
const response = await fetch(toolUrl, {
headers: { authorization: `Bearer ${await session.accessToken()}` },
});
// … count the answer, wait, go round again
}onbe-sdk · examples/long-running/index.ts · lines 53–84, shortened
@onbe/mcp
An MCP server that only answers to one
Each tool names the scope it needs. A tool marked high-risk is checked with the control plane on every single call, so revoking a task is felt at once rather than when the token happens to expire.
const guard = new OnbeGuard({
issuer: process.env['ONBE_ISSUER'] ?? 'http://127.0.0.1:5100',
audience: 'https://jira.internal',
tools: { search: { scope: 'jira:read' }, comment: { scope: 'jira:comment', highRisk: true } },
});
createServer((req, res) => serve(req, res).catch((e) => guard.reject(res, e))).listen(port());
async function serve(req: IncomingMessage, res: ServerResponse): Promise<void> {
const auth = await guard.authenticate(req.headers.authorization);
await (await jira()).handleRequest(Object.assign(req, { auth }), res);
}onbe-sdk · examples/mcp-jira/index.ts · lines 20–29
Both files are typechecked in the SDK repository’s continuous integration, so neither is code that only ever existed in a screenshot. What is shown here is an excerpt of each, named down to the line so it can be held against the original.
The invariants
Five rules, enforced by the server rather than by convention.
Everything else in the design follows from these. They are the reason a conformance suite exists: the rules are checked against the running server, not against a document that says they are true.
The subject is always the human
An agent appears in the token’s act claim and never in sub. This is delegation, not impersonation, and a tool server that sees no act knows a human called it directly.
Scope only narrows
At issue, at refresh, and at every delegation hop. Effective scope is an intersection, and a refresh may ask for a subset of what the task holds and nothing more. Never widens, for any reason.
A token never outlives its task
Token lifetime is capped by whatever is left of the task. A six-hour job does not hold a six-hour credential; it holds a five-minute one, renewed as it goes.
Every decision is recorded
Allows and denials alike, appended to a hash-chained ledger before the answer goes out, with a machine-readable reason. A denial that was not written down did not happen, as far as the person it protects is concerned.
No secret is ever logged
Not in an error response, not in a log line, not in a stack trace. The project exists to remove shared secrets, so it does not go on to leak the ones that remain.
A 22-minute task, on five-minute tokens
max_token_ttl PT5M · max_task_ttl PT22M
- token 1, issued at minute 0, good for 5m
- token 2, issued at minute 5, good for 5m
- token 3, issued at minute 10, good for 5m
- token 4, issued at minute 15, good for 5m
- token 5, issued at minute 20, good for 2m
Four tokens run their full five minutes. The fifth is issued for two, because two is all the task has left — the cap is the task's remaining life, never the other way round. The client refreshes at sixty per cent of each token's life, so the tool server never sees an expired one and never has to answer 401.
The audit query
Every action any agent took on behalf of this person last month.
One request, one index range, an answer in page order. This is the question that gets asked after an incident, and the one that is nearly impossible to answer when an agent runs on a service account. Every record naming a person sits on that person’s chain, so the answer is one chain, shown complete.
Request
GET /audit?sponsor=f47ac10b-58cc-4372-a567-0e02b2c3d479
&from=2026-09-01&to=2026-09-30
Authorization: Bearer <admin api key>Filters combine with and: sponsor, agent, task, a time range, and whether the decision was an allow or a deny. The endpoint sits behind the admin key, and a request without one is itself written down.
Response5 of 61 records
- 1042814:03:41token.issuedallowjira:read jira:comment
- 1043114:04:07task.createdallowhttps://jira.internal · 30 minutes
- 1043614:06:55token.denieddenyinvalid_scope · asked for jira:admin
- 1044014:09:12token.refreshedallowsame scope, 5 more minutes
- 1050214:33:00task.expiredthe sweeper, revoking its grants
sponsor=f47ac10b-58cc-4372-a567-0e02b2c3d479 · 2026-09-01 to 2026-09-30
It cannot be edited later
Each record hashes its own contents together with the hash of the record before it in its chain, so a line cannot be changed, removed or inserted without breaking every link that follows. The table refuses updates and deletes outright, and a command walks every chain and reports the first record that no longer adds up.
hash = sha256(canonical_json(record minus hash) || prev_hash)
Try it on the right. Rewriting one record and recomputing its hash — what someone with write access to the database would actually do — leaves the next record pointing at a hash that no longer exists.
One chain of the ledger
- 10428token.issuedscope: jira:read jira:commentprev 9c1f…a20bhash 4e77…c913
- 10429token.refreshedscope: jira:read jira:commentscope: jira:read jira:adminprev 4e77…c913hash 8b30…11ded4a9…0f7c
- 10430token.refreshedscope unchangedprev 8b30…11dehash a15c…7b92prev does not match the record before it
- 10431task.expiredgrants revoked with itprev a15c…7b92hash 6f02…be41prev does not match the record before it
$ onbe audit-verify 10431:6f02…be41
chain intact · head reached unchanged
broken link at seq 10430 · exit 3
{
"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"
}Standards
No new protocol to learn, and nothing proprietary to trust.
Onbe is its own OIDC issuer, discoverable the usual way. Agents authenticate with private_key_jwt, signing a short-lived assertion with their own key. There is no client_secret_post, because static shared secrets are the thing this exists to remove.
- RFC 8693
Token exchange
The one endpoint. The user’s access token and the agent’s signed assertion go in as subject and actor; a task token scoped to the intersection comes out. When sub-agent delegation lands, the previous actor nests inside the new one exactly as §4.1 describes; v0.1 issues depth 1 only.
- RFC 7662
Introspection
A tool server asks whether a token is still good. Tools marked high-risk ask on every call, which is what makes a revocation take effect immediately instead of whenever the token would have expired.
- RFC 7009
Revocation
Kill one token by its jti, or revoke the task and every grant delegated beneath it. Descendants are written to the ledger with the parent as the reason, so the shape of what was stopped is recoverable afterwards.
- RFC 8707
Resource indicators
A token is minted for one audience and is not a token for another. The agent must already be registered for that audience, so a misconfigured client cannot ask its way into a system it was never meant to reach.
Running it
Self-host it, or wait for the hosted one.
Self-hosting is the product. Onbe Cloud is the same control plane operated for you, and it is not open yet.
Self-hosted
One service next to your identity provider and a Postgres database. It is its own OIDC issuer, so the agents and tool servers you already run discover it the usual way.
- Control plane under AGPL-3.0; the SDKs under Apache-2.0.
- Migrations run by an explicit command, never on startup.
- A conformance suite that checks the invariants against your running server.
The source opens with v0.1. There is nothing to clone yet, and this page will link to it the day there is.
Onbe Cloud
The same control plane, operated and upgraded for you, with the audit ledger kept somewhere your own infrastructure cannot quietly rewrite.