HTTP protocol
The REST surface — every route, what it returns, and the capability checks each one performs.
docs/reference/10-http-protocol.mdxOn this page
Bubble's HTTP transport is for request/response operations: introspecting the calling identity, creating principals/threads/tokens, inspecting metadata, mutating grants, paging persisted history, and appending one event with a synchronous response. Live event delivery is intentionally not part of HTTP — clients that need a push stream use the WebSocket protocol.
This page assumes you are familiar with principals, authentication, and authorization. It documents the REST surface in terms of those primitives.
Conventions
A few rules apply to every route:
-
Authentication.
Authorization: Bearer <token>on every request, exceptGET /healthzandGET /v1/auth/authenticators. The dispatcher returnsunauthenticated(401) when no driver accepts the credentials. -
Versioning. Routes are mounted under
/v1/...(BUBBLE_HTTP_VERSION = '1'). The version increments only on breaking changes to routes or frame shapes; the event payload version is independent. -
The
mealias. Any path or body parameter that takes aprincipalIdaccepts the literalme, which the server resolves to the calling principal. -
Errors. Every non-2xx response carries
{ code: BubbleErrorCode, message: string }. Status codes follow the standard mapping:Code HTTP unauthenticated401 forbidden403 not_found404 invalid_request400 conflict409 rate_limited429 unavailable503 internal500 Several read routes deliberately collapse "permission denied" into
not_foundso a non-reader cannot distinguish "the thread does not exist" from "you cannot see it."
Health and Identity
GET /healthz
Liveness probe. No authentication required, no version prefix.
curl -s http://localhost:3000/healthz
# => {"ok":true}GET /v1/auth/authenticators
Returns public metadata for the configured authenticator stack. No authentication is required, so login screens can call it before they have a bearer token and decide which frontend login flow applies. Secret config fields such as master keys are never returned.
curl -s http://localhost:3000/v1/auth/authenticators
Response:
{
"authenticators": [
{
"driver": "auth0",
"interactive": true,
"issuer": "https://example.us.auth0.com/",
"authorizationUrl": "https://example.us.auth0.com/authorize",
"audience": "https://bubble.example.com"
},
{
"driver": "entra",
"interactive": true,
"issuer": "https://login.microsoftonline.com/<tenant-id>/v2.0",
"authorizationUrl": "https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/authorize",
"audience": "<client-id>",
"clientId": "<client-id>",
"scopes": ["api://<client-id>/access_as_user"]
},
{
"driver": "local-token",
"interactive": false,
"tokenPrefix": "bubble_agent_"
}
]
}clientId and scopes are what a browser needs to start an
interactive login itself. Both are public values — the client id is an
identifier, never a secret — and serving them here is what lets a web
client configure its OAuth library from the server it is already
talking to, instead of baking one deployment's identity provider into
its build.
GET /v1/auth/whoami
Returns the principal identity the dispatcher resolved for the bearer token, plus every grant that principal currently holds. Useful for clients that want to confirm their effective identity (for example after rotating credentials) and to render UI conditionally.
curl -s http://localhost:3000/v1/auth/whoami \ -H "Authorization: Bearer $TOKEN"
Response:
{
"principal": {
"principalId": "user-alice",
"kind": "user",
"displayName": "Alice",
"email": "alice@example.com",
"avatarUrl": "https://example.com/alice.png"
},
"grants": [
{ "target": { "type": "system" }, "role": "owner" },
{ "target": { "type": "principal", "principalId": "user-alice" }, "role": "owner" }
]
}Authorization decisions still go through the policy on each request.
whoami.grantsis for UI hints — do not gate behaviour on it client-side beyond cosmetics.
Principals
POST /v1/principals
Creates an explicit bot principal and returns it together with the
one-time plaintext of its first local bearer token. See
Bootstrapping → Bot Creation
for the end-to-end picture.
| Property | Value |
|---|---|
| Body | CreatePrincipalBody (kind must be "bot") |
| Response | CreatePrincipalResponse (principal + plaintext token) |
| Authorization | principal.create on principalTarget(callerId) |
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",
"avatarUrl": "https://example.com/research-agent.png",
"ownerPrincipalIds": ["user-bob"],
"tokenExpiresAt": "2026-12-01T00:00:00Z"
}'Validation: principalId must be a non-empty literal and not the
reserved me; the bot's own id may not appear in ownerPrincipalIds
(invalid_request); tokenExpiresAt, when present, must be RFC 3339.
GET /v1/principals/:principalId
Returns the global profile. Self lookups (or principalId = me) are
always allowed; non-self lookups require principal.read on the
principal target. New principals receive an explicit self grant when
created, so self-access goes through the same policy path.
not_found (404) when the principal does not exist.
PATCH /v1/principals/:principalId
Updates the mutable fields of a principal's profile (displayName,
email, and avatarUrl). Body is UpdatePrincipalBody:
{ "displayName": "Research Bot v2" } // overwrite
{ "displayName": null } // clear
{ "avatarUrl": "https://example.com/research-agent-v2.png" }| Authorization | Self: none. Non-self: principal.manage on the principal target |
GET /v1/principals/:principalId/tokens
Lists local-token metadata for the principal (no plaintext is ever
returned). Bot principals only — calling on a non-bot principal
returns invalid_request.
| Authorization | principal.manage on the principal target |
POST /v1/principals/:principalId/tokens
Mints another local bearer token for rotation. Body is optional
(CreatePrincipalTokenBody):
{ "expiresAt": "2026-06-01T00:00:00Z" }Response carries the new token's metadata plus the one-time plaintext. Bot principals only.
| Authorization | principal.manage on the principal target |
DELETE /v1/principals/:principalId/tokens/:tokenId
Revokes one local bearer token. Idempotent — revoking a token that no
longer exists succeeds. Bot principals only. Returns 204.
| Authorization | principal.manage on the principal target |
Threads
POST /v1/threads
Creates an empty thread. Top-level threads need grant-management authority on the new thread target (typically inherited from a system owner grant); subthreads need it on the parent thread:
# Top-level
curl -X POST http://localhost:3000/v1/threads \
-H "Authorization: Bearer $TOKEN" \
-d '{"threadId": "thread-1"}'
# Subthread
curl -X POST http://localhost:3000/v1/threads \
-H "Authorization: Bearer $TOKEN" \
-d '{"threadId": "thread-1/sub-a", "parentThreadId": "thread-1"}'| Authorization | Top-level: grants.manage on threadTarget(threadId). Subthread: thread.create.child on threadTarget(parentThreadId) (held by member, thread_manager, and owner). Per-participant grant authority is checked against the parent thread for subthreads, so a plain member may seed only themselves as member. |
GET /v1/threads/:threadId
Returns the BubbleThread
snapshot. Denial maps to not_found to avoid leaking thread existence.
| Authorization | thread.read on the thread target |
GET /v1/threads/:threadId/participants
Lists the participant
rows of the thread. Returns the same rows embedded in the
getThread snapshot, in a dedicated collection so paginated /
large participant sets can evolve independently.
| Authorization | thread.read and participants.list on the thread target |
GET /v1/threads/:threadId/participants/:principalId
Returns one participant row. not_found (404) when the principal is
not a member of the thread. :principalId accepts me.
| Authorization | thread.read and participants.get on the thread target |
Events
GET /v1/threads/:threadId/events
Returns one contiguous page of the thread's persisted history. This
is not a subscribe endpoint — clients that want to stay current
over HTTP poll with fromSeq set to the previous response's
nextSeq. For a live push stream, use
WebSocket thread.subscribe.
Query (GetEventPageQuery):
| Field | Default |
|---|---|
fromSeq | 1 |
toSeq | latest event known to the server |
limit | server cap (in-memory store: 100) |
Response (GetEventPageResponse):
{
"events": [
/* BubbleSequencedEvent[] in ascending seq */
],
"nextSeq": 51 // null when no more pages
}| Authorization | thread.read and events.read on the thread target |
GET /v1/threads/:threadId/capabilities
Returns the current thread-wide capability catalog without scanning event
history. Commands, tools, and skills are merged into provider-owned arrays;
providerVersions records each provider's latest advertisement sequence,
including empty withdrawals, so a delayed snapshot cannot replace newer live
state.
{
"capabilities": {
"commands": [
{
"name": "model",
"description": "Switch model",
"providerPrincipalId": "agent-claude"
}
],
"tools": [
{
"name": "readParagraphs",
"description": "Read paragraphs from the Word document.",
"inputSchema": { "type": "object", "properties": {} },
"annotations": { "readOnlyHint": true },
"providerPrincipalId": "office"
}
],
"skills": [],
"providerVersions": { "agent-claude": 42, "office": 57 }
}
}A tool's optional annotations.readOnlyHint is its provider's statement that a
call changes nothing. Agents may then run it without asking a human; a tool
without the hint may write. The hint carries whatever authority its advertiser
has.
The TypeScript client exposes this as thread(threadId).capabilities.list().
| Authorization | thread.read and events.read on the thread target |
POST /v1/threads/:threadId/events
Appends one event and returns it with its server-assigned sequencing
metadata. Live WebSocket subscribers to the same thread receive the
event as a thread.event frame after the response is produced. Use
this when the assigned seq is needed before continuing — the WS
thread.append frame is fire-and-forget.
curl -X POST http://localhost:3000/v1/threads/thread-1/events \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"type":"run_started","principalId":"user-alice","runId":"run-user-001","kind":"user_input"}'| Authorization | The events.* capability matching the body's type (e.g. events.run_started, events.message_part, events.tool_call_approval) on the thread target. Server-emitted shapes (participant_joined, participant_left, thread_title_updated) are rejected. |
Grants
The grant collection lives at a single /v1/grants endpoint. The
target is carried in the request body for PUT/DELETE and in
targetType / targetId query parameters for GET.
GET /v1/grants
Lists grants. Exactly one of two query modes is required — supplying
both, or neither, fails with invalid_request:
- By target.
?targetType=...&targetId=...— every row on the givenAuthzTarget. OmittargetIdonly whentargetType=system. - By principal.
?principalId=...— every row held by that principal across every target. Acceptsme.
# Every grant on thread-1. curl "http://localhost:3000/v1/grants?targetType=thread&targetId=thread-1" \ -H "Authorization: Bearer $TOKEN" # Every grant alice holds. curl "http://localhost:3000/v1/grants?principalId=user-alice" \ -H "Authorization: Bearer $TOKEN"
| Authorization | By target: grants.read on the requested target. By principal: self always allowed; non-self requires grants.read on principalTarget(principalId). |
PUT /v1/grants
Idempotently grants body.role to body.principalId on
body.target. Returns the resulting Grant row (whether newly
created or replacing a previous role).
# Owner adds bob as a thread member.
curl -X PUT http://localhost:3000/v1/grants \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"target": {"type": "thread", "threadId": "thread-1"},
"principalId": "user-bob",
"role": "member"
}'
# Self-join — caller hands themselves member on a thread.
curl -X PUT http://localhost:3000/v1/grants \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"target": {"type": "thread", "threadId": "thread-1"},
"principalId": "me",
"role": "member"
}'Authorization is decided by canGrantRole:
- Generic case:
grants.manageonbody.target. - Thread self-join branch: caller is granting themselves
memberon a thread target and satisfiesparticipants.manage.selfon the thread.
When the target is a thread and the change crosses the
participant boundary (a participant role newly granted, or a
participant role replaced by a non-participant role), the server
appends a participant_joined / participant_left
event to the thread stream after the grant write succeeds.
DELETE /v1/grants
Idempotently revokes any grant held by body.principalId on
body.target. Returns 204.
curl -X DELETE http://localhost:3000/v1/grants \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"target": {"type": "thread", "threadId": "thread-1"},
"principalId": "user-bob"
}'Authorization is decided by canRevokeGrant:
grants.manageonbody.target(target managers can remove anything from their target), orprincipal.manageonprincipalTarget(principalId)(the principal-manager kill switch — remove this principal from anywhere they appear), or- thread self-leave: target is a thread, caller is revoking their
own grant, and the caller satisfies
participants.manage.selfon the thread.
When a participant role is removed, a participant_left event is
appended to the thread stream.
Quick Reference Table
| Route | Method | Capability requirement |
|---|---|---|
/healthz | GET | none |
/v1/auth/authenticators | GET | none |
/v1/auth/whoami | GET | authentication only |
/v1/principals | POST | principal.create on principalTarget(callerId) |
/v1/principals/:id | GET | self: none; non-self: principal.read on the target |
/v1/principals/:id | PATCH | self: none; non-self: principal.manage on the target |
/v1/principals/:id/tokens | GET | principal.manage on the target |
/v1/principals/:id/tokens | POST | principal.manage on the target |
/v1/principals/:id/tokens/:tokenId | DELETE | principal.manage on the target |
/v1/threads | POST | Top-level: grants.manage on the new thread. Subthread: thread.create.child on the parent (plain members included). |
/v1/threads/:tid | GET | thread.read on the thread |
/v1/threads/:tid/participants | GET | thread.read and participants.list on the thread |
/v1/threads/:tid/participants/:pid | GET | thread.read and participants.get on the thread |
/v1/threads/:tid/events | GET | thread.read and events.read on the thread |
/v1/threads/:tid/capabilities | GET | thread.read and events.read on the thread |
/v1/threads/:tid/events | POST | The events.* capability matching body.type on the thread (e.g. events.message_part for message_part) |
/v1/threads/:tid/cursors/:pid | GET | thread.read and events.read on the thread; self always allowed, non-self requires principal.manage on the target |
/v1/threads/:tid/cursors/:pid | PUT | thread.read and events.read on the thread; self always allowed, non-self requires principal.manage on the target |
/v1/grants?targetType=… | GET | grants.read on the requested target |
/v1/grants?principalId=… | GET | grants.read on principalTarget(principalId); self always allowed |
/v1/grants | PUT | grants.manage on body.target, or thread self-join with participants.manage.self when granting self member |
/v1/grants | DELETE | grants.manage on body.target, or principal.manage on the revoked principal, or thread self-leave |
What's Next
- WebSocket Protocol for live event
delivery and the
thread.appendfire-and-forget alternative toPOST /v1/threads/:threadId/events.