--- title: 'stackbone.storage' description: 'S3-compatible object storage with per-agent key prefixing and signed URLs.' position: 4 --- # stackbone.storage > S3-compatible object storage. Your deployment's own bucket in production, > MinIO in `stackbone dev`. Reach it through the ambient `stackbone` client, > `stackbone.storage`, from inside any [agent tool](/docs/sdk/agents/overview) > or [durable workflow step](/docs/sdk/workflows/overview). The runtime prefixes > every key 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 the SDK rewrites every key to `${agentId}/${bucket}/${key}` before hitting the underlying physical S3 bucket (`STACKBONE_S3_BUCKET`), and it rejects path-traversal segments (`..`), 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. The runtime injects the credentials and agent identity as environment variables on boot, so there is no `createClient()` to call and no context object to thread through. The SDK builds the underlying `S3Client` **lazily on the first method call** and reuses it for the lifetime of the process. Every method that issues an S3 round-trip awaits the [readiness handshake](/docs/sdk/reference/overview#the-contract-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 skips that gate by design. > The storage API is identical whether you reach it through the ambient > `stackbone.storage` or an explicit `createClient({ s3 }).storage`; only > the entrypoint differs. ## 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`](/docs/sdk/data/rag#managed-ingestion-from-studio)), the platform keeps the original file in your object storage under a `rag/` prefix so you can download the source later. The dashboard shows that folder in the object explorer and lets you **list and download** it, but it is **read-only there**: the Storage explorer refuses any upload, overwrite or delete under `rag/`. A stray manual upload there would be an orphan: a file with no matching document or chunks, invisible to your agent. 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 (MinIO in `stackbone dev`). | | `STACKBONE_S3_BUCKET` | The physical bucket your logical buckets are namespaced under. | | `STACKBONE_S3_REGION` | Region, defaults to `'us-east-1'` when unset. | | `STACKBONE_S3_FORCE_PATH_STYLE` | How the bucket is addressed. Defaults to `true`. | | `STACKBONE_AGENT_ID` | The agent identity prepended to every key. | ### Path-style or subdomain `STACKBONE_S3_FORCE_PATH_STYLE` decides where the bucket name goes in the URL, and different backends disagree: | Value | URL shape | Backends that want it | | ---------------- | ------------------------- | ----------------------- | | `true` (default) | `https://host/bucket/key` | MinIO, Cloudflare R2 | | `false` | `https://bucket.host/key` | AWS S3, Railway Buckets | The default is `true` because that is what MinIO speaks, and MinIO is what `stackbone dev` runs. A self-host folder written by [`stackbone package`](/docs/cli/reference/package) also brings its own MinIO and creates the bucket for you, so it keeps the same default. If your deployment points at a bucket that only answers to the subdomain form, every call fails with `NoSuchBucket` or a bare 403 even though the credentials are correct. That is the symptom of this one variable, so check it before you conclude the keys are wrong. 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: ```ts import { createClient } from '@stackbone/sdk'; const sb = createClient({ s3: { accessKeyId: '…', secretAccessKey: '…', endpoint: 'http://localhost:9000', bucket: 'my-bucket', // region defaults to 'us-east-1' // forcePathStyle defaults to true; set false for AWS S3 }, 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 a hint naming what to set. `region` and `forcePathStyle` never trip those errors, because both carry a default (`'us-east-1'` and `true`). ## 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, with no context wiring: ```ts 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 the runtime persists and retries it as one unit: ```ts 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: ```ts // 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; // string ``` For private buckets, mint short-lived signed URLs: ```ts // 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; ``` `getSignedUploadUrl` pins `contentType` into the signature, so the client uploading to the URL **must** send a matching `Content-Type` header. Use this to enforce an image type on 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 advertises an incompatible contract, the readiness gate also surfaces `contract_version_unsupported`, `capability_unavailable`, `contract_unreachable` and `contract_malformed`. See the [readiness handshake](/docs/sdk/reference/overview#the-contract-handshake). ## Where to go next - **[`stackbone.database`](/docs/sdk/data/database)**: for typed Postgres alongside your blob storage. - **[`stackbone.rag`](/docs/sdk/data/rag)**: a higher-level pipeline that ingests parsed text into Postgres + `pgvector`. Use `stackbone.storage` for the raw uploads and `stackbone.rag` for the indexable extract. - **[Agents](/docs/sdk/agents/overview)**: where agent tools run, and how a tool's return becomes part of a chat turn. - **[Workflows](/docs/sdk/workflows/overview)**: `'use step'` semantics, the other call site for storage round-trips.