Principals
The actor identity behind every Bubble request — kinds, ids, profiles, the `me` alias, and bot-managed local tokens.
docs/concepts/40-principals.mdxOn 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:
- The
Principalshape and the three kinds Bubble understands. - The
mealias used on principal-scoped routes. - How a principal becomes a durable row (registration), at the conceptual level. The detailed bootstrap walkthrough lives in Bootstrapping.
- Local bot tokens — the bearer credentials Bubble itself issues for
botprincipals.
The Principal Shape
A principal is a tiny, plain object:
interface Principal {
principalId: string;
kind: 'user' | 'bot' | 'system';
displayName?: string;
email?: string;
avatarUrl?: string;
discoverable?: boolean;
openToInvites?: boolean;
}| Field | Meaning |
|---|---|
principalId | Stable, opaque identifier. Chosen by the embedding system for users/system principals; chosen by the caller when explicitly creating a bot. |
kind | Which kind of actor this is — see below. |
displayName | Optional human-readable label. Consumers fall back to principalId when it is absent. |
email | Optional contact email from the upstream identity provider. |
avatarUrl | Optional profile image URL. Auth0 deployments commonly map this from the picture claim emitted by examples/auth0-post-login-action.js. |
discoverable | Substring-search visibility. Default true (omitted on the wire). When false, only callers already adjacent to the principal — or exact-id / exact-email queries — match. |
openToInvites | Invite 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:
discoverable | openToInvites | Meaning |
|---|---|---|
true | true | Fully public — substring-search finds them and any thread grants-manager can drag them in. The historical default. |
true | false | Findable but selective — friend-request UX: people can find them, but a third party can't drag them into threads. |
false | true | Unlisted but open — handle-only lookup; whoever already knows the id or email can find them and invite them. |
false | false | Maximum 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.readcapability and thePublicPrincipalProfileprojection. 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
| Kind | Typical actor | How it gets created |
|---|---|---|
user | A human, identified by an upstream IdP (Auth0, etc.) | Silently registered on the first authenticated request |
bot | An autonomous agent that uses Bubble-issued local tokens | Created explicitly via POST /v1/principals |
system | The 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:
export const BUBBLE_SELF_PRINCIPAL_ID = 'me';
You can use it on profile lookups, participant lookups, grant queries, and grant mutations. Two examples:
# 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:
systemis registered from config at server start, before any request is served. Each entry inprincipals.seedbecomes a durable row; each row also receivesowneron its own principal target.useris registered silently the first time a non-bot authentication succeeds for a principal id the store does not yet know. The new row receivesowneron its own principal target.botis registered explicitly, by an authorized caller, viaPOST /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
principalIdit belongs to, and the local-token authenticator populatesBubbleAuthContext.principalIdfrom 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:
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):
{
"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:
# 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:
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:
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.