Agents quickstart
1. Get a client ID
Section titled “1. Get a client ID”It’s self-serve and free. Start free in the console: enter your work email and the code we send, name your company, then register your agent with a name, the company that runs it, and an icon. Nobody reviews it first. The client ID is issued on the spot: it’s your agent’s public name at Bage, the sub of every call it makes and the client_id in every token, so sites can tell which agent came. The console makes a key pair in your browser and gives you the private half once, as a key file:
{ "issuer": "https://api.trybage.com", "client_id": "agt_…", "private_jwk": { "kty": "OKP", "crv": "Ed25519", "x": "…", "d": "…" }}Keep it secret, like any API key. Bage only stores the public half. If you rotate the key, the old one stops working at once.
2. Sign in as the person
Section titled “2. Sign in as the person”npm install @trybage/sdk// npm install @trybage/sdkimport { BageAgent } from "@trybage/sdk/agent";
// The key file you downloaded from the console: { "issuer", "client_id", "private_jwk" }const { issuer, client_id, private_jwk } = JSON.parse(process.env.BAGE_AGENT_KEY ?? "{}");const agent = new BageAgent({ issuer, clientId: client_id, privateJwk: private_jwk });
// Asks the person, then waits until they answer (up to 5 minutes).const session = await agent.signIn({ site: process.env.SITE ?? "https://shop.example", loginHint: process.env.PERSON ?? "alex@example.com", scopes: ["browse", "cart"], message: "Reorder this week's groceries", // shown on the approval screen onPending: () => console.log("Waiting for the person to approve…"),});
// Every request carries the agent token and a signature made with your key.const products = await session.fetch("/products");console.log(products.status, JSON.stringify(products.body));pip install trybage# pip install trybageimport jsonimport os
from trybage import BageAgent
# The key file you downloaded from the console: its JSON, or a path to itagent = BageAgent.from_key_file(os.environ["BAGE_AGENT_KEY"])
# Asks the person, then waits until they answer (up to 5 minutes).session = agent.sign_in( os.environ.get("SITE", "https://shop.example"), os.environ.get("PERSON", "alex@example.com"), ["browse", "cart"], message="Reorder this week's groceries", # shown on the approval screen on_pending=lambda _: print("Waiting for the person to approve…"),)
# Every request carries the agent token and a signature made with your key.products = session.fetch("/products")print(products.status, json.dumps(products.body))# pip install "pyjwt[crypto]" requestsimport base64, hashlib, json, os, time, uuidfrom urllib.parse import urlsplit
import jwt, requestsfrom cryptography.hazmat.primitives import hashesfrom cryptography.hazmat.primitives.asymmetric import ecfrom cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature
# The key file you downloaded from the console: { "issuer", "client_id", "private_jwk" }key_file = json.loads(os.environ["BAGE_AGENT_KEY"])ISSUER, CLIENT_ID, PRIVATE_JWK = key_file["issuer"], key_file["client_id"], key_file["private_jwk"]KEY = jwt.PyJWK(PRIVATE_JWK) # Ed25519 (EdDSA) or P-256 (ES256)
def thumbprint(jwk): """RFC 7638 JWK thumbprint: Bage's key id for your key.""" names = ("crv", "kty", "x") if jwk["kty"] == "OKP" else ("crv", "kty", "x", "y") digest = hashlib.sha256(json.dumps({n: jwk[n] for n in names}, separators=(",", ":")).encode()).digest() return base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
def client_assertion(endpoint): """A one-time JWT that proves this call comes from your agent (RFC 7523).""" now = int(time.time()) claims = {"iss": CLIENT_ID, "sub": CLIENT_ID, "aud": ISSUER + endpoint, "iat": now, "exp": now + 60, "jti": str(uuid.uuid4())} return jwt.encode(claims, KEY.key, algorithm=KEY.algorithm_name, headers={"typ": "client-assertion+jwt", "kid": thumbprint(PRIVATE_JWK)})
def call(endpoint, body): response = requests.post(ISSUER + endpoint, json={**body, "client_assertion": client_assertion(endpoint)}, timeout=10) return response.status_code, response.json()
def sign_in(site, person, scopes, message=None): """Asks the person, then polls until they answer. Returns the agent token.""" login = requests.get(site + "/.well-known/agent-login", timeout=10).json() status, started = call("/v1/bc-authorize", {"login_hint": person, "audience": login["audience"], "scope": " ".join(scopes), "binding_message": message}) if status != 200: raise RuntimeError(started["error"]) deadline = time.time() + started["expires_in"] while time.time() < deadline: status, body = call("/v1/token", {"grant_type": "urn:openid:params:grant-type:ciba", "auth_req_id": started["auth_req_id"]}) if status == 200: return body["agent_token"] if body["error"] != "authorization_pending": raise RuntimeError(body["error"]) # access_denied, expired_token, ... time.sleep(started["interval"]) raise RuntimeError("expired_token")
def sign(data): if PRIVATE_JWK["kty"] == "OKP": return KEY.key.sign(data) r, s = decode_dss_signature(KEY.key.sign(data, ec.ECDSA(hashes.SHA256()))) return r.to_bytes(32, "big") + s.to_bytes(32, "big")
def signed_get(site, path, token): """GET with the agent token and a Web Bot Auth signature (RFC 9421), naming the agent's key directory at Bage.""" authority, created = site.split("://", 1)[1], int(time.time()) pathname, _, query = path.partition("?") agent = f'sig1="{ISSUER}/v1/agents/{CLIENT_ID}/directory";type=jwks_uri' alg = "ed25519" if PRIVATE_JWK["kty"] == "OKP" else "ecdsa-p256-sha256" nonce = base64.urlsafe_b64encode(os.urandom(64)).rstrip(b"=").decode() params = ( f'("@method" "@authority" "@path" "@query" "signature-agent";key="sig1" "agent-token");created={created};expires={created + 60}' f';keyid="{thumbprint(PRIVATE_JWK)}";alg="{alg}";nonce="{nonce}";tag="web-bot-auth"' ) base = "\n".join( [ '"@method": GET', f'"@authority": {authority}', f'"@path": {pathname}', f'"@query": ?{query}', f'"signature-agent";key="sig1": {agent.removeprefix("sig1=")}', f'"agent-token": {token}', f'"@signature-params": {params}', ] ) signature = base64.b64encode(sign(base.encode())).decode() headers = {"agent-token": token, "signature-agent": agent, "signature-input": f"sig1={params}", "signature": f"sig1=:{signature}:"} return requests.get(site + path, headers=headers, timeout=10)
site = os.environ.get("SITE", "https://shop.example")token = sign_in(site, os.environ.get("PERSON", "alex@example.com"), ["browse", "cart"], "Reorder this week's groceries")response = signed_get(site, "/products", token)print(response.status_code, json.dumps(response.json()))# Ask the person (make a new client assertion for each call)curl https://api.trybage.com/v1/bc-authorize -H 'content-type: application/json' -d '{ "client_assertion": "eyJ…", "login_hint": "alex@example.com", "audience": "https://shop.example", "scope": "browse cart", "binding_message": "Reorder this week'\''s groceries" }'# → { "auth_req_id": "req_…", "expires_in": 300, "interval": 2 }
# Then poll every `interval` secondscurl https://api.trybage.com/v1/token -H 'content-type: application/json' -d '{ "client_assertion": "eyJ…", "grant_type": "urn:openid:params:grant-type:ciba", "auth_req_id": "req_…" }'# → 400 { "error": "authorization_pending" } while the person decides# → 200 { "agent_token": "eyJ…", "expires_in": 900, "scope": "browse cart" }signIn reads the site’s /.well-known/agent-login to check that it accepts Bage, then waits up to 5 minutes for the person. If they chose Always before, it returns at once. Tokens last 15 minutes, and session.fetch() refreshes them by itself for as long as the sign-in lasts. To hear back instead of waiting, to keep a session across restarts, or to sign the person in next time without their email, see Long sessions. Driving a browser? See Browser agents.
Ask only for scopes the site allows, or the request is refused with invalid_scope. Each site picks its own: Bage’s standard ones and scopes it defined itself, like returns. GET https://api.trybage.com/v1/sites?origin=https://shop.example lists them, its own with a title and description; the site’s /.well-known/agent-login points there as scopes_endpoint.
Without the SDK: every call carries a client_assertion, a JWT signed with your private key. Its header has typ: "client-assertion+jwt". Its claims are iss and sub (your client ID), aud (the full endpoint URL), exp (at most 5 minutes out) and a jti used only once. Send the token to the site in the agent-token header. Sign each request with Web Bot Auth (tag="web-bot-auth"), covering @method, @authority, @path and agent-token. The “Python, no SDK” sample does all of this.
Test mode
Section titled “Test mode”New agents start in test mode. A test agent can only ask its own team and three test people who answer by themselves:
login_hint |
Answer |
|---|---|
approve@test.trybage.com |
Approves at once |
deny@test.trybage.com |
Denies at once (access_denied) |
wait@test.trybage.com |
Never answers (authorization_pending, then expired_token) |
Test tokens carry "test": true, and websites refuse them unless they turn on allowTestAgents, which is meant for staging. When you’re ready, make the agent live in the console yourself. There’s no review. To stop abuse, one person can register up to 20 agents an hour.