Artifact protocol
The global artifact control plane, authorization scopes, staged byte transfers, and SDK contract.
docs/build/30-artifact-protocol.mdxOn this page
Artifacts are named byte objects governed by an explicit Bubble authorization target. They are not inherently attached to a thread: the same control plane can store artifacts under a system, thread, or principal scope.
The SDK exposes this through the negotiated artifacts extension,
and an enabled server mounts its HTTP surface under /v1/artifacts. There is
deliberately no ThreadHandle.artifacts API and no
/v1/threads/:threadId/artifacts route. A thread-oriented adapter can still pass
threadTarget(threadId) as the access scope.
Clients explicitly register the trusted local implementation when they create a
connection, then request its typed key. The SDK checks /v1/info before loading
the adapter: a missing server manifest yields unsupported, and a different
extension protocol version yields incompatible. Bubble never supplies or
downloads executable extension code.
import { createBubbleClient } from '@octostaff/sdk/client';
import { artifactExtensionKey } from '@octostaff/sdk/extensions/artifacts';
const bubble = createBubbleClient({ baseUrl, token });
const artifacts = await bubble.extensions.require(artifactExtensionKey);When the server does not enable the extension, it neither advertises the
manifest nor mounts /v1/artifacts. require fails during negotiation before
issuing an artifact operation; optional returns undefined for callers that
can operate without artifact support.
Access Scopes and Authorization
Every operation identifies the AuthzTarget governing
the artifact:
import { principalTarget, systemTarget, threadTarget } from '@octostaff/sdk';
systemTarget(); // { type: 'system' }
threadTarget('thread-1'); // { type: 'thread', threadId: 'thread-1' }
principalTarget('user-alice'); // { type: 'principal', principalId: 'user-alice' }The service derives the caller only from the authenticated request. Supplying an access scope selects the policy target; it never selects or overrides the caller.
| Operation | Required capability |
|---|---|
| List, download, import source | artifact.read |
| Begin upload, commit, inline upload | artifact.write |
| Import destination | artifact.write |
| Delete a committed or pending artifact | artifact.manage |
Imports carry two independent targets:
sourceAccessScope is checked for read access and
destinationAccessScope is checked for write access.
HTTP Control Plane
All control-plane requests are authenticated. Signed byte transfer URLs are the only exception; possession of a live transfer URL authorizes that one transfer.
| Method | Path | Request | Response |
|---|---|---|---|
GET | /v1/artifacts | ListArtifactsQuery | ListArtifactsResponse |
POST | /v1/artifacts/uploads | BeginArtifactUploadBody | BeginArtifactUploadResponse |
POST | /v1/artifacts/commits | CommitArtifactBody | ArtifactDescriptor |
POST | /v1/artifacts/inline | UploadArtifactInlineBody | ArtifactDescriptor |
POST | /v1/artifacts/downloads | DownloadArtifactBody | DownloadArtifactResponse |
POST | /v1/artifacts/imports | ImportArtifactBody | ArtifactDescriptor |
DELETE | /v1/artifacts | DeleteArtifactBody | 204 |
PUT | /v1/artifacts/transfers | Raw bytes plus signed token query | 204 |
GET | /v1/artifacts/transfers | Signed token query | Raw bytes |
Listing encodes an authorization target in the query string. targetId is
required for thread and principal, and omitted for system:
/v1/artifacts?targetType=thread&targetId=thread-1 /v1/artifacts?targetType=principal&targetId=user-alice /v1/artifacts?targetType=system
Staged Uploads
The SDK stages uploads but never uploads the bytes automatically. The caller
performs the returned PUT, then commits the declared content:
import { threadTarget } from '@octostaff/sdk';
const accessScope = threadTarget('thread-1');
const contentHash = `sha256:${sha256Hex(bytes)}`;
const pending = await artifacts.beginUpload({
accessScope,
name: 'reports/result.txt',
contentHash,
byteSize: bytes.byteLength,
mimeType: 'text/plain',
});
await fetch(pending.upload.url, {
method: pending.upload.method,
headers: pending.upload.headers,
body: bytes,
});
const artifact = await artifacts.commit({
accessScope,
name: 'reports/result.txt',
contentHash,
});Commit verifies that uploaded size, MIME type, and SHA-256 digest match the declaration. A transfer is short-lived and may be either a Bubble-signed local URL or an object-store URL. Callers must use the returned method and headers.
uploadInline is the bounded alternative for small payloads. It carries the
same metadata plus standard base64 bytes and commits in one authenticated
request.
Reading, Importing, and Deleting
The SDK returns transfer instructions for downloads rather than fetching bytes inside the client:
const resolved = await artifacts.download({
accessScope,
name: 'reports/result.txt',
});
const bytes = await fetch(resolved.download.url, {
method: resolved.download.method,
headers: resolved.download.headers,
}).then((response) => response.arrayBuffer());Imports create a destination name referencing the same immutable byte object; they do not copy the bytes:
await artifacts.importArtifact({
sourceAccessScope: threadTarget('thread-1'),
destinationAccessScope: principalTarget('user-alice'),
sourceName: 'reports/result.txt',
name: 'saved/result.txt',
});list(accessScope) returns committed artifacts in exactly that scope.
delete({ accessScope, name }) tombstones a committed name or removes its
pending upload. Physical bytes are retained while another imported name still
references them.
Safe Public Shapes
ArtifactDescriptor contains an ArtifactRecord and content ArtifactRef.
ArtifactTransfer contains only the URL, expiry, method, and required headers.
Backing-store keys are server-private: the SDK does not export an artifact
object shape containing blobKey, and strict response codecs reject it.
The @octostaff/sdk/extensions/artifacts subpath exports the corresponding runtime codecs, including
ArtifactDescriptorSchema, ArtifactTransferSchema, request body schemas, and
the begin/download/list response schemas.
idempotencyKey is optional on staged uploads, inline uploads, and imports. Use
the same key when retrying one logical operation after an uncertain transport or
worker failure.