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.

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.

Terminal window
cd quickstart
docker compose up

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

What it looks like
[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:58Z

Eight 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

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:comment in the token at all. The same agent asking for exactly the same thing gets two different tokens, and the tool server refuses comment for 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_ttl and max_token_ttl doing 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.

Everything keeps running. The control plane is on http://localhost:5100, and the ledger is the part worth reading:

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

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.

  • .NET 10 SDK. dotnet --version should print 10.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.

  1. 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.pem
    Output
    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.pem

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

  2. Write the configuration. Every setting is an environment variable; the Onbe__ prefix and the double underscores are how .NET maps them onto Onbe: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=sqlite
    export Onbe__Database__Path=./onbe.db
    export Onbe__Signing__Keys__0__Path=./signing.pem
    export 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/main
    export Onbe__UpstreamIdp__Audience=onbe
    export Onbe__UpstreamIdp__SponsorCheck__UsersUrl=https://localhost:8443/admin/realms/main/users
    export Onbe__UpstreamIdp__SponsorCheck__TokenUrl=https://localhost:8443/realms/main/protocol/openid-connect/token
    export Onbe__UpstreamIdp__SponsorCheck__ClientId=onbe
    export ASPNETCORE_URLS=http://127.0.0.1:5100
    export ASPNETCORE_ENVIRONMENT=Production

    The admin key must be at least 32 characters. Without it the admin API is disabled entirely and every /admin request answers 503.

  3. 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.sh
    Onbe.Server migrate
    Output
    Applied 5 migration(s):
    - 20260912075307_InitialSchema
    - 20260912131102_AddAgentJwks
    - 20260912132216_WidenAuditReason
    - 20260913223135_AddAuditEventCount
    - 20260915165935_AddAuditEventChain
    Schema version: 20260915165935_AddAuditEventChain
  4. Check it before you need it. doctor runs 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 doctor
    Output
    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.

  5. Start it.

    Terminal window
    Onbe.Server
  6. Check that it is its own issuer.

    Terminal window
    curl -s http://127.0.0.1:5100/.well-known/openid-configuration
    Response
    {
    "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_post in that list and there never will be: static shared secrets are the thing this project exists to remove.

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.

  1. 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_audiences
    agents/jira-triage.jwks.json its public keys, the same set the registration embeds
    agents/jira-triage.key.pem its private key, readable by its owner only
    Key id: gfjXIu9dHb5E9KJfKdR4BhczXLzwa_cokBPzsdlxavw (RFC 7638 thumbprint)
    The private key belongs in a secret store, not in the repository. Everything else here
    is 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.

  2. Fill in what it may do, then apply it.

    agents/jira-triage.yaml
    agent_id: jira-triage
    display_name: Jira triage agent
    sponsor_required: true
    allowed_scopes: [jira:read, jira:comment]
    allowed_audiences: [https://jira.internal]
    max_task_ttl: PT30M
    max_token_ttl: PT5M
    max_delegation_depth: 1
    high_risk_audiences: [https://jira.internal]
    jwks:
    keys:
    - kid: gfjXIu9dHb5E9KJfKdR4BhczXLzwa_cokBPzsdlxavw
    kty: EC
    crv: P-256
    x: Jc2LJFZnn6MzLIVes3fkOzE9AuUISXBnXi-kKPq1mFI
    y: xxcT-A9tys4KKQPFsCZulIHCSe-GWFo68V8iit5sKiI
    alg: ES256
    use: sig
    Terminal window
    export ONBE_ADMIN_KEY=$Onbe__Admin__ApiKey
    Onbe.Server agent apply agents/jira-triage.yaml --server http://127.0.0.1:5100
    Output
    created jira-triage
    1 file(s), 1 changed.

    Run it again and it says unchanged, having made one call — the read. apply creates 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.

  3. 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.json
    Response
    {
    "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.

You need two things: a user’s access token from your identity provider, and an assertion signed with the agent’s key.

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

  2. Build the agent’s assertion. A compact JWS with these claims, signed by the key agent init wrote, with that key’s kid in the header:

    Claim Value
    iss jira-triage
    sub jira-triage
    aud http://127.0.0.1:5100 or http://127.0.0.1:5100/oauth2/token
    jti Something never used before, at most 256 characters
    iat, exp exp at most five minutes ahead

    You do not have to build this by hand. @onbe/client does it for you — see Build an agent — and this is the last time this page asks you to think about it.

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

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:

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

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

© 2026 Onbe