HTTP protocol

The REST surface — every route, what it returns, and the capability checks each one performs.

Referencedocs/reference/10-http-protocol.mdx
On 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, except GET /healthz and GET /v1/auth/authenticators. The dispatcher returns unauthenticated (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 me alias. Any path or body parameter that takes a principalId accepts the literal me, which the server resolves to the calling principal.

  • Errors. Every non-2xx response carries { code: BubbleErrorCode, message: string }. Status codes follow the standard mapping:

    CodeHTTP
    unauthenticated401
    forbidden403
    not_found404
    invalid_request400
    conflict409
    rate_limited429
    unavailable503
    internal500

    Several read routes deliberately collapse "permission denied" into not_found so 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.

bash
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.

bash
curl -s http://localhost:3000/v1/auth/authenticators

Response:

json
{
  "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.

bash
curl -s http://localhost:3000/v1/auth/whoami \
  -H "Authorization: Bearer $TOKEN"

Response:

json
{
  "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.grants is 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.

PropertyValue
BodyCreatePrincipalBody (kind must be "bot")
ResponseCreatePrincipalResponse (principal + plaintext token)
Authorizationprincipal.create on principalTarget(callerId)
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",
        "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:

json
{ "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):

json
{ "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:

bash
# 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):

FieldDefault
fromSeq1
toSeqlatest event known to the server
limitserver cap (in-memory store: 100)

Response (GetEventPageResponse):

json
{
  "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.

json
{
  "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.

bash
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 given AuthzTarget. Omit targetId only when targetType=system.
  • By principal. ?principalId=... — every row held by that principal across every target. Accepts me.
bash
# 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).

bash
# 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.manage on body.target.
  • Thread self-join branch: caller is granting themselves member on a thread target and satisfies participants.manage.self on 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.

bash
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.manage on body.target (target managers can remove anything from their target), or
  • principal.manage on principalTarget(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.self on the thread.

When a participant role is removed, a participant_left event is appended to the thread stream.

Quick Reference Table

RouteMethodCapability requirement
/healthzGETnone
/v1/auth/authenticatorsGETnone
/v1/auth/whoamiGETauthentication only
/v1/principalsPOSTprincipal.create on principalTarget(callerId)
/v1/principals/:idGETself: none; non-self: principal.read on the target
/v1/principals/:idPATCHself: none; non-self: principal.manage on the target
/v1/principals/:id/tokensGETprincipal.manage on the target
/v1/principals/:id/tokensPOSTprincipal.manage on the target
/v1/principals/:id/tokens/:tokenIdDELETEprincipal.manage on the target
/v1/threadsPOSTTop-level: grants.manage on the new thread. Subthread: thread.create.child on the parent (plain members included).
/v1/threads/:tidGETthread.read on the thread
/v1/threads/:tid/participantsGETthread.read and participants.list on the thread
/v1/threads/:tid/participants/:pidGETthread.read and participants.get on the thread
/v1/threads/:tid/eventsGETthread.read and events.read on the thread
/v1/threads/:tid/capabilitiesGETthread.read and events.read on the thread
/v1/threads/:tid/eventsPOSTThe events.* capability matching body.type on the thread (e.g. events.message_part for message_part)
/v1/threads/:tid/cursors/:pidGETthread.read and events.read on the thread; self always allowed, non-self requires principal.manage on the target
/v1/threads/:tid/cursors/:pidPUTthread.read and events.read on the thread; self always allowed, non-self requires principal.manage on the target
/v1/grants?targetType=…GETgrants.read on the requested target
/v1/grants?principalId=…GETgrants.read on principalTarget(principalId); self always allowed
/v1/grantsPUTgrants.manage on body.target, or thread self-join with participants.manage.self when granting self member
/v1/grantsDELETEgrants.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.append fire-and-forget alternative to POST /v1/threads/:threadId/events.