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.
Quickstart
There are two ways to see Onbe working. The first takes one command and shows you the whole story: an identity provider, the control plane, a tool server and an agent acting for a real user, with a scoped exchange, a refusal, a kill switch and the ledger of all of it. The second is the same thing assembled by hand, which is what you want before you deploy anything.
The whole story in one command
Section titled “The whole story in one command”cd quickstartdocker compose upPostgres, Keycloak, the control plane, a sample tool server and a demo agent come up; the schema is applied by a one-shot migrate before the control plane starts; and the agent then plays the story out in the logs.
[3/8] the agent exchanges the human's token for a scoped task token ok task task_01M2A5WB4S9FCX3QV4DPM088AR started sub 11111111-1111-4111-8111-111111111111 <- the human, not the agent act.sub agent:demo-agent <- the agent, as the actor scope jira:read jira:comment expires_in 300s, while the task runs until 2026-09-12 07:14:58ZEight steps, each one a guarantee:
| Step | What happens |
|---|---|
| 1 | The demo user signs in at Keycloak and gets an ordinary access token |
| 2 | The agent registers: the scopes, audiences and lifetimes it may ever have |
| 3 | It exchanges the human’s token for a task token — sub the human, the agent in act |
| 4 | The tool server takes that token: search locally, comment by asking the control plane |
| 5 | The task outlives the token, so the agent renews it, narrower; comment is then refused |
| 6 | The agent asks for a scope nobody gave it, and the exchange refuses with invalid_scope |
| 7 | An operator kills the task, and the still-unexpired token stops working on the next call |
| 8 | The ledger prints every one of those decisions, allow and deny, against the human’s name |
Drive it yourself, in a browser
Section titled “Drive it yourself, in a browser”Once it is up, http://localhost:8090 is a small portal for doing the same things by hand. Sign in as the demo user and it shows two tokens side by side: theirs from
Keycloak, and the one the agent got in exchange for it. The sub is the same on both; the act
on the agent’s says who is actually holding it.
The things worth doing there, in rough order of how much they change people’s minds:
- Sign in as each of the two demo users. One holds a realm role the other does not, and the
role decides whether Keycloak will put
jira:commentin the token at all. The same agent asking for exactly the same thing gets two different tokens, and the tool server refusescommentfor the second. Nothing in the agent knows why — which is the point. - Send your own token to the tool server instead of the agent’s.
401. The tool server takes task tokens from this control plane and nothing else. - Start a long task and leave it. Tokens last thirty seconds, the task runs for ten minutes,
and you watch the renewal counter climb while the same task keeps working. That split is
max_task_ttlandmax_token_ttldoing their job: a long job never holds a long credential. - Revoke a running task, then call the tool again. The token in hand is unexpired and correctly signed, and it stops working on the next call.
The portal holds the admin API key so it can show you the ledger, and by default it collects a password rather than sending you to Keycloak. No real application does either; it is a viewer for the thing being demonstrated, not an application to copy.
Afterwards
Section titled “Afterwards”Everything keeps running. The control plane is on http://localhost:5100, and the ledger is
the part worth reading:
ADMIN_KEY=$(docker compose exec -T onbe cat /etc/onbe/admin-key)curl -s -H "Authorization: Bearer $ADMIN_KEY" \ 'http://localhost:5100/audit?sponsor=11111111-1111-4111-8111-111111111111&limit=50'docker compose down -v stops it and throws away the database and the agent’s key.
This is a demonstration on one machine, not a deployment: everything talks plain HTTP inside the
compose network, the credentials in compose.yaml protect nothing, and the signing key, admin
key and agent key are generated when the images are built. Operate it is
the other end of that.
The same thing by hand
Section titled “The same thing by hand”Nothing below needs Docker. It is the shape of a real install, minus the parts that only matter when more than one person depends on it.
What you need
Section titled “What you need”- .NET 10 SDK.
dotnet --versionshould print10.x. - An OIDC identity provider. v0.1 targets Keycloak. Onbe never authenticates anyone itself; it takes a user’s access token from your provider as the subject of an exchange.
- A database — or not. Postgres 16 for anything real. For a trial on a laptop the embedded SQLite provider is a file path and nothing else, and it is the same control plane: the same schema, the same rules, one writer at a time.
Onbe.Server below is the server binary: from a checkout,
dotnet run --project src/Onbe.Server -- <command>; in the published image, the same words as
the container’s arguments.
-
Make a signing key. This is the key the control plane signs task tokens with. It is never generated for you — outside development a missing key stops the server at startup, because a key that appeared by itself is a key nobody is minding.
Terminal window Onbe.Server keys generate --out ./signing.pemOutput Wrote a new P-256 signing key to ./signing.pem, readable by its owner only.Key id: 9Fy-qRMxKnbHaNBF9nwF68GQVxzhgz3miPA15FHHv38 (RFC 7638 thumbprint)Configure the server with:Onbe__Signing__Keys__0__Path=./signing.pemThe key id is the RFC 7638 thumbprint of the public half. It appears in JWKS and in the header of every token signed with the key, so it is what a verifier uses to pick the right one. Nothing private is ever printed; the PEM exists only in the file.
-
Write the configuration. Every setting is an environment variable; the
Onbe__prefix and the double underscores are how .NET maps them ontoOnbe:Section:Key.env.sh export Onbe__Issuer=http://127.0.0.1:5100# The embedded database. For Postgres, set Onbe__Database__ConnectionString instead.export Onbe__Database__Provider=sqliteexport Onbe__Database__Path=./onbe.dbexport Onbe__Signing__Keys__0__Path=./signing.pemexport Onbe__Admin__ApiKey=$(openssl rand -base64 32)# Your identity provider's realm URL. Onbe derives discovery from it.export Onbe__UpstreamIdp__Issuer=https://localhost:8443/realms/mainexport Onbe__UpstreamIdp__Audience=onbeexport Onbe__UpstreamIdp__SponsorCheck__UsersUrl=https://localhost:8443/admin/realms/main/usersexport Onbe__UpstreamIdp__SponsorCheck__TokenUrl=https://localhost:8443/realms/main/protocol/openid-connect/tokenexport Onbe__UpstreamIdp__SponsorCheck__ClientId=onbeexport ASPNETCORE_URLS=http://127.0.0.1:5100export ASPNETCORE_ENVIRONMENT=ProductionThe admin key must be at least 32 characters. Without it the admin API is disabled entirely and every
/adminrequest answers503. -
Apply the schema. Migrations are never applied at startup — running them is a decision someone makes, not a side effect of a deploy.
Terminal window source env.shOnbe.Server migrateOutput Applied 5 migration(s):- 20260912075307_InitialSchema- 20260912131102_AddAgentJwks- 20260912132216_WidenAuditReason- 20260913223135_AddAuditEventCount- 20260915165935_AddAuditEventChainSchema version: 20260915165935_AddAuditEventChain -
Check it before you need it.
doctorruns the five checks the server would otherwise fail at, in the order they matter, and exits non-zero if any of them is wrong.Terminal window Onbe.Server doctorOutput ok configuration Loaded.ok signing key Active kid '9Fy-qRMxKnbHaNBF9nwF68GQVxzhgz3miPA15FHHv38', 1 key(s) published.FAIL upstream The discovery document could not be fetched.Likely cause: nothing is serving http://127.0.0.1:8080/realms/main from here, or the request timed out.Check the host is resolvable and reachable from this pod, and that Onbe__UpstreamIdp__Issuer is the realm URL.ok database Reachable (sqlite), schema up to date.ok admin API Enabled; /admin requires the configured key.5 check(s), 1 failed.That is a real failure, and it is the common one: the identity provider is not up, or the realm URL is not the URL this machine can reach it on.
-
Start it.
Terminal window Onbe.Server -
Check that it is its own issuer.
Terminal window curl -s http://127.0.0.1:5100/.well-known/openid-configurationResponse {"issuer": "http://127.0.0.1:5100","token_endpoint": "http://127.0.0.1:5100/oauth2/token","introspection_endpoint": "http://127.0.0.1:5100/oauth2/introspect","revocation_endpoint": "http://127.0.0.1:5100/oauth2/revoke","jwks_uri": "http://127.0.0.1:5100/.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"]}One authentication method, and it is the one where the client proves a key it never sends. There is no
client_secret_postin that list and there never will be: static shared secrets are the thing this project exists to remove.
Register an agent
Section titled “Register an agent”The registration is the policy. Everything the agent may ever do is bounded by it, so it belongs
in a repository under review rather than in a curl typed by whoever holds the admin key.
-
Write the registration and the agent’s key.
Terminal window Onbe.Server agent init jira-triage --out agents/Output Wrote a registration for 'jira-triage':agents/jira-triage.yaml the registration; fill in allowed_scopes and allowed_audiencesagents/jira-triage.jwks.json its public keys, the same set the registration embedsagents/jira-triage.key.pem its private key, readable by its owner onlyKey id: gfjXIu9dHb5E9KJfKdR4BhczXLzwa_cokBPzsdlxavw (RFC 7638 thumbprint)The private key belongs in a secret store, not in the repository. Everything else hereis public and is meant to be committed.The defaults are deliberately useless:
sponsor_required: true, a thirty-minute task, a five-minute token, depth 1, and empty scope and audience lists. An agent with no scopes and no audiences can be granted nothing, so the file cannot become dangerous by being applied before somebody has decided what it may do. Filling those two lists in is the review. -
Fill in what it may do, then apply it.
agents/jira-triage.yaml agent_id: jira-triagedisplay_name: Jira triage agentsponsor_required: trueallowed_scopes: [jira:read, jira:comment]allowed_audiences: [https://jira.internal]max_task_ttl: PT30Mmax_token_ttl: PT5Mmax_delegation_depth: 1high_risk_audiences: [https://jira.internal]jwks:keys:- kid: gfjXIu9dHb5E9KJfKdR4BhczXLzwa_cokBPzsdlxavwkty: ECcrv: P-256x: Jc2LJFZnn6MzLIVes3fkOzE9AuUISXBnXi-kKPq1mFIy: xxcT-A9tys4KKQPFsCZulIHCSe-GWFo68V8iit5sKiIalg: ES256use: sigTerminal window export ONBE_ADMIN_KEY=$Onbe__Admin__ApiKeyOnbe.Server agent apply agents/jira-triage.yaml --server http://127.0.0.1:5100Output created jira-triage1 file(s), 1 changed.Run it again and it says
unchanged, having made one call — the read.applycreates what is missing, patches what differs, and goes through the admin API like everything else, so every registration lands in the ledger exactly as a hand-made request would. -
The agent needs no public endpoint. Because the registration carries the keys, the control plane serves them itself:
Terminal window curl -s http://127.0.0.1:5100/agents/jira-triage/jwks.jsonResponse {"keys": [{"kid": "gfjXIu9dHb5E9KJfKdR4BhczXLzwa_cokBPzsdlxavw","kty": "EC","alg": "ES256","use": "sig","crv": "P-256","x": "Jc2LJFZnn6MzLIVes3fkOzE9AuUISXBnXi-kKPq1mFI","y": "xxcT-A9tys4KKQPFsCZulIHCSe-GWFo68V8iit5sKiI"}]}An agent may instead publish its own key set and register a
jwks_uri, which must be absolute HTTPS — an agent’s identity is not something to fetch over a cleartext connection. Holding the keys here is the shorter path: no endpoint for the agent to run, no egress for the control plane, and rotation is a change to a file under review.
Register an agent explains every field and what it costs you to get one wrong.
Exchange a token
Section titled “Exchange a token”You need two things: a user’s access token from your identity provider, and an assertion signed with the agent’s key.
-
Get a user’s access token. However your provider issues one — a password grant against a dev realm is the usual shortcut. Its audience must match
Onbe__UpstreamIdp__Audience. -
Build the agent’s assertion. A compact JWS with these claims, signed by the key
agent initwrote, with that key’skidin the header:Claim Value issjira-triagesubjira-triageaudhttp://127.0.0.1:5100orhttp://127.0.0.1:5100/oauth2/tokenjtiSomething never used before, at most 256 characters iat,expexpat most five minutes aheadYou do not have to build this by hand.
@onbe/clientdoes it for you — see Build an agent — and this is the last time this page asks you to think about it. -
Ask for the token.
Terminal window curl -s -X POST http://127.0.0.1:5100/oauth2/token \-H 'Content-Type: application/x-www-form-urlencoded' \--data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \--data-urlencode "subject_token=$USER_TOKEN" \--data-urlencode 'subject_token_type=urn:ietf:params:oauth:token-type:access_token' \--data-urlencode "actor_token=$AGENT_ASSERTION" \--data-urlencode 'actor_token_type=urn:ietf:params:oauth:token-type:jwt' \--data-urlencode 'requested_token_type=urn:ietf:params:oauth:token-type:access_token' \--data-urlencode 'resource=https://jira.internal' \--data-urlencode 'scope=jira:read jira:comment'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"}
That is a task token. Decode it and the subject is the human who authenticated at your identity
provider — not the agent, which appears in act.
When it does not work
Section titled “When it does not work”| What you see | What it means |
|---|---|
| The server refuses to start | It names every missing or invalid setting at once, and never repeats a configured value back at you |
503 from /admin/agents |
Onbe__Admin__ApiKey is not set, so the admin API is switched off |
401 from /admin/agents |
The key is wrong. The refusal is in the audit ledger as admin.denied |
invalid_request, actor_token is required |
The exchange went out without the agent’s assertion |
invalid_client |
The assertion did not verify: wrong key, a reused jti, or an unknown agent |
invalid_grant |
The subject token is expired, malformed, or not from the configured provider |
invalid_scope |
The intersection of user, agent and request is empty |
invalid_target |
The audience is not in the agent’s allowed_audiences |
temporarily_unavailable |
The identity provider’s keys, the agent’s keys, or the sponsor’s status could not be fetched. Retry |
slow_down, 429 |
Too many requests from one source. Wait for Retry-After; behind a proxy, set Onbe__RateLimit__TrustedProxies or every caller counts as one |
Anything involving your identity provider — a realm whose issuer is not the URL you reach it on,
or an audience that is not yours — Onbe.Server doctor will tell you in one line.
Every refusal above is also a row in the audit ledger, with the reason. That is usually the faster place to look:
curl -s "http://127.0.0.1:5100/audit?decision=deny&limit=5" \ -H "Authorization: Bearer $Onbe__Admin__ApiKey"Your first exchange takes the response apart field by field, including the one that surprises people.
© 2026 Onbe