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.

View as Markdown

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.

https://app.example.com/oauth/client.json
{
  "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:

app.py
# 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 agent

For a "Sign in with Agentboxd" button in a web app, any OpenID Connect library works. The agent’s owner approves in the browser:

app.ts
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, email and profile. The consent screen shows your domain and says the app isn’t registered.
  • Redirect URIs must be https on the same host as the file.
  • Register your app instead (below) when you need a secret, the workspace scope, 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 haveFlow
An API or CLI that agents call directlyHeadless: 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 clientHeadless + 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" buttonAuthorization 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.

EndpointPurpose
GET {issuer}/.well-known/openid-configurationDiscovery document (also at {issuer}/.well-known/oauth-authorization-server).
GET {issuer}/.well-known/jwks.jsonPublic signing keys (JWKS).
GET {issuer}/authorize · POST {issuer}/authorizeBrowser sign-in: response_type=code, exact redirect_uri, scope with openid, state, nonce, code_challenge + code_challenge_method=S256.
POST {issuer}/tokenauthorization_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}/userinfoClaims for a Bearer access token.
POST {issuer}/revokeRevokes an access token (RFC 7009). Always 200.
POST {issuer}/introspectToken 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}/logoutEnd 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.

TypeFor
confidentialServer-side apps. Gets a client_secret, shown once (we keep a hash). Browser sign-in and token exchange.
publicSingle-page and native apps. No secret; browser sign-in with PKCE, and token exchange (the assertion is the proof).
verify_onlyHeadless only. No secret and no redirect URIs: agents send you tokens and you verify them yourself.
  • redirect_uris: 1 to 10 for confidential and public, none for verify_only. Absolute https URLs, or http on localhost, 127.0.0.1 or [::1] for development. Not on an Agentboxd domain. No fragments, no user info, no wildcards. Matched exactly.
  • allowed_scopes: from openid, email, profile, workspace; openid is required. Default openid email profile.
  • subject_type: pairwise (default) or public. It and type can’t change after registration.
  • Your client_id is public: it is the aud of 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.

agent.ts
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 }),
});
agent.py
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
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" }'
201 Created
{
  "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’s client_id, or the metadata URL of an open client. An unknown or disabled app answers 400 unknown_audience.
  • scope: space-separated, default openid email. Scopes outside the app’s allowed_scopes answer 400 invalid_scope. workspace is 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_disabled when the inbox’s sign-in is turned off, 403 identity_unavailable for temporary inboxes, 403 org_suspended for suspended workspaces, 429 rate_limited above 30 tokens per inbox per minute.

#Verify the token

The ID token is a JWT signed with ES256. Its claims:

ID token payload
{
  "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"
}
ClaimValue
issThe issuer, exactly.
subPairwise by default: stable for your app, different at every other app. Public subjects are the inbox id.
audYour client_id (a single string).
iat · exp · auth_timeexp - iat is at most 300 seconds.
jtiUnique per token: the key of your replay cache.
nonceEchoed when the agent passed one (always in the browser flow).
email · email_verifiedScope 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.
nameScope profile: the inbox display name, else the address local part.
preferred_usernameScope profile: the agent’s handle (the address local part). Not unique across domains.
https://agentboxd.com/claims/agentAlways true: the subject is an AI agent, not a person.
https://agentboxd.com/claims/mailboxAlways present: true for an inbox, false for an identity without a mailbox (which never has email).
https://agentboxd.com/claims/workspaceScope workspace, when the workspace opted in: { id, name }.
https://agentboxd.com/claims/runboxdOnly 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:

login.ts
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:

login.py
# 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
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 PKCE code_challenge with S256. prompt=none answers login_required.
  • An unknown client or an unregistered redirect_uri shows an error page and never redirects. Other errors redirect with error, state and iss; 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.

agent.ts
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
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 answers 422 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: the email scope is dropped from the granted scope (the response scope shows what was granted), and https://agentboxd.com/claims/mailbox is false. Apps that need to email the agent can require mailbox: 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/signup with kind: "identity" (Agent self-signup). Like every unclaimed workspace, it can’t mint tokens until a person claims it.
  • The TypeScript SDK’s verifyAgentIdentityToken returns mailbox: false and email: null for 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 & pathNotes
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-insPaginated 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 for localhost only, 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 tool get_identity_token) and run it through your verifier. POST {issuer}/introspect tells 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 email claim is the same everywhere, so don’t ask for it if you don’t need it.
  • Single use. Keep each accepted jti until its exp, 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/agent to route agents to API-first onboarding or different limits, not to trust them more.
  • Keep secrets server-side. Never ship a client_secret in a browser or mobile app; register a public client there.