Docs · Guides
Sign in with Agentboxd
An OpenID Connect provider where an AI agent’s identity is its inbox: agents sign in to your app with one API call, no password or verification email.
Agentboxd is an OpenID Connect provider, and Shakehand is the product built on it. An AI agent’s identity is its Agentboxd inbox, or an identity without a mailbox for agents that never need email, so an app that adds a Sign in with Agentboxd button lets agents sign in with the identity they already have, instead of creating a password account and clicking a verification email. The tokens are ordinary OpenID Connect ID tokens, so any standards-compliant library can check them.
#Add it in 5 minutes
An agent signs in with one API call, no browser: it asks Agentboxd for an ID token addressed to your app and sends it to you. You check the token and start a session. No password, no verification email, no secret to store.
You don’t need an Agentboxd account to accept agents. Publish a small JSON file and use its URL as your client_id: that is an open client.
{
"client_id": "https://app.example.com/oauth/client.json",
"client_name": "Example app",
"client_uri": "https://app.example.com",
"redirect_uris": ["https://app.example.com/callback"],
"token_endpoint_auth_method": "none"
}Agents sign in to you headless: they call POST /v1/inboxes/:id/identity-token with audience set to that URL and send you the token. Check it in a few lines:
# pip install 'agentboxd[identity]'
from agentboxd.identity import MemoryReplayCache, verify_agent_identity_token
CLIENT_ID = "https://app.example.com/oauth/client.json"
replay_cache = MemoryReplayCache() # several processes: keep the jti in a shared store instead
def sign_in_agent(id_token: str) -> str:
# An agent sent its Agentboxd ID token (minted with audience=CLIENT_ID): check it, return its stable id.
agent = verify_agent_identity_token(id_token, audience=CLIENT_ID, replay_cache=replay_cache)
return agent.sub # start your own session for this agentFor a "Sign in with Agentboxd" button in a web app, any OpenID Connect library works. The agent’s owner approves in the browser:
import * as oidc from 'openid-client'; // npm install openid-client
const CLIENT_ID = 'https://app.example.com/oauth/client.json'; // the file above: no registration
const config = await oidc.discovery(new URL('https://id.agentboxd.com'), CLIENT_ID, undefined, oidc.None());
// "Sign in with Agentboxd": keep verifier, state and nonce in the person's session, then redirect to url.
const verifier = oidc.randomPKCECodeVerifier();
const state = oidc.randomState();
const nonce = oidc.randomNonce();
const url = oidc.buildAuthorizationUrl(config, {
redirect_uri: 'https://app.example.com/callback',
scope: 'openid email profile',
state,
nonce,
code_challenge: await oidc.calculatePKCECodeChallenge(verifier),
code_challenge_method: 'S256',
});
// On /callback: exchange the code. The ID token is checked for you (signature, iss, aud, exp, nonce).
const tokens = await oidc.authorizationCodeGrant(config, callbackUrl, { pkceCodeVerifier: verifier, expectedState: state, expectedNonce: nonce });
const agent = tokens.claims()!; // agent.sub, agent.email, agent.actor_type === 'agent'- Open clients get
openid,emailandprofile. The consent screen shows your domain and says the app isn’t registered. - Redirect URIs must be
httpson the same host as the file. - Register your app instead (below) when you need a secret, the
workspacescope, the token exchange at/token, or your name without the "not registered" note.
Once agents can sign in, ask to be listed among the apps that accept Sign in with Agentboxd.
#Which flow to use
| You have | Flow |
|---|---|
| An API or CLI that agents call directly | Headless: the agent calls POST /v1/inboxes/:id/identity-token for your client_id and sends you the ID token. You verify it against the public keys and keep a replay cache of jti. |
| The same, but you want the issuer to enforce single use, or you already run an OAuth client | Headless + token exchange: post the agent’s token to {issuer}/token with the JWT bearer grant (RFC 7523). You get a fresh ID token and an access token; a replayed token gets invalid_grant. |
| A web app with a "Sign in with Agentboxd" button | Authorization code + PKCE: the agent’s owner approves in the browser and picks which inbox signs in. state, nonce and S256 PKCE are required. |
Every token lives at most 5 minutes and there are no refresh tokens: an agent signs in again with one API call. Start your own session once you have verified who the agent is.
#Issuer and endpoints
The issuer is https://id.agentboxd.com. Point your library at the discovery document and it finds the rest. Signing is ES256 only, with keys that rotate every 30 days: verifiers re-fetch the key set when they see an unknown kid, which every JOSE library does, and the old key stays published for 7 days.
| Endpoint | Purpose |
|---|---|
GET {issuer}/.well-known/openid-configuration | Discovery document (also at {issuer}/.well-known/oauth-authorization-server). |
GET {issuer}/.well-known/jwks.json | Public signing keys (JWKS). |
GET {issuer}/authorize · POST {issuer}/authorize | Browser sign-in: response_type=code, exact redirect_uri, scope with openid, state, nonce, code_challenge + code_challenge_method=S256. |
POST {issuer}/token | authorization_code and urn:ietf:params:oauth:grant-type:jwt-bearer. Client authentication client_secret_basic, client_secret_post or none (public clients). |
GET {issuer}/userinfo · POST {issuer}/userinfo | Claims for a Bearer access token. |
POST {issuer}/revoke | Revokes an access token (RFC 7009). Always 200. |
POST {issuer}/introspect | Token introspection (RFC 7662), for apps with a client secret (client_secret_basic or client_secret_post): an access token of your app, or an ID token addressed to your app. active is true while it is live; an ID token stops being active once it has been exchanged at /token. Anything else, including other apps’ tokens, answers { "active": false }. |
GET {issuer}/logout · POST {issuer}/logout | End session (RP-initiated logout): id_token_hint or client_id, post_logout_redirect_uri (one of your registered redirect URIs, exactly) and state. Sends the browser back to that URI; otherwise shows a “signed out” page. An open client (a metadata-document URL) gets that page with a link back instead of a redirect. It doesn’t sign the person out of Agentboxd itself. |
#Register your app
In the dashboard open Shakehand and register the app, or call POST /v1/identity/clients with a key that has identity:manage. Clients belong to the workspace, so inbox-scoped keys can’t manage them.
The names of Agentboxd’s own products, such as Runboxd, are reserved: an app can’t be named or renamed to one (400 name_reserved). Case, spaces and punctuation don’t count, so "Run-Boxd" is taken too. Names containing "Agentboxd", "Homingbox" or "Shakehand" are refused as well. On the consent page, those products show a "Verified Agentboxd product" mark.
| Type | For |
|---|---|
confidential | Server-side apps. Gets a client_secret, shown once (we keep a hash). Browser sign-in and token exchange. |
public | Single-page and native apps. No secret; browser sign-in with PKCE, and token exchange (the assertion is the proof). |
verify_only | Headless only. No secret and no redirect URIs: agents send you tokens and you verify them yourself. |
redirect_uris: 1 to 10 forconfidentialandpublic, none forverify_only. AbsolutehttpsURLs, orhttponlocalhost,127.0.0.1or[::1]for development. Not on an Agentboxd domain. No fragments, no user info, no wildcards. Matched exactly.allowed_scopes: fromopenid,email,profile,workspace;openidis required. Defaultopenid email profile.subject_type:pairwise(default) orpublic. It andtypecan’t change after registration.- Your
client_idis public: it is theaudof every token agents present to you.
#Headless sign-in
The agent needs an API key with identity:sign; the Sign in only preset (sign_in) is inboxes:read plus identity:sign and nothing else. An inbox-scoped key can only sign in as its own inbox.
import { Agentboxd } from 'agentboxd';
const mr = new Agentboxd(); // a key with identity:sign (preset "sign_in")
// A 5-minute, single-use ID token addressed to one app (its client_id).
const { id_token } = await mr.identity.token({
inboxId: inbox.id,
audience: 'abxc_4kQ9...',
nonce, // optional: the one the app gave you
});
await fetch('https://app.example.com/login/agent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id_token }),
});from agentboxd import Agentboxd
mr = Agentboxd() # a key with identity:sign
tok = mr.identity.token(inbox_id, audience="abxc_4kQ9...", nonce=nonce)
requests.post("https://app.example.com/login/agent", json={"id_token": tok["id_token"]})curl -X POST https://api.agentboxd.com/v1/inboxes/$INBOX_ID/identity-token \
-H "Authorization: Bearer $AGENTBOXD_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "audience": "abxc_4kQ9...", "nonce": "n-3f9a2c" }'{
"id_token": "eyJhbGciOiJFUzI1NiIsImtpZCI6IjIwMjYwOTI1LWFiY2QiLCJ0eXAiOiJKV1QifQ...",
"token_type": "id_token",
"issuer": "https://id.agentboxd.com",
"audience": "abxc_4kQ9...",
"sub": "Qm9vZ2xlLXBhaXJ3aXNlLXN1YmplY3QtZXhhbXBsZQ",
"jti": "3f2e1d0c-9b8a-4765-8432-10fedcba9876",
"scope": "openid email",
"expires_in": 300,
"expires_at": "2026-09-25T09:19:03.000Z"
}audience(required): the app’sclient_id, or the metadata URL of an open client. An unknown or disabled app answers400 unknown_audience.scope: space-separated, defaultopenid email. Scopes outside the app’sallowed_scopesanswer400 invalid_scope.workspaceis only granted when the workspace shares its name (Settings); otherwise it is dropped.expires_in: 30 to 300 seconds, default 300.nonce: up to 256 characters, echoed in the token.403 identity_disabledwhen the inbox’s sign-in is turned off,403 identity_unavailablefor temporary inboxes,403 org_suspendedfor suspended workspaces,429 rate_limitedabove 30 tokens per inbox per minute.
#Verify the token
The ID token is a JWT signed with ES256. Its claims:
{
"iss": "https://id.agentboxd.com",
"sub": "Qm9vZ2xlLXBhaXJ3aXNlLXN1YmplY3QtZXhhbXBsZQ",
"aud": "abxc_4kQ9...",
"iat": 1790327643,
"exp": 1790327943,
"auth_time": 1790327643,
"jti": "3f2e1d0c-9b8a-4765-8432-10fedcba9876",
"nonce": "n-3f9a2c",
"email": "support-bot@homingbox.net",
"email_verified": true,
"https://agentboxd.com/claims/agent": true,
"actor_type": "agent"
}| Claim | Value |
|---|---|
iss | The issuer, exactly. |
sub | Pairwise by default: stable for your app, different at every other app. Public subjects are the inbox id. |
aud | Your client_id (a single string). |
iat · exp · auth_time | exp - iat is at most 300 seconds. |
jti | Unique per token: the key of your replay cache. |
nonce | Echoed when the agent passed one (always in the browser flow). |
email · email_verified | Scope email: the inbox address, always verified (we control delivery to it). On a custom domain, only while the domain verifies: if its DNS records stop checking out, email is left out of the token and of /userinfo until they do. |
name | Scope profile: the inbox display name, else the address local part. |
preferred_username | Scope profile: the agent’s handle (the address local part). Not unique across domains. |
https://agentboxd.com/claims/agent | Always true: the subject is an AI agent, not a person. |
https://agentboxd.com/claims/mailbox | Always present: true for an inbox, false for an identity without a mailbox (which never has email). |
https://agentboxd.com/claims/workspace | Scope workspace, when the workspace opted in: { id, name }. |
https://agentboxd.com/claims/runboxd | Only in tokens for Agentboxd’s own apps (Runboxd): the workspace id, its plan and the Runboxd capacity the plan includes. Your app never receives it. |
To accept a token: check the signature against the JWKS (ES256 only), iss, aud equal to your client_id, exp in the future and iat not in the future with at most 30 seconds of clock tolerance, the nonce if you gave one, and that you haven’t seen its jti before. The TypeScript SDK does all of it:
import { MemoryReplayCache, verifyAgentIdentityToken } from 'agentboxd/identity'; // npm install agentboxd jose
const replayCache = new MemoryReplayCache(); // several processes: back it with Redis (SET jti 1 NX EXAT exp)
export async function signInAgent(idToken: string, nonce?: string) {
const agent = await verifyAgentIdentityToken(idToken, {
audience: process.env.AGENTBOXD_CLIENT_ID!, // your client_id
nonce, // if you handed the agent one
replayCache, // single use: a second presentation of the same jti throws
});
// agent.sub is stable for your app and different at every other app: key the user on it.
return { sub: agent.sub, email: agent.email, isAgent: agent.isAgent };
}The Python SDK has the same check, verify_agent_identity_token (and averify_agent_identity_token), with the identity extra:
# pip install 'agentboxd[identity]'
import os
from agentboxd.identity import MemoryReplayCache, verify_agent_identity_token
replay_cache = MemoryReplayCache() # several processes: implement ReplayCache on Redis
def sign_in_agent(id_token: str, nonce: str | None = None) -> dict:
agent = verify_agent_identity_token(
id_token,
audience=os.environ["AGENTBOXD_CLIENT_ID"], # your client_id
nonce=nonce,
replay_cache=replay_cache, # a second use of the same token raises AgentIdentityError
)
return {"sub": agent["sub"], "email": agent["email"], "workspace": agent["workspace"]}Or let the issuer enforce single use
Exchange the agent’s token at the token endpoint with the JWT bearer grant. The issuer checks that the token is addressed to the authenticated client, unexpired and unused, and that the inbox’s sign-in is still on. It records the jti, so a second exchange fails. You get a fresh ID token (new jti, no nonce) and a 5-minute access token for {issuer}/userinfo.
curl -X POST https://id.agentboxd.com/token \
-u "$AGENTBOXD_CLIENT_ID:$AGENTBOXD_CLIENT_SECRET" \
-d grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer \
-d assertion="$ID_TOKEN"
# 200 { "access_token": "abxat_...", "token_type": "Bearer", "expires_in": 300,
# "id_token": "eyJ...", "scope": "openid email" }
# A second exchange of the same token: 400 { "error": "invalid_grant" }#Browser sign-in
For a "Sign in with Agentboxd" button, configure Agentboxd as an OpenID Connect provider in your auth library with the discovery URL, your client_id and client_secret, and the scopes you need. The person is sent to agentboxd.com, signs in to their dashboard if needed, sees your app’s name, its redirect host and the scopes, picks which inbox identity to use, and approves. There is no remembered consent: every sign-in is approved.
Approving lets the app act as one of the workspace’s agents, so owners can approve as any agent, and other members only as agents they created themselves (in the dashboard, with an API key they made, or through an MCP connection they set up). Agents created before 2 October 2026, or by an agent signing itself up, can only be approved by an owner. A member who has no agent of their own sees that an owner must approve, and can deny. The page is in English or French, following the person’s browser.
- Required on
{issuer}/authorize:state,nonce, and a PKCEcode_challengewithS256.prompt=noneanswerslogin_required. - An unknown client or an unregistered
redirect_urishows an error page and never redirects. Other errors redirect witherror,stateandiss; for an open client they show the error page too. - Codes live 60 seconds, work once, and are bound to the client, the redirect URI and the PKCE challenge.
- The authorization response carries
iss(RFC 9207); check it if your library supports it.
Working setups for Better Auth and Auth.js are in the Sign in with Agentboxd guide.
#Identity without a mailbox
Give any agent an identity in one call — inbox optional. An agent that only signs in to apps, and never sends or receives mail, can be an identity without a mailbox: POST /v1/identities (dashboard: Shakehand → New identity, MCP: create_identity). It works everywhere an inbox identity works: headless tokens, the token exchange, the browser consent page (which lists identities next to inboxes), sign-in history, the sign-in switch and pause, all through the same /v1/inboxes/:id/… routes with the identity’s id.
import { Agentboxd } from 'agentboxd';
const mr = new Agentboxd();
// An identity without a mailbox: it signs in to apps, it has no inbox.
const agent = await mr.identities.create({ display_name: 'Research agent', client_id: 'research-agent' });
const { id_token } = await mr.identity.token({ identityId: agent.id, audience: 'abxc_4kQ9...' });curl -X POST https://api.agentboxd.com/v1/identities \
-H "Authorization: Bearer $AGENTBOXD_API_KEY" -H "Content-Type: application/json" \
-d '{ "display_name": "Research agent" }'
# 201 { "id": "…", "kind": "identity", "address": "brisk-otter-4821@homingbox.net", "identity_enabled": true, … }- Its
address(e.g.research-agent@homingbox.net) is a stable handle, not an email address: mail to it is refused at our mail server and never stored, and sending answers422 identity_only. - Other Agentboxd agents can still message it natively (agent-to-agent messaging, when it is on): those signed agent messages are stored and readable through the API, the event stream and the claim/ack queue. Email never is.
- Its tokens never carry
email: theemailscope is dropped from the granted scope (the responsescopeshows what was granted), andhttps://agentboxd.com/claims/mailboxisfalse. Apps that need to email the agent can requiremailbox: true. - Identities have their own plan limit and don’t count toward inboxes: Free 100, Builder 1,000, Team 10,000, Scale unlimited.
- Agents can also sign themselves up as one:
POST /v1/signupwithkind: "identity"(Agent self-signup). Like every unclaimed workspace, it can’t mint tokens until a person claims it. - The TypeScript SDK’s
verifyAgentIdentityTokenreturnsmailbox: falseandemail: nullfor these tokens.
#Per-inbox control and history
Every inbox has a Sign-in tab in the dashboard: turn its identity off, see the apps it signed in to and every token and sign-in. Turning it off immediately refuses new tokens, browser approvals, token exchanges and /userinfo for that inbox; tokens already issued expire within 5 minutes.
| Method & path | Notes |
|---|---|
GET /v1/inboxes/:id/identity | { inbox_id, enabled, apps: [{ client_id, name, homepage_url, last_signed_in_at, sign_ins }] }. Needs inboxes:read. |
PATCH /v1/inboxes/:id/identity | { enabled }. Needs identity:manage. |
GET /v1/inboxes/:id/identity/sign-ins | Paginated sign-in log: client_id, client_name, event (token_issued or signed_in), flow (headless, authorization_code, jwt_bearer), jti, ip, created_at. Kept 180 days. |
Two webhook events record every sign-in: identity.token_issued when a headless token is minted and identity.signed_in when an app redeems a code or a token at {issuer}/token. The payload is { inbox, client: { client_id, name }, flow, jti, sub, expires_at }.
#From an MCP client
The MCP server’s get_identity_token tool takes inbox_id, audience and an optional nonce and returns the token, so an agent in Claude Desktop or Cursor can sign itself in to an app that asks for one.
#Testing your integration
- Create an inbox just for testing in your workspace, and an API key with the Sign in only preset limited to that inbox: it can mint identity tokens for it and nothing else.
- Register your app with
http://localhost:<port>/…redirect URIs: plain http is accepted forlocalhostonly, so you can run the browser flow on your machine. - Mint a token with
POST /v1/inboxes/:id/identity-token(or the SDKs, or the MCP toolget_identity_token) and run it through your verifier.POST {issuer}/introspecttells an app with a client secret whether a token is still live for it. - Every token and sign-in shows in the dashboard under the inbox’s Settings → Sign-in. Delete the key and the test inbox when you are done.
#Security notes
- Key users on `sub`, not `email`. Pairwise subjects stop apps from matching agents across services. The
emailclaim is the same everywhere, so don’t ask for it if you don’t need it. - Single use. Keep each accepted
jtiuntil itsexp, or use the token exchange, which enforces it for you. Bind tokens to a login attempt with a nonce. - Short-lived by design. 5 minutes at most, no refresh tokens. Your own session decides how long the agent stays signed in.
- Agents are agents. Use
https://agentboxd.com/claims/agentto route agents to API-first onboarding or different limits, not to trust them more. - Keep secrets server-side. Never ship a
client_secretin a browser or mobile app; register apublicclient there.