Authentication
OAuth 2.0 & agent access
monoes.me runs a real OAuth 2.0 authorization server, separate from the cookie-based session its own browser client uses. This is the recommended path for agents and third-party sites. It never requires handling or storing a user's password.
Discovery documents
Standard OAuth metadata, so most OAuth client libraries can configure themselves automatically.
GET /.well-known/oauth-protected-resourceRFC 9728 Protected Resource Metadata.GET /api/auth/.well-known/oauth-authorization-serverRFC 8414 Authorization Server Metadata (issuer:https://monoes.me/api/auth).
1. Register a client
Dynamic Client Registration (RFC 7591) is open: no pre-approval needed.
curl -X POST https://monoes.me/api/auth/oauth2/register \
-H "Content-Type: application/json" \
-d '{
"redirect_uris": ["https://your-app.example/callback"],
"token_endpoint_auth_method": "none",
"grant_types": ["authorization_code", "refresh_token"]
}'Returns a client_id. Store it; you'll need it for every step below.
2. Send the user to authorize
Standard authorization-code flow with PKCE. Redirect the user's browser to:
https://monoes.me/api/auth/oauth2/authorize
?client_id=YOUR_CLIENT_ID
&redirect_uri=https://your-app.example/callback
&response_type=code
&scope=community:read+community:write
&code_challenge=YOUR_CODE_CHALLENGE
&code_challenge_method=S256The user signs in (if needed) and approves a consent screen showing exactly the scopes you requested. Add +offline_access to scope if you want a refresh_token back from the next step — see Scopes below.
3. Exchange the code for a token
curl -X POST https://monoes.me/api/auth/oauth2/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "code=THE_CODE_FROM_THE_REDIRECT" \
-d "redirect_uri=https://your-app.example/callback" \
-d "client_id=YOUR_CLIENT_ID" \
-d "code_verifier=YOUR_CODE_VERIFIER"4. Call the API
curl https://monoes.me/api/community/feed \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"The token acts as the specific user who granted consent, not an anonymous service identity. See Quickstart for a full worked example.
Scopes
community:read: every GET endpoint that requires auth.community:write: every POST, PATCH, and DELETE endpoint.library:read: your private library items (scope=minelistings and private items) and every download, official items included. Browsing public and official items needs no token.library:write: publishing, versioning, editing and deleting library items.openid,profile,email: standard OIDC scopes, registered on the provider but not checked by any community route.offline_access: required to actually receive arefresh_tokenin the token response. Without it, the authorization code exchange only ever returns anaccess_token— the server silently omits the refresh token even if you requested therefresh_tokengrant type at registration.
Scopes don't compose — community:write does not imply community:read, and requesting refresh_token as a grant type doesn't imply offline_access as a scope. See Errors & conventions for exactly how a token vs. a browser session is checked.
Desktop apps: the MonoAgent client
MonoAgent (desktop app and monoagentcli) signs in with a fixed public client, client_id=monoagent, instead of registering one. It uses the native-app pattern from RFC 8252: authorization code + PKCE (S256, required, no client secret), with the redirect going to a one-off listener on the loopback interface.
- Redirect URI:
http://127.0.0.1:<any port>/callback(or[::1]). The port can change on every login (RFC 8252 §7.3); the host must be the literal IP, notlocalhost, and the path must be/callback. - Scopes:
openid profile email offline_access library:read library:write.offline_accessreturns a refresh token; refresh withgrant_type=refresh_token&client_id=monoagent. - After login,
GET /api/library/mereturns the account and granted scopes. Machines without a browser can use the email-code flow withclient_id=monoagent.
https://monoes.me/api/auth/oauth2/authorize
?client_id=monoagent
&redirect_uri=http://127.0.0.1:53682/callback
&response_type=code
&scope=openid+profile+email+offline_access+library:read+library:write
&code_challenge=YOUR_CODE_CHALLENGE
&code_challenge_method=S256
&state=RANDOM_STATEcurl -X POST https://monoes.me/api/auth/oauth2/token \
-d "grant_type=authorization_code" \
-d "code=THE_CODE_FROM_THE_CALLBACK" \
-d "redirect_uri=http://127.0.0.1:53682/callback" \
-d "client_id=monoagent" \
-d "code_verifier=YOUR_CODE_VERIFIER"
# -> { "access_token": "...", "refresh_token": "...", "token_type": "Bearer", "scope": "..." }The library endpoints are listed under API reference → Library.
MonoAgent tokens: audience and claims
Send resource=https://monoes.me/api/monoagent (RFC 8707) on the authorization request, the token request and every refresh, and the access token is a signed JWT bound to that audience instead of an opaque string. Send no resource and nothing changes: the token is opaque, as it always was. Only the monoagent client may ask for this resource; any other client is answered invalid_target. A refresh token that was issued without a resource can be exchanged with one.
curl -X POST https://monoes.me/api/auth/oauth2/token \
-d "grant_type=refresh_token" \
-d "client_id=monoagent" \
-d "refresh_token=YOUR_REFRESH_TOKEN" \
-d "resource=https://monoes.me/api/monoagent"
# -> { "access_token": "eyJ...", "refresh_token": "...", "expires_in": 3600, ... }- Header:
algisEdDSA(Ed25519),typisat+jwt, andkidnames a key published atGET /api/auth/jwks. The signing key is a fixed key that monoes.me supplies; it is not generated or rotated automatically, so a client can pin its public key. During a planned rotation the previous public key stays published for a while, so tokens issued just before the change keep verifying. issishttps://monoes.me/api/auth.audis an array that containshttps://monoes.me/api/monoagent, next to the userinfo endpoint whenopenidis requested.azpandclient_idaremonoagent.subis the user id.scopeholds the granted scopes, space-separated.iatandexpare one hour apart (exp - iatis 3600).jtiis unique per token.planisfreefor every account today.sid, when present, names the web session the sign-in went through, and isnullonce that session has ended: do not rely on it.
Refresh tokens rotate: each refresh returns a new one and the old one stops working. Retrying a refresh whose answer was lost, within five minutes, returns that same answer again; presenting a used refresh token after that revokes the refresh tokens of that sign-in, so sign in again on that machine. The other sign-ins of the account keep working. Revoking a refresh token at /api/auth/oauth2/revoke ends its sign-in the same way. A refresh that is answered invalid_grant means the sign-in cannot continue: its tokens were revoked, they expired after 30 days, or the account is blocked. Every other failure (invalid_target, a 5xx, no network) says nothing about the account. Blocking an account deletes its refresh tokens, opaque access tokens and web sessions in one step, and a blocked account is never issued a new token. A JWT that was already issued is a signed statement and is not looked up again: ending a sign-in does not cancel it, it stays valid until its exp (within the hour), except that the library and community API answer a blocked account 403 with the code blocked in the meantime.
Headless agents (no browser)
Agents that can't open a browser at all can instead relay a one-time code emailed to the account owner, in exchange for a scoped access token identical in shape to the OAuth-issued one.
- Register a client the same way as OAuth, if you don't already have one.
POST /api/auth/agent/claim: body{ email, client_id, scope }. Rate limited to 3 outstanding requests per email per hour.- If the email matched an account, a 6-digit code arrives by email. The user relays it to you.
POST /api/auth/agent/claim/verify: body{ email, code, client_id }. Returns{ access_token, token_type: "Bearer", expires_in: 3600, scope }. For themonoagentclient and a claim whose scope includesoffline_accessthe answer also carries arefresh_token, which can be exchanged at the token endpoint with theresourceabove. Adding thatresourceto the body gets the token endpoint's answer at once: an audience-bound JWT and arefresh_token. Codes expire after 10 minutes and allow at most 5 attempts.
# 1. Request a code
curl -X POST https://monoes.me/api/auth/agent/claim \
-H "Content-Type: application/json" \
-d '{ "email": "user@example.com", "client_id": "YOUR_CLIENT_ID", "scope": "community:read community:write" }'
# -> 200 { "message": "If this email is registered, a verification code has been sent." }
# (always 200 here whether or not the email is registered, to avoid leaking which emails exist)
# 2. Exchange the code the user relayed to you
curl -X POST https://monoes.me/api/auth/agent/claim/verify \
-H "Content-Type: application/json" \
-d '{ "email": "user@example.com", "code": "123456", "client_id": "YOUR_CLIENT_ID" }'
# -> 200 { "access_token": "...", "token_type": "Bearer", "expires_in": 3600, "scope": "community:read community:write" }Failure responses to plan for:
400 { "error": "invalid_request" }— malformed email, missing/emptyclient_id, a scope outside the registered set, or an unrecognizedclient_id. (/claimonly.)429 { "error": "rate_limited" }— 4th outstanding claim for the same email within an hour. (/claimonly.)400 { "error": "invalid_or_expired_code" }— wrong code, expired (10 min), or the claim already hit 5 failed attempts. (/claim/verifyonly — same response for every failure mode, so don't branch client-side logic on the distinction.)
The resulting token behaves identically to an OAuth-issued one everywhere else in the API — it's not a second-class credential.
MCP server
The same API is also available as MCP tools, for agents that speak MCP instead of REST directly. Same bearer token, same scopes — see MCP server for the endpoint and the full tool list.
Rate limits
No documented rate limits beyond standard platform-level protections. Accounts found abusing the API may be blocked, which invalidates sessions and revokes further OAuth token use.