Skip to content

Capabilities

Custos tells clients what it can do. com.atproto.server.describeServer, the public, unauthenticated endpoint every AT Protocol client already calls, carries an extra custos object alongside the standard fields:

{
"did": "did:web:pds.example.com",
"availableUserDomains": ["example.com"],
"inviteCodeRequired": true,
"phoneVerificationRequired": false,
"custos": {
"version": "0.8.1",
"capabilities": ["sovereignSessions", "walletConsent", "walletAccountDelete", "didWebHosting"]
}
}

A client reads capabilities and enables the matching features. Obsign, for example, only offers to create a brand-new identity on a server that advertises createCeremony, and only offers escrow-assisted recovery where escrow is advertised, instead of calling the endpoint and interpreting the failure.

Two properties follow from this being a list of named capabilities rather than an “is this Custos?” flag. First, your deployment is described by what it actually offers: a Custos server running without a master key advertises fewer capabilities, and clients adapt rather than break. Second, a server that sends no custos object at all (the reference PDS, rsky-pds, millipds) simply has none of these, and a well-behaved client falls back to standard AT Protocol behavior. Nothing here is required of anyone; the extension is additive, and clients that don’t know the field ignore it.

The complete, generated list lives in the capability reference. This page explains what each capability means for you and how to switch it on.

GET /xrpc/_health reports a self-identifying version string rather than a bare number:

{ "version": "custos v0.8.1", "db": "ok" }

This is the shape third-party AT Protocol diagnostic tooling reads to fingerprint an implementation (millipds reports millipds v… the same way; the reference PDS returns a bare commit hash that names no software). It is also what Obsign pings when you point it at a server. Nothing in it is sensitive: it names software and version, which any protocol-level probe can already infer.

Each heading below is the literal name that appears in custos.capabilities.

Custos-native identity creation: mobile signup, per-account repo signing key issuance, and the client-authored did:plc genesis ceremony in which the user’s device key is written into the DID’s rotation keys from the very first operation. This is the only way an identity is born already sovereign: the standard createAccount lexicon never lets a client author its own genesis rotation keys.

Without it, a wallet steers users to import an existing identity instead, and the claim ceremony takes sovereign custody of an identity hosted anywhere.

Controlled by: signing_key_master_key. The ceremony issues each account a repo signing key and stores it encrypted under the master key, so a deployment with no EZPDS_SIGNING_KEY_MASTER_KEY cannot perform it and does not advertise it. There is no separate switch: configure a master key (as any real deployment should; see Configuration) and the capability appears.

Custos holds one share of the account’s 2-of-3 Shamir recovery split (encrypted, and useless on its own) and releases it through the recovery gate: an emailed one-time code, then a cancellable delay window during which the account holder is notified and can abort. This is what makes recovery possible for a user who has lost their device but still holds one share.

Without it, the wallet’s recovery story is entirely self-held: the user keeps both remaining shares themselves, and no server is involved in recovery at all.

Controlled by: signing_key_master_key. The stored share envelope is encrypted under the master key, so a deployment without one cannot accept a deposit or perform a release, and does not advertise the capability. The delay window itself is tuned separately with recovery.release_delay_secs (EZPDS_RECOVERY_RELEASE_DELAY_SECS, default 24 hours); that changes how a release behaves, not whether escrow is offered.

Passwordless full-access sessions. Instead of a password, the client signs a fresh, timestamped, nonce-bound proof with a key that is already in the identity’s current PLC rotation keys, and Custos verifies it against plc.directory, never against a cached document. The account holder’s device key is the credential, so an account can have no password at all.

Everything that needs a full-access session rides on this: app passwords, changing a handle, removing an identity, restoring media from a backup.

Controlled by: always. This is inherent to running Custos and has no operator switch. It grants Custos no additional power over an identity: any current rotation key qualifies, and rotation keys are the account holder’s to control.

The auth.md agent surface: an AI agent or automation registers with the server, the account holder confirms the claim from their wallet (reading the exact scopes first), and the agent then exchanges its assertion for narrowly-scoped, revocable, audited access tokens. Agent tokens can never manage agents, including themselves, and every registration is revocable from the wallet or by the identity provider.

Controlled by: agent_auth.service_auth_enabled, agent_auth.anonymous_enabled, agent_auth.trusted_issuers. These are the three ways an agent can register, and all three are off or empty by default: an untouched deployment does not offer agents at all. Enable whichever registration flows you want (EZPDS_AGENT_AUTH_SERVICE_AUTH_ENABLED, EZPDS_AGENT_AUTH_ANONYMOUS_ENABLED, or a configured [[agent_auth.trusted_issuers]] entry) and the capability appears. What a claimed agent may then do is bounded separately by agent_auth.granted_scopes; narrowing it narrows every assertion minted afterwards.

An OAuth authorization can be approved in the identity wallet with a device-key-signed decision, rather than by typing a password into a browser consent page. The signed envelope binds the request, the client, the decision, and the exact scope set, so an approval cannot be replayed onto a different request, flipped from a denial, or widened after the fact.

Controlled by: always. This is inherent to running Custos and has no operator switch. Password-based browser consent continues to work for accounts that have a password; the wallet path is an additional, stronger route to the same grant.

An account can be created on this server with no password at all. A client that sees this capability may omit the password field entirely when creating an account; the account is stored with no password hash and authenticates afterwards through its device key (wallet-confirmed OAuth consent and sovereign sessions), plus app passwords for standard AT Protocol clients like the Bluesky app.

Passwordless accounts are not a new storage shape: accounts that migrate in from another server have always been stored without a password. What this capability changes is only whether a new account may be created that way.

An empty password is refused whether or not this is enabled: an empty string is what an uninitialized form field sends, and accepting one would let a client bug create an account with no credential that nobody asked for. Omitting the field is the only way to ask.

Controlled by: accounts.password_optional. Off by default: a passwordless account depends on device-key custody you cannot verify on the account holder’s behalf, so a deployment whose users are not all running the identity wallet should keep the password required. Enable it with [accounts] password_optional = true (EZPDS_ACCOUNTS_PASSWORD_OPTIONAL) and the capability appears. Turning it back off stops new passwordless accounts being created; it does not affect accounts that already have no password, which keep working as before.

An account can be permanently deleted with a device-key-signed proof from one of its current rotation keys, in place of the account password. The emailed single-use confirmation code is still required: this swaps one of the two factors rather than removing one.

This capability is what makes optionalPassword safe to turn on. Deletion is the one operation that had no route around the password: an account created without one could be made, used, and migrated, but never removed, because every credential path answered with the same refusal and left the holder no way forward. The proof closes that.

The signed envelope binds this server, the account, the signing key, the moment, and a single-use nonce, and it is verified against the identity’s authoritative current rotation set read live from plc.directory, never a cached document. It is accepted for accounts that do have a password too: a current rotation key can already migrate or retire the identity outright, so it is the stronger credential, and honoring only the weaker one would make the endpoint answer differently depending on whether a password exists. Unknown accounts, wrong passwords, and bad proofs remain one indistinguishable response.

Controlled by: always. This is the same key-sovereign trust model as sovereignSessions and has no operator switch; password-based deletion continues to work unchanged for accounts that have a password.

Custos serves an opted-in account’s did:web document at the account’s own domain (/.well-known/did.json) and emits an identity event so relays re-resolve after an edit.

Hosting is opt-in per account, never automatic, and only ever for a domain the user controls: moving the document onto Custos trades away some of the independence a self-hosted document gives an identity, so it is the account holder’s decision. Exit is a DNS repoint away.

Controlled by: always. The server-side capability is inherent to running Custos and has no operator switch; whether any given account uses it is that account holder’s choice.

An app password can be minted with an opt-in personal-details grant (personalDetails on createAppPassword, chosen at mint time like the standard privileged DM flag). Sessions opened with a granted app password can read and set the preference types the reference PDS reserves for full-access sessions — today exactly personalDetailsPref, the birth date age-assurance checks require.

This is a deliberate divergence from the reference PDS (ADR-0033). A passwordless sovereign account has no full-access password login, so an app password is the only credential the official Bluesky app can use — and without this grant that app cannot get past its mandatory birth-date prompt. Ungranted app passwords behave exactly as they do on the reference PDS.

Controlled by: always. The grant is a stored property of each credential with no operator switch; whether any given app password carries it is the account holder’s mint-time choice, and it cannot be added to an existing credential.

A public interest-signup waitlist for a pre-launch deployment: an unauthenticated POST /waitlist accepting an email address plus an optional atproto handle, meant to be posted to directly from a marketing page on another origin (the endpoint is CORS-open and carries no credentials). Signups are idempotent per email (a repeat returns the same success and never discloses whether an address was already on the list), and the handle is syntax-checked but never resolved, so the endpoint has no outbound-request surface. The operator reads the list back with GET /v1/admin/waitlist (newest first, with a total), which keeps working even after the public endpoint is switched off again.

Controlled by: waitlist.enabled. Off by default: an instance that isn’t running a launch funnel should not carry a public write endpoint it never asked for. Enable it with [waitlist] enabled = true (EZPDS_WAITLIST_ENABLED) and the capability appears; the per-IP signup rate cap is rate_limit.waitlist_per_5min (EZPDS_RATE_LIMIT_WAITLIST_PER_5MIN, default 30).

The capability table in the server source (crates/pds/src/capabilities.rs) is the single source of truth: it holds each capability’s wire name, the configuration that gates it, and the predicate the server actually evaluates. The capability reference is generated from it, and a CI gate (just capability-docs-check) fails the build if a capability is added, renamed, or re-gated without this page being updated in the same change. If a “Controlled by” line above disagrees with your server’s behavior, that is a bug, not a documentation lag; please report it.