Embedding Octostaff
Mount Octostaff in a host server and call its authorized conversation services in-process.
On this page
Octostaff's supported in-process surface is runtime.application, typed as
BubbleApplication from @octostaff/bubble. It is the same service layer used by
the HTTP and WebSocket routes. A host supplies an authenticated actor to each
operation; the services enforce Octostaff's role and capability policy.
LoopbackBubbleClient remains an internal adapter for denizens. A host does not
need to construct a second client or make an internal HTTP request.
Octostaff requires Node 24 or newer. @octostaff/bubble depends on
@octostaff/sdk rather than bundling it, so a host that also uses the SDK, for
its types or the assembler, shares the same copy.
Mounting and ownership
import Fastify from 'fastify';
import { authenticateHttpActor, bubblePlugin, createBubbleRuntime } from '@octostaff/bubble';
const app = Fastify();
const runtime = await createBubbleRuntime({
store, // provisioned by the host
authenticator: [
{ driver: 'local-token' },
{
driver: 'entra',
tenantId: { env: 'BUBBLE_ENTRA_TENANT_ID' },
clientId: { env: 'BUBBLE_ENTRA_CLIENT_ID' },
},
],
logger: app.log,
autoGrantFirstUserSystemOwner: false,
});
await app.register(bubblePlugin(runtime), { prefix: '/bubble' });
app.get('/api/history/:threadId', async (request) => {
const actor = await authenticateHttpActor(runtime.ctx, request);
const { threadId } = request.params as { threadId: string };
await requireHostResourceAccess(actor.actorPrincipalId, threadId);
return runtime.application.events.list(threadId, {}, actor);
});
await app.listen({ port: 3000 });
await runtime.start();Here store and requireHostResourceAccess are host-owned dependencies. Octostaff
validates authenticator and builds its verifiers over the supplied store, using the same
configuration and implementations as the standalone server. Provision
administrator grants before serving requests.
The plugin adds routes beneath its prefix, keeps error handling scoped, and
uses existing WebSocket support when the host has registered it. A host that
registers @fastify/websocket itself should set its maxPayload to
runtime.application.events.appendBodyLimit, so an oversized frame is refused
at the same size as an HTTP append. The host owns CORS. Remote clients use a base URL such as https://host.example/bubble.
On shutdown, stop accepting work, close the host's connections, release host
subscriptions, await runtime.stop(), and then close storage. stop() drains
Octostaff's workers and pending enclosure cleanup; it does not close host-owned
subscriptions or storage. Unwind these resources on startup failure too.
For Postgres, PostgresConnection.open(...) owns the handles it creates.
PostgresConnection.borrow({ sql, listenSql, schema }) leaves the supplied
postgres driver handles with their owner. Pass the connection to
new PostgresBubbleStore(connection) and await store.init() before building
the runtime. Closing a borrowed store removes its subscriptions; the host still
ends both handles. A host using another database driver provisions separate
Octostaff clients against the same database and budgets connections for both.
Host writes and Octostaff writes do not share a transaction.
The combined host integration test provides a complete PostgreSQL example. It verifies shared host/Octostaff identity, issuer separation, host-contributed permissions, prefixed HTTP and WebSocket traffic, and live account revocation from another instance. It then unmounts Octostaff, proves its database listeners were removed while a host listener and both borrowed handles remain usable, and mounts a fresh runtime over the same resources. Identity bindings, grants, disabled state, conversation history and artifact bytes survive; subscriptions resume from the persisted sequence.
Authentication and authorization
Pass authenticator to createBubbleRuntime to configure Octostaff's built-in verifiers.
It accepts the same ordered list as launchBubbleServer's auth setting: local-token,
auth0, entra, master-key, or the development-only default driver.
Include local-token to accept Octostaff-issued bearer tokens against the runtime's
store. An explicit list replaces the development stack; Octostaff adds no fallback.
Invalid or empty lists fail during runtime construction. The root package exports
BubbleAuthConfig, BubbleAuthenticatorConfig, and their corresponding
BubbleAuthConfigSchema and BubbleAuthenticatorConfigSchema validators for
hosts that load configuration themselves.
A host with an existing credential verifier passes that object or function as
authenticator instead of a configuration list. If authenticator is omitted,
Octostaff uses local tokens plus a development verifier that accepts a principal id
as a bearer token; production hosts must explicitly configure authentication.
Both the mounted Octostaff routes and authenticateHttpActor(runtime.ctx, request)
use the same verifier and identity registration. Huabu or another embedding host
does not need to implement JWT verification or local-token lookup itself.
An actor context is a trusted assertion from the host, not a credential. Never
copy a request body's principalId into it. authenticateHttpActor runs the
runtime's verifier and identity registration, including the disabled-account
gate. A host with an already established identity must resolve its durable
Octostaff principal and call application.identity.revalidate(principalId) before
using that context. Credential validity and tenant membership remain host checks.
The host checks access to its resource; Octostaff checks access to the thread. Both must pass. Keep the mapping from a host resource to its conversation thread in the host. Do not create a thread merely to represent a host permission target, or add host-shaped targets to Octostaff's authorization model. If users can reach the mounted Octostaff routes directly, the host must enforce its resource gate there too; checking only a separate host route leaves another access path.
runtime.store is privileged persistence intended for provisioning
and administration, and bypasses the service policy. Do not use it to implement
user-facing history or append operations. The application services are a
trusted TypeScript API: hosts also validate incoming data before calling them.
A long-lived subscription is authorized when opened. The host must revalidate credentials and account access and unsubscribe when access ends, just as Octostaff's WebSocket transport owns its revalidation timer. A plain actor context does not perform ongoing session validation. Hosts also own cancellation, deadlines, queue limits, and slow-consumer handling.
Rendering principal profiles
A host that shows authors, reviewers, or participants holds principal ids from
events, grants, or its own records. Resolve the ids a view needs in one call
rather than one get per id:
const { principals } = await runtime.application.principals.resolve(authorIds, actor);
const byId = new Map(principals.map((principal) => [principal.principalId, principal]));A remote client calls client.principals.resolve(ids), which sends
GET /v1/principals?ids=a,b,c. Both take at most
BUBBLE_RESOLVE_PRINCIPALS_MAX_IDS (100) ids, so chunk a longer list. Each row is
shaped for the caller exactly as principal(id).get() shapes it: the full
Principal when the actor holds principal.read on that principal, otherwise a
PublicPrincipalProfile with partial: true carrying the id, kind,
displayName, email, avatarUrl, presence, and disabledAt. Narrow with
'partial' in row before reading full-only fields. Disabled principals are
returned like any other, so history still renders their names; both shapes
carry disabledAt, so a host can mark them for every viewer.
Unknown ids are omitted rather than failing the request, so render a fallback
for any id missing from the result. Rows follow the order in which ids first
appear, and me resolves to the actor. The call reads the actor's grants and
the requested principals once each, whatever the number of ids.
Discovering supported operations
runtime.info.capabilities and GET /bubble/v1/info expose the same operation
support descriptor. Names follow the public SDK's BubbleApi hierarchy:
threads is the collection, while thread describes the handle returned by
client.thread(id). principals and principal follow the same convention.
Resource IDs are not included.
const capabilities = (await client.server.getInfo()).capabilities;
if (capabilities?.thread.events.subscribe === true) {
// This backend supports client.thread(id).events.subscribe(...).
}Every server operation has an explicit boolean: true means supported, false
means unsupported. An omitted descriptor means support is unknown. Consumers
should only enable an operation when its flag is true. Unknown fields are
ignored by the SDK decoder; missing known operations or non-boolean flags are
invalid descriptors. The descriptor follows the core API contract, with no
separate feature vocabulary or descriptor version.
The SDK derives the descriptor type from BubbleApi, expanding thread(id)
and principal(id) into their handle operations. Resource metadata and the
locally cached identity.getPrincipalId() getter are excluded. A type check
requires the wire codec to match this projection exactly; Octostaff's declaration
must supply every operation, and integration tests compare it with the real
SDK namespaces and handles.
These flags describe implementation support, not permission grants or an agent's
tool catalog. A supported operation may still be denied to an actor. Extensions
own their discovery through info.extensions; limits remain in info.limits.
The application services use the same descriptor without adopting the SDK's
handle calling convention:
| Public API path | Application service |
|---|---|
thread.events.list/append | events.list/append |
thread.events.subscribe | events.subscribe, with replay and a sequence boundary |
thread.participants | threads.listParticipants, grants.put, and grants.remove |
thread.createChild | threads.createChild, with Octostaff's inheritance policy |
thread.cursor.get/set | cursors.get/set |
principal.cursors.list | cursors.list |
Approvals are conversation events, with no separate operation namespace. Agent execution and tenant isolation remain host responsibilities described below.
A thread has a participant set and may contain several humans and several agents. There is no single-agent field. A run identifies one activity within that shared thread; it is not a private agent session.
Multi-participant conversations
Create a thread with the complete participant set. For example, Alice can own
the thread, Bob can hold Role.Approver, and two agents can each hold
Role.Commenter. Commenters author runs and messages; approvers record
decisions and interrupt requests without authoring conversation content. An
owner can manage the participant grants. Use application.grants.put/remove
for membership changes so the grant and participant_joined/participant_left
event are committed together.
A mention is an ordinary native message part:
await runtime.application.events.append(
threadId,
{
type: 'message_part',
principalId: 'agent-a',
runId,
version: 'bubble-v1',
part: { kind: 'mention', principalId: 'agent-b' },
},
{ actorPrincipalId: 'agent-a' },
);The host runs the agents, authenticates their identities, and decides how each reacts to delivered mentions. A mention grants no permission and starts no agent by itself. Both agents can author independently in the shared log. Octostaff binds every appended event's author to the supplied actor context.
Approval is also a shared event. The author emits a tool approval request in
its message stream; an authorized reviewer appends tool_call_approval with
their own principalId, the originating runId and toolCallId, and
approved: true or approved: false with an optional rejection reason. Other
participants observe the decision through subscriptions or history. The host's
executor decides how to proceed from that recorded verdict; Octostaff does not
turn approval into a callback to the person who started the run, or define a
quorum rule for competing decisions.
Use application.threads.createChild(parentId, { key, participants }, actor) to
delegate into a child thread. The child has its own explicit participant set;
creating it does not copy the parent's participants by default. Parent grants
inherit non-authoring capabilities according to Octostaff's policy, including
reading and management where the parent's role allows them. Event authorship
and approval do not inherit: a parent approver needs a direct child grant to
approve in the child. A delegated agent's child grant does not grant authority
upward to its parent.
Each principal has its own monotonic consumer cursor. Appending advances the
author's cursor; receiving events does not acknowledge consumption for other
participants. Hosts advance an agent's cursor with application.cursors.set
after that agent consumes the events. Managing another principal's cursor
requires the corresponding Octostaff permission.
The multi-participant integration example exercises two humans and two agents, mention-driven replies, approval and rejection, membership transitions, cursors, child inheritance, and matching HTTP/in-process authorization on memory and PostgreSQL stores. It also shows the host resource gate and Octostaff thread gate rejecting independently.
History and folding
Octostaff persists events, not folded turns. The public @octostaff/sdk/assembler package is supported in Node
as well as browser consumers:
import { createAssembler } from '@octostaff/sdk/assembler';
import type { BubbleSequencedEvent } from '@octostaff/sdk';
const events: BubbleSequencedEvent[] = [];
let fromSeq = 1;
for (;;) {
const page = await runtime.application.events.list(threadId, { fromSeq }, actor);
events.push(...page.events);
if (page.nextSeq === null) break;
fromSeq = page.nextSeq;
}
const snapshot = await createAssembler().assemble(events, { grouping: 'run' });Follow nextSeq rather than assuming the requested page size was honored. For a
stable history snapshot, pin toSeq to a known head; for large histories, apply
pages incrementally to an open assembler instead of accumulating them. Native
extension parts require the same trusted part handlers as other assembler
consumers. See Thread assembler for folding and cleanup.
Calling an agent and consuming a stream
Agent execution belongs to the host or an external agent service. Octostaff has no
create, start, close, fork, rehome, or supervise operation for an agent process.
A child thread is a conversation, not a newly started agent. Driver registries
and durable agent state snapshots stay host-owned, keyed by the host's tenant,
thread, and agent identity. Thread metadata and preferences are not substitutes
for a driver state store. agents.lifecycle and agents.stateSnapshots are not
advertised.
A host whose API says “call an agent and consume its output” adapts that call to Octostaff's append-and-subscribe model:
- Authenticate the caller and check host resource access. Choose a unique run id within the target thread and ensure the producer is a participant.
- Await
application.events.subscribe(threadId, { live: true }, actor, onEvent)before dispatching work. Buffer matching events during subscription setup. - Start the host-owned producer. It appends
run_started, orderedmessage_partevents, andrun_finishedunder its own authenticated actor. Propagate producer failures through the host call's error channel; the producer owns recording its terminal event and any conversation error parts. - Yield sequenced events matching the chosen run id. Other runs and participant activity remain in the shared log but do not finish this call.
- On completion, caller cancellation, deadline, or error, await the
subscription's
unsubscribe(). Signal cancellation to the producer; the host owns awaiting or terminating its worker. A remote producer can instead receive arun_interrupt_requestedevent, which is cooperative cancellation.
The runnable host adapter integration example implements this pattern with a bounded queue, a required cancellation/deadline signal, and error propagation. It runs against memory and Postgres, proves HTTP/in-process authorization parity, and folds the persisted result in Node. It deliberately does not introduce an agent execution API into Octostaff.
For reconnects, resume from the last processed seq + 1 and deduplicate by
sequence. Do not blindly replay a producer invocation: Octostaff's event log does
not supply a host job's idempotency key or execution lease.
Share a host-owned artifact backend
The artifacts extension accepts an ArtifactObjectStore from
@octostaff/bubble/extensions/artifacts. Bind it with
createArtifactServerExtension(objects) in the runtime's trusted definitions:
import { createBubbleRuntime } from '@octostaff/bubble';
import { createArtifactServerExtension, DiskArtifactObjectStore } from '@octostaff/bubble/extensions/artifacts';
// The host owns this location and reserves this subdirectory for the server.
const objects = new DiskArtifactObjectStore('/srv/host-blobs/bubble/tenant-a');
const runtime = await createBubbleRuntime({
store,
authenticator,
extensions: {
definitions: [createArtifactServerExtension(objects)],
config: {
artifacts: { signingSecret: { env: 'ARTIFACT_TRANSFER_SECRET' } },
},
persistence, // { driver: 'memory' } or { driver: 'postgres', connection }
publicBaseUrl: 'https://host.example/bubble',
},
});Here store, authenticator, and persistence are host-provisioned as above;
artifact metadata uses the supplied persistence. Mount bubblePlugin(runtime)
at /bubble and follow the same start/close/stop sequence. The host initializes
its object backend before composing Octostaff and owns its lifetime afterward.
Octostaff never closes the borrowed backend or deletes its root/container.
This factory replaces the artifactServerExtension definition for that runtime;
do not register both. Its strict configuration omits objects, rejecting a
second storage choice. Signing, upload limits, transfer expiry, and pending-upload
cleanup settings keep their existing meanings and defaults. Standalone JSON
configuration still uses extensions.artifacts.objects with memory, disk,
or azure; its construction path is unchanged.
For an existing Azure client, implement ArtifactObjectStore over the host's
ContainerClient. Map put to a block blob upload, get to its download stream,
stat to properties (return null only for a missing blob), and delete to
deleteIfExists for that blob only. Preserve MIME type and byte size. On disk,
DiskArtifactObjectStore provides the same interface, including streamed writes
and single-object deletion. Reuse the underlying disk/Azure client, not a host
resource-scoped blob port whose delete operation removes an entire resource area.
Octostaff still chooses opaque keys of the form v1/<principalId>/<objectId> and
checks access to each artifact's scope through its own policy. Reserve a host
prefix for each Octostaff runtime, as in the disk example; an Azure adapter should
prepend that prefix consistently to every operation. Host blobs must not use
Octostaff's reserved keys. Shared storage does not imply shared authorization or
retention: cleanup visits only Octostaff's recorded pending objects, while deleting
an artifact releases its object only once no live alias references it.
Without directTransfers, signed URLs route bytes through Octostaff. An adapter
that provides directTransfers must mint expiring, object-scoped URLs; upload
URLs must be create-only so replay cannot overwrite committed content. Apply the
same host prefix to URL generation as to put/get/stat/delete.
The artifact embedding tests exercise shared disk storage with memory and Postgres metadata, access denial, alias retention, pending cleanup, and host access after shutdown. The Azure integration tests exercise both transfer modes and single-object deletion against Azurite through both config and host injection. Core thread enclosures remain transactional attachments in Octostaff's core store; this adapter changes only artifacts.
Tenant and backend boundaries
This contract has no tenant parameter and does not advertise tenancy. Use one
runtime and isolated store namespace per tenant. A host may provision a distinct
Postgres schema for each runtime, but schema separation alone does not authorize
the request: authenticate and check tenant membership before selecting that
runtime. Never select a privileged runtime solely from a client-supplied id.
A host may put an implementation that does not use Octostaff behind its own conversation port. That port belongs to the host, and can use operation discovery to route or disable unsupported operations. This contract does not claim native tenant isolation, agent process management, or server-side folded history for Octostaff.