Principals

The actor identity behind every Bubble request — kinds, ids, profiles, the `me` alias, and bot-managed local tokens.

Conceptsdocs/concepts/40-principals.mdx
On this page

A principal is the global identity of an actor — the only thing that can authenticate to Bubble and the entity every authorization decision ultimately attaches to. Principals are independent of any specific thread or event; a thread links principals to its event stream through the participant projection covered in Authorization.

This page covers four things:

  1. The Principal shape and the three kinds Bubble understands.
  2. The me alias used on principal-scoped routes.
  3. How a principal becomes a durable row (registration), at the conceptual level. The detailed bootstrap walkthrough lives in Bootstrapping.
  4. Local bot tokens — the bearer credentials Bubble itself issues for bot principals.

The Principal Shape

A principal is a tiny, plain object:

ts
interface Principal {
  principalId: string;
  kind: 'user' | 'bot' | 'system';
  displayName?: string;
  email?: string;
  avatarUrl?: string;
  discoverable?: boolean;
  openToInvites?: boolean;
}
FieldMeaning
principalIdStable, opaque identifier. Chosen by the embedding system for users/system principals; chosen by the caller when explicitly creating a bot.
kindWhich kind of actor this is — see below.
displayNameOptional human-readable label. Consumers fall back to principalId when it is absent.
emailOptional contact email from the upstream identity provider.
avatarUrlOptional profile image URL. Auth0 deployments commonly map this from the picture claim emitted by examples/auth0-post-login-action.js.
discoverableSubstring-search visibility. Default true (omitted on the wire). When false, only callers already adjacent to the principal — or exact-id / exact-email queries — match.
openToInvitesInvite gate. Default true (omitted on the wire). When false, third parties cannot drag this principal into a thread without holding principal.invite on their target.

Bubble itself does not enforce policy on kind; it is carried so that UIs and authorization layers can render and reason about the actor without a separate lookup. The kind does, however, change how a principal becomes a row in the store, and it changes which roles Bubble grants to that principal on its own principal target — both covered in the registration section below.

Visibility — discoverable and openToInvites

The two visibility booleans are deliberately orthogonal so they can be tuned independently:

discoverableopenToInvitesMeaning
truetrueFully public — substring-search finds them and any thread grants-manager can drag them in. The historical default.
truefalseFindable but selective — friend-request UX: people can find them, but a third party can't drag them into threads.
falsetrueUnlisted but open — handle-only lookup; whoever already knows the id or email can find them and invite them.
falsefalseMaximum privacy — handle-only lookup AND principal.invite is required to add them. The default for bots.

Three things visibility does not change:

  • Reading is unaffected. A caller's view of profile fields is governed by the existing principal.read capability and the PublicPrincipalProfile projection. The flags do not gate the per-row content — only discovery and invite.
  • Privacy isn't retroactive. Flipping a principal from public to private does not remove them from threads they are already in or hide them from co-participants. It only affects future discovery and future third-party invitations.
  • Privacy isn't anonymity. Anyone who knows the handle (id or email) can confirm the principal exists by exact-match lookup — same posture as Signal or Telegram. That's the channel through which a friend request finds you in the first place.

Defaults at registration: user → both true (no change from historical behavior); bot → both false; system → both true. Both flags are mutable via PATCH /v1/principals/:id (gated by principal.manage) or as overrides on POST /v1/principals.

The Three Kinds

KindTypical actorHow it gets created
userA human, identified by an upstream IdP (Auth0, etc.)Silently registered on the first authenticated request
botAn autonomous agent that uses Bubble-issued local tokensCreated explicitly via POST /v1/principals
systemThe platform itself, support tooling, the bootstrap "root"Seeded from config at server start

The "how it gets created" column is summarised in Registration below and detailed in Bootstrapping.

The me Alias

Every route that takes a :principalId path or body parameter accepts the literal me, which the server resolves to the calling principal:

ts
export const BUBBLE_SELF_PRINCIPAL_ID = 'me';

You can use it on profile lookups, participant lookups, grant queries, and grant mutations. Two examples:

bash
# Get my own profile.
curl http://localhost:3000/v1/principals/me \
  -H "Authorization: Bearer $TOKEN"

# Am I a participant of thread t-1?
curl http://localhost:3000/v1/threads/t-1/participants/me \
  -H "Authorization: Bearer $TOKEN"

The alias is purely a transport convenience — the resolved principalId is what flows through to the store and the policy.

Registration

Authentication proves that a request is from some identity. Registration is the moment that identity becomes a durable Principal row. Bubble keeps the two concerns separate so that a misbehaving authenticator cannot accidentally pollute the store with rows for identities that never actually used the system.

The three kinds register through three different paths:

  • system is registered from config at server start, before any request is served. Each entry in principals.seed becomes a durable row; each row also receives owner on its own principal target.
  • user is registered silently the first time a non-bot authentication succeeds for a principal id the store does not yet know. The new row receives owner on its own principal target.
  • bot is registered explicitly, by an authorized caller, via POST /v1/principals. The route returns the principal and the one-time plaintext of its first local bearer token.

The complete sequence, including how the very first registered user becomes the system owner when a system principal is seeded, is detailed in Bootstrapping. For now the only fact you need to carry forward is: users register implicitly, bots register explicitly, system principals come from config.

Bot Principals and Local Tokens

A bot principal is the only kind of identity Bubble itself can issue credentials for. The credentials are local bearer tokens: opaque strings of the form bubble_agent_<base64url> that authenticate exactly one bot principal.

Three guarantees of the local-token system are worth committing to memory before working with the API:

  • The plaintext is returned exactly once. When the route layer creates a token it generates the plaintext, hashes it, hands the hash to the store, and returns the plaintext to the caller in the response body. The plaintext is never persisted; if a caller loses it, the only recovery is to create another token and revoke the lost one.
  • Tokens have a stable prefix. Every local token starts with bubble_agent_ (BUBBLE_LOCAL_TOKEN_PREFIX). The authentication dispatcher uses this prefix to route a request to the local-token driver before considering anything else (see Authentication).
  • Each token authenticates one bot only. The token row carries the principalId it belongs to, and the local-token authenticator populates BubbleAuthContext.principalId from that row. There is no delegation or impersonation in this driver.

Creating a Bot

POST /v1/principals creates the principal, grants the bot itself principal_manager on its own principal target, and creates the first local token in one transaction:

bash
curl -X POST http://localhost:3000/v1/principals \
  -H "Authorization: Bearer $CALLER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "principalId": "bot-research",
        "kind": "bot",
        "displayName": "Research Agent",
        "ownerPrincipalIds": ["user-alice"]
      }'

Response (the only time the plaintext token is ever returned):

json
{
  "principal": {
    "principalId": "bot-research",
    "kind": "bot",
    "displayName": "Research Agent"
  },
  "token": {
    "tokenId": "tk_01HV…",
    "principalId": "bot-research",
    "createdAt": "2026-05-06T12:34:56.000Z",
    "token": "bubble_agent_AbCd…"
  }
}

The caller is always added to ownerPrincipalIds automatically. A bot cannot be listed as one of its own owners — Bubble rejects the request with invalid_request.

Token Rotation

The principal's owners (or anyone with principal.manage on the principal target — see Authorization) can list, mint, and revoke tokens for that principal:

bash
# List existing tokens (metadata only — no plaintext).
curl http://localhost:3000/v1/principals/bot-research/tokens \
  -H "Authorization: Bearer $TOKEN"

# Mint another token; rotate the old one out by deleting it later.
curl -X POST http://localhost:3000/v1/principals/bot-research/tokens \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"expiresAt": "2026-06-01T00:00:00Z"}'

# Revoke an individual token.
curl -X DELETE http://localhost:3000/v1/principals/bot-research/tokens/tk_01HV… \
  -H "Authorization: Bearer $TOKEN"

Why two routes for token mint and one for revoke, but no route for "reveal the plaintext"? The store only keeps the token hash. Mint and revoke are first-class because they are the only safe operations; a "reveal" route would either need to persist the plaintext (defeating the security model) or be inert.

Updating a Bot's Profile

PATCH /v1/principals/:principalId mutates the principal row. The body is a partial patch — only the keys present are touched, and displayName: null clears the field:

bash
curl -X PATCH http://localhost:3000/v1/principals/bot-research \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"displayName": "Research Bot v2"}'

Self-edits are always allowed. Non-self edits require principal.manage on the target's principal target — see Authorization.

Where Participants Fit

A Participant is the per-thread projection of a principal. It is not a separate identity; it is a row that appears on a thread when a principal holds a write-bearing grant on that thread. The shape lives on the thread snapshot:

ts
interface Participant {
  threadId: string;
  principalId: string; // join key back to Principal
  joinedAt: string;
  role: Role;
}

Participants belong to the authorization story because they are derived from grants — see Authorization for the rule that turns a grant into a participant row and for how participant_joined / participant_left events fall out of grant mutations.