Skip to content

Sessions, revocation and the site list

Checking every request with bage.verify suits APIs. Pages work better the way they do for people: sign the agent in once, then let its browser carry your usual cookie. Bage gives you three pieces for that.

Mount bage.sessionHandler at /.well-known/agent-login/session and pass sessionPath to createVerifier, so /.well-known/agent-login tells agents where it is (session_endpoint).

The agent sends one request there with its agent token and a Web Bot Auth signature. The handler checks both, then calls your startSession with the verified agent. You find the person’s account (by agent.userEmail when they shared it), make a session that remembers agent.tokenId, and return its Set-Cookie values. The handler answers 303 to ?next= (only a path on your site, never another origin), or JSON { "ok": true, "next": "/…" } when the agent asks for JSON. Return { refuse: "why" } to turn the agent away with a 403.

Your session can last longer than the 15-minute agent token: the webhook below ends it the moment the agent is revoked.

In the console, open your verified website and set a webhook address. Bage signs every event with the secret shown there (whsec_…) in the Standard Webhooks format, so any Standard Webhooks library works too.

{
"id": "evt_0mhk2c3d9xQ4fT2aB",
"type": "tokens.revoked",
"created_at": 1767226000,
"data": {
"site": "https://shop.example",
"token_ids": ["tok_8d2e4f"],
"person": { "id": "usr_5b1c9e" },
"agent": { "id": "agt_7a3d1f", "name": "Scout", "operator": "Northwind Labs" },
"reason": "person_revoked",
"revoked_at": 1767226000
}
}

End every session made from a token in token_ids. reason is person_revoked (the person cut the agent off in Bage), agent_disabled or agent_key_rotated (the agent’s company), site_blocked_agent (you, in the console), or session_ended (Bage ended one sign-in session, for example because its refresh token was used twice). bage.receiveWebhook(headers, rawBody) checks the signature and the timestamp, and also refuses those tokens in verify at once. Answer 2xx. Bage retries anything else after 30 seconds, 2 minutes, 10 minutes, 1 hour, 6 hours and 24 hours, with the same webhook-id, so you can skip repeats. Send test in the console sends a ping event.

server.ts
// npm install @trybage/sdk
// A site that turns a verified agent into its normal cookie session, and ends those sessions when Bage says the
// agent's tokens were revoked.
import crypto from "node:crypto";
import http from "node:http";
import { createVerifier, SESSION_PATH, type VerifiedAgent } from "@trybage/sdk/site";
const origin = process.env.ORIGIN ?? "https://shop.example"; // your site, as agents see it
const bage = createVerifier({
issuers: [process.env.BAGE_ISSUER ?? "https://api.trybage.com"],
audience: origin,
sessionPath: SESSION_PATH, // advertised to agents as session_endpoint
webhookSecret: process.env.BAGE_WEBHOOK_SECRET, // from the console: Websites → your site → Webhook
});
// Your session store. Keep the token id with each session made from an agent.
const sessions = new Map<string, { agent: VerifiedAgent; expiresAt: number }>();
const send = (response: http.ServerResponse, status: number, body: object) => {
response.writeHead(status, { "content-type": "application/json" });
response.end(JSON.stringify(body));
};
// One signed request from the agent: check it, make a session, set the cookie, redirect to ?next=.
const startSession = bage.sessionHandler({
startSession: async (agent) => {
// Match the person's account here, for example by agent.userEmail (the "email" scope).
const id = crypto.randomBytes(24).toString("base64url");
sessions.set(id, { agent, expiresAt: Date.now() + 60 * 60 * 1000 });
return { cookies: [`sid=${id}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=3600`] };
},
});
const readBody = (request: http.IncomingMessage) =>
new Promise<string>((resolve) => {
let body = "";
request.on("data", (chunk: Buffer) => (body += chunk.toString()));
request.on("end", () => resolve(body));
});
async function handle(request: http.IncomingMessage, response: http.ServerResponse) {
const path = new URL(request.url ?? "/", origin).pathname;
if (path === "/.well-known/agent-login") return send(response, 200, bage.discovery());
if (path === SESSION_PATH) return startSession(request, response);
// Bage's webhook: end every session made from a revoked token. Check the signature on the raw body.
if (request.method === "POST" && path === "/bage/webhooks") {
try {
const event = bage.receiveWebhook(request.headers, await readBody(request));
if (event.type === "tokens.revoked") for (const [id, session] of sessions) if (event.data.token_ids.includes(session.agent.tokenId)) sessions.delete(id);
return send(response, 200, { ok: true });
} catch {
return send(response, 400, { error: "not from Bage" });
}
}
// Every other page: the cookie, like any logged-in visitor.
const id = /(?:^|;\s*)sid=([^;]+)/.exec(request.headers.cookie ?? "")?.[1];
const session = id ? sessions.get(id) : undefined;
if (!session || session.expiresAt < Date.now()) return send(response, 401, { error: "Sign in first" });
return send(response, 200, { hello: `${session.agent.agent.name}, acting for ${session.agent.userName ?? session.agent.userId}` });
}
http.createServer((request, response) => void handle(request, response)).listen(Number(process.env.PORT ?? 3000));

On the agent side, session.startSiteSession({ next }) makes the handoff request and returns the cookies. A browser agent on Playwright can instead post with page.context().request and session.signedHeaders("POST", path), which puts the cookies straight into the browser.

Once your domain is verified, your site shows in GET https://api.trybage.com/v1/sites, which agents read to know where they can sign in as their person. ?origin=https://shop.example asks about one site. Each entry has your name, origin, agent-login file, the scopes you allow (custom_scopes spells out your own) and whether you let in any agent or only ones you listed. Turn off “List this website” in the console to leave the list; nothing else changes.