stackbone.storage
S3-compatible object storage. Cloudflare R2 in production, MinIO in
stackbone dev. Reach it through the ambientstackboneclient —stackbone.storage— from inside any agent tool or durable workflow step. Every key is automatically prefixed with the agent's identity so two agents that pick the same logical bucket name never collide.
Mental model
stackbone.storage.from(bucket) returns a StorageBucket scoped to a
logical bucket name. Internally every key is rewritten to
${agentId}/${bucket}/${key} before hitting the underlying physical
S3 bucket (STACKBONE_S3_BUCKET). Path-traversal segments (..) are rejected
so a caller-controlled key cannot escape the agent's namespace.
stackbone is the ambient client — you import { stackbone } from '@stackbone/sdk'
and use it directly. There is no createClient() to call and no context
object to thread through; the runtime injects the credentials and agent
identity as environment variables on boot. The underlying S3Client is built
lazily on the first method call and reused for the lifetime of the process.
Every method that issues an S3 round-trip awaits the
readiness handshake the ambient
client runs once, on its first gated call (the surface depends on the
storage.s3 capability). getPublicUrl is a pure URL builder — no S3
round-trip — so it intentionally bypasses that gate.
The storage API is identical whether you reach it through the ambient
stackbone.storageor an explicitcreateClient({ s3 }).storage. Only the entrypoint changed; the bucket methods below are byte-for-byte the same.
The reserved rag/ prefix
One logical bucket name is reserved: rag. When someone uploads a
file through the dashboard's managed RAG ingestion (see
stackbone.rag), the
original file is kept in your object storage under a rag/ prefix, so
the source can be downloaded later. The dashboard shows that folder in the
object explorer and lets you list and download it, but it is
read-only there: uploading, overwriting or deleting anything under
rag/ from the Storage explorer is refused. A stray manual upload
there would be an orphan — a file with no matching document or chunks,
invisible to your agent — so the RAG flow owns that folder's lifecycle.
Deleting a document or collection from the RAG screen removes its
original under rag/ for you.
For your own uploads, avoid the rag logical bucket name:
stackbone.storage.from('rag') collides with this reserved space. Pick
any other name (uploads, attachments, exports, …) and your keys
stay in your own namespace.
Configuration
A deployed agent never configures storage by hand. The runtime injects these
environment variables when your container boots, and the ambient stackbone
client reads them on first use:
| Env var the runtime injects | Holds |
|---|---|
STACKBONE_S3_ACCESS_KEY |
Access key for the agent's physical bucket. |
STACKBONE_S3_SECRET_KEY |
Secret access key. |
STACKBONE_S3_ENDPOINT |
S3-compatible endpoint (R2 in prod, MinIO in stackbone dev). |
STACKBONE_S3_BUCKET |
The physical bucket your logical buckets are namespaced under. |
STACKBONE_S3_REGION |
Region — defaults to 'auto' when unset (works on R2 and MinIO). |
STACKBONE_AGENT_ID |
The agent identity prepended to every key. |
If you need explicit configuration — for a local script or a test — you can
pass an s3 block to createClient() instead of relying on the injected env:
import { createClient } from '@stackbone/sdk';
const sb = createClient({
s3: {
accessKeyId: '…',
secretAccessKey: '…',
endpoint: 'http://localhost:9000',
bucket: 'my-bucket',
// region defaults to 'auto'
},
agentId: 'local-agent',
});
const bucket = sb.storage.from('uploads');Missing any required value surfaces s3_credentials_missing,
s3_bucket_missing or agent_id_missing with an actionable hint —
region is the only one that never trips the missing-credential
errors because of the 'auto' default.
Upload, download, list, remove
The primary place an agent does I/O is inside an agent tool. Import the
ambient stackbone and grab a bucket, no context wiring:
import { tool } from '@langchain/core/tools';
import { stackbone, z } from '@stackbone/sdk';
const noteRoundtrip = tool(
async ({ note }: { note: string }) => {
const bucket = stackbone.storage.from('uploads');
// Upload — body can be Blob | Uint8Array | string.
await bucket.upload('docs/welcome.txt', note, {
contentType: 'text/plain',
metadata: { authorId: 'user-123' },
});
// Download — materialises the object as a Blob in memory.
// For large objects, prefer getSignedDownloadUrl + fetch().
const downloaded = await bucket.download('docs/welcome.txt');
if (downloaded.error) throw new Error(downloaded.error.code);
const text = await downloaded.data.text();
// Paginated list, scoped by prefix.
const listed = await bucket.list({ prefix: 'docs/', limit: 50 });
if (listed.error) throw new Error(listed.error.code);
const keys = listed.data.objects.map((obj) => obj.key);
// Remove.
await bucket.remove('docs/welcome.txt');
return JSON.stringify({ text, keys });
},
{
name: 'note_roundtrip',
description: 'Store and read back a note for the current user.',
schema: z.object({ note: z.string() }),
},
);The same calls work unchanged from a durable workflow step. Keep every S3
round-trip inside a 'use step' so it is persisted and retried as one unit:
import { stackbone } from '@stackbone/sdk';
async function archiveReport(key: string, body: string) {
'use step'; // runs once, persisted, retried on failure — keep it idempotent
const bucket = stackbone.storage.from('exports');
const result = await bucket.upload(key, body, { contentType: 'application/json' });
if (result.error) throw new Error(result.error.code);
return { key: result.data.key };
}Pagination is cursor-based — listed.data.nextCursor is set when
S3 truncated the response; pass it back as cursor in the next
call.
Public and signed URLs
bucket here is stackbone.storage.from(...) — the ambient client you
import inside any tool or step:
// Pure URL builder. Whether the URL is publicly fetchable depends on
// the bucket policy. No S3 round-trip; the readiness gate is skipped.
const result = bucket.getPublicUrl('docs/welcome.txt');
if (result.error) throw new Error(result.error.code);
const publicUrl = result.data; // stringFor private buckets, mint short-lived signed URLs:
// Default TTL: 3600s (1h). Override with `expiresIn`.
const upload = await bucket.getSignedUploadUrl('uploads/raw.bin', {
expiresIn: 600,
contentType: 'application/octet-stream', // pinned into the signature
});
if (upload.error) throw new Error(upload.error.code);
const uploadUrl = upload.data.url; // PUT the bytes to this URL
const download = await bucket.getSignedDownloadUrl('docs/welcome.txt', {
expiresIn: 300,
});
if (download.error) throw new Error(download.error.code);
const downloadUrl = download.data.url;contentType on getSignedUploadUrl is pinned into the signature,
so the client uploading to the URL must send a matching
Content-Type header — useful to enforce an image type from a
signed direct upload.
A common durable-agent pattern: mint a signed upload URL inside a tool, return it as part of the session turn, and let the end user PUT their file straight to S3 — your agent never streams the bytes through itself.
Errors
| Code | When |
|---|---|
s3_credentials_missing |
Access key, secret or endpoint absent. |
s3_bucket_missing |
STACKBONE_S3_BUCKET (or s3.bucket override) absent. |
agent_id_missing |
STACKBONE_AGENT_ID (or agentId override) absent. |
s3_invalid_key |
Key contains a .. segment. |
s3_invalid_argument |
list({ limit }) ≤ 0. |
s3_empty_response |
S3 succeeded but returned no body. |
s3_error |
Anything else (auth, throttling, network). error.meta includes the AWS HTTP status, name, and fault when available; error.cause is the original SDK error. |
If the runtime ever advertises an incompatible contract, the readiness gate
also surfaces contract_version_unsupported, capability_unavailable,
contract_unreachable and contract_malformed — see the
readiness handshake.
Where to go next
stackbone.database— for typed Postgres alongside your blob storage.stackbone.rag— a higher-level pipeline that ingests parsed text into Postgres +pgvector. Usestackbone.storagefor the raw uploads andstackbone.ragfor the indexable extract.- Agents — where agent tools run, and how a tool's return becomes part of a chat turn.
- Workflows —
'use step'semantics, the other call site for storage round-trips.