/storage
Use when uploading or downloading files, generating presigned URLs, configuring storage ACLs, or persisting file references (avatars, attachments, images) in a Butterbase app
$ npx -y skills add butterbase-ai/butterbase-skills --skill storage --agent claude-codeHow it fires
How this skill gets triggered: by you, by Claude, or both.
- Fires itselfAuto-invocation. Claude auto-loads it when your prompt matches the work.Auto-invocation is when the right skill fires by itself at the right moment, driven by a FLOW.md router and a hook, instead of you invoking it by name. It is the difference between a skill being installed and a skill actually getting used.Read the full definition →
- You can call itInvoke it directly when you want it.
- Slash command
/storage
Context preview
The summary Claude sees to decide when to auto-load this skill.
Use when uploading or downloading files, generating presigned URLs, configuring storage ACLs, or persisting file references (avatars, attachments, images) in a Butterbase app
SKILL.md
storage.SKILL.mdname: storage
description: Use when uploading or downloading files, generating presigned URLs, configuring storage ACLs, or persisting file references (avatars, attachments, images) in a Butterbase app
Butterbase Storage
Butterbase stores files in S3 (or LocalStack in dev) and exposes them via presigned URLs. Every file gets a stable `object_id` (UUID) that you persist in your tables; URLs are generated on demand and expire.
All storage operations go through one tool: **`manage_storage`** with an `action` parameter.
| Action | Purpose | |--------|---------| | `upload_url` | Generate a 15-minute presigned PUT URL and reserve an `object_id` | | `download_url` | Generate a 1-hour presigned GET URL for a stored object | | `list` | List objects (scoped by caller's role) | | `delete` | Permanently remove an object from S3 + database | | `update_config` | Toggle app-level `publicReadEnabled` and other storage settings |
---
1. The mental model: `object_id` vs `s3_key`
| Field | What it is | When you use it | |-------|-----------|-----------------| | `object_id` | UUID, stable, app-level handle | Persist in your tables (e.g. `users.avatar_id`, `posts.image_id`) | | `s3_key` | Internal bucket path like `app_abc/user_uuid/file.jpg` | Internal only — **never** treat this as a URL |
**Critical:** `s3_key` is **not** a URL. You cannot use it as `<img src>` or `<a href>`. Always store the `object_id` and resolve a fresh download URL at render time.
---
2. The upload lifecycle
A single upload is two HTTP calls and one DB insert in your app:
┌─────────────────────────┐
│ 1. manage_storage( │ → returns { upload_url, object_id, expires_at }
│ action: upload_url)│
├─────────────────────────┤
│ 2. PUT file -> S3 │ → must include exact Content-Type header
├─────────────────────────┤
│ 3. INSERT INTO ... │ → save object_id alongside the user/post/etc.
└─────────────────────────┘If you skip step 3, the file lives in S3 but no row references it — an **orphaned object** counting against your quota. Always persist the `object_id`.
Step 1 — request an upload URL
manage_storage({
app_id: "app_abc123",
action: "upload_url",
filename: "avatar.jpg",
content_type: "image/jpeg",
size_bytes: 245123,
public: false // optional; default false
})Returns:
{
"upload_url": "https://s3.amazonaws.com/...",
"object_id": "9c14b2e0-...",
"expires_at": "2026-05-08T22:15:00Z"
}The `object_id` is created **immediately** in the database; the file just hasn't been uploaded yet.
Step 2 — PUT the bytes
The Content-Type on the PUT must match exactly the `content_type` you sent in step 1. If it doesn't, S3 rejects the upload or stores the wrong MIME — breaking browser previews.
curl -X PUT "{upload_url}" \
-H "Content-Type: image/jpeg" \
--data-binary @avatar.jpgFrom the browser:
await fetch(uploadUrl, {
method: "PUT",
headers: { "Content-Type": file.type },
body: file
});Step 3 — persist the `object_id`
UPDATE users SET avatar_id = $1 WHERE id = $2
Or via the auto-API / SDK — whatever your app uses for writes. Without this step, the file is unreachable.
---
3. Generating download URLs
Each call returns a fresh URL valid for 1 hour. Don't bake URLs into static HTML or long-lived caches — re-generate per render or per session.
manage_storage({
app_id: "app_abc123",
action: "download_url",
object_id: "9c14b2e0-..."
})
// → { download_url: "https://s3.amazonaws.com/..." }For lists with many files, resolve URLs in parallel:
const urls = await Promise.all(
posts.map(p => getDownloadUrl(p.image_id))
);
If the caller is unauthorized, the response is `404` (not `403`) — Butterbase deliberately hides existence to avoid leaking object IDs.
---
4. Access control
Three tiers, evaluated in order:
| Caller | What they can read | |--------|--------------------| | Service key (`bb_sk_*`) | Everything in the app — RLS bypassed | | End-user JWT | Files where `(user_id === caller_id) OR object.public === true OR app.publicReadEnabled === true` | | Anonymous (no auth) | Only public objects (and only if app access mode allows anon) |
Per-object public flag
Set at upload time:
manage_storage({ app_id, action: "upload_url", filename, content_type, size_bytes, public: true })Use this for one-off public files (a marketing image, a shared avatar) without flipping the whole app to public-read.
App-wide public read
manage_storage({
app_id: "app_abc123",
action: "update_config",
publicReadEnabled: true
})When `true`, any authenticated user in the app can download any file. Uploads and deletes stay user-scoped.
> Storage ACL is hardcoded — you cannot layer Postgres RLS policies on top of `storage_objects`. If you need fine-grained custom rules, gate downloads through a serverless function instead of handing out direct presigned URLs.
---
5. Listing and deleting
manage_storage({ app_id: "app_abc123", action: "list" })Service key sees everything; end-user JWT sees only their own files. Each item has `id, user_id, key, filename, content_type, size_bytes, created_at`.
manage_storage({ app_id: "app_abc123", action: "delete", object_id: "9c14b2e0-..." })Permanently removes the S3 object and DB row. **Clear foreign-key references first** (e.g. `UPDATE users SET avatar_id = NULL`) — `manage_storage` doesn't.
---
6. Quotas & error codes
| Limit | Default | Override | |-------|---------|----------| | Per-file size | 10 MB | `storage_config` | | Total app storage | Plan-dependent | Upgrade plan | | Allowed content types | All by default | `storage_config.allowedContentTypes` whitelist |
| Error | When | |-------|------| | `QUOTA_FILE_SIZE_EXCEEDED` (400) | `size_bytes` > per-file limit | | `QUOTA_STORAGE_EXCEEDED` (429) | App total exhausted | | `VALIDATION_INVALID_TYP
Read more
name: storage description: Use when uploading or downloading files, generating presigned URLs, configuring storage ACLs, or persisting file references (avatars, attachments, images) in a Butterbase app
Butterbase Storage
Butterbase stores files in S3 (or LocalStack in dev) and exposes them via presigned URLs. Every file gets a stable `object_id` (UUID) that you persist in your tables; URLs are generated on demand and expire.
All storage operations go through one tool: **`manage_storage`** with an `action` parameter.
| Action | Purpose | |--------|---------| | `upload_url` | Generate a 15-minute presigned PUT URL and reserve an `object_id` | | `download_url` | Generate a 1-hour presigned GET URL for a stored object | | `list` | List objects (scoped by caller's role) | | `delete` | Permanently remove an object from S3 + database | | `update_config` | Toggle app-level `publicReadEnabled` and other storage settings |
---
1. The mental model: `object_id` vs `s3_key`
| Field | What it is | When you use it | |-------|-----------|-----------------| | `object_id` | UUID, stable, app-level handle | Persist in your tables (e.g. `users.avatar_id`, `posts.image_id`) | | `s3_key` | Internal bucket path like `app_abc/user_uuid/file.jpg` | Internal only — **never** treat this as a URL |
**Critical:** `s3_key` is **not** a URL. You cannot use it as `<img src>` or `<a href>`. Always store the `object_id` and resolve a fresh download URL at render time.
---
2. The upload lifecycle
A single upload is two HTTP calls and one DB insert in your app:
┌─────────────────────────┐
│ 1. manage_storage( │ → returns { upload_url, object_id, expires_at }
│ action: upload_url)│
├─────────────────────────┤
│ 2. PUT file -> S3 │ → must include exact Content-Type header
├─────────────────────────┤
│ 3. INSERT INTO ... │ → save object_id alongside the user/post/etc.
└─────────────────────────┘If you skip step 3, the file lives in S3 but no row references it — an **orphaned object** counting against your quota. Always persist the `object_id`.
Step 1 — request an upload URL
manage_storage({
app_id: "app_abc123",
action: "upload_url",
filename: "avatar.jpg",
content_type: "image/jpeg",
size_bytes: 245123,
public: false // optional; default false
})Returns:
{
"upload_url": "https://s3.amazonaws.com/...",
"object_id": "9c14b2e0-...",
"expires_at": "2026-05-08T22:15:00Z"
}The `object_id` is created **immediately** in the database; the file just hasn't been uploaded yet.
Step 2 — PUT the bytes
The Content-Type on the PUT must match exactly the `content_type` you sent in step 1. If it doesn't, S3 rejects the upload or stores the wrong MIME — breaking browser previews.
curl -X PUT "{upload_url}" \
-H "Content-Type: image/jpeg" \
--data-binary @avatar.jpgFrom the browser:
await fetch(uploadUrl, {
method: "PUT",
headers: { "Content-Type": file.type },
body: file
});Step 3 — persist the `object_id`
UPDATE users SET avatar_id = $1 WHERE id = $2
Or via the auto-API / SDK — whatever your app uses for writes. Without this step, the file is unreachable.
---
3. Generating download URLs
Each call returns a fresh URL valid for 1 hour. Don't bake URLs into static HTML or long-lived caches — re-generate per render or per session.
manage_storage({
app_id: "app_abc123",
action: "download_url",
object_id: "9c14b2e0-..."
})
// → { download_url: "https://s3.amazonaws.com/..." }For lists with many files, resolve URLs in parallel:
const urls = await Promise.all( posts.map(p => getDownloadUrl(p.image_id)) );
If the caller is unauthorized, the response is `404` (not `403`) — Butterbase deliberately hides existence to avoid leaking object IDs.
---
4. Access control
Three tiers, evaluated in order:
| Caller | What they can read | |--------|--------------------| | Service key (`bb_sk_*`) | Everything in the app — RLS bypassed | | End-user JWT | Files where `(user_id === caller_id) OR object.public === true OR app.publicReadEnabled === true` | | Anonymous (no auth) | Only public objects (and only if app access mode allows anon) |
Per-object public flag
Set at upload time:
manage_storage({ app_id, action: "upload_url", filename, content_type, size_bytes, public: true })Use this for one-off public files (a marketing image, a shared avatar) without flipping the whole app to public-read.
App-wide public read
manage_storage({
app_id: "app_abc123",
action: "update_config",
publicReadEnabled: true
})When `true`, any authenticated user in the app can download any file. Uploads and deletes stay user-scoped.
> Storage ACL is hardcoded — you cannot layer Postgres RLS policies on top of `storage_objects`. If you need fine-grained custom rules, gate downloads through a serverless function instead of handing out direct presigned URLs.
---
5. Listing and deleting
manage_storage({ app_id: "app_abc123", action: "list" })Service key sees everything; end-user JWT sees only their own files. Each item has `id, user_id, key, filename, content_type, size_bytes, created_at`.
manage_storage({ app_id: "app_abc123", action: "delete", object_id: "9c14b2e0-..." })Permanently removes the S3 object and DB row. **Clear foreign-key references first** (e.g. `UPDATE users SET avatar_id = NULL`) — `manage_storage` doesn't.
---
6. Quotas & error codes
| Limit | Default | Override | |-------|---------|----------| | Per-file size | 10 MB | `storage_config` | | Total app storage | Plan-dependent | Upgrade plan | | Allowed content types | All by default | `storage_config.allowedContentTypes` whitelist |
| Error | When | |-------|------| | `QUOTA_FILE_SIZE_EXCEEDED` (400) | `size_bytes` > per-file limit | | `QUOTA_STORAGE_EXCEEDED` (429) | App total exhausted | | `VALIDATION_INVALID_TYP
Claude Code plugin for Butterbase — the AI-Native Backend-as-a-Service. This plugin gives Claude deep knowledge of Butterbase's 42+ MCP tools, guides you through common workflows, and auto-configures the MCP server connection.
Repo: butterbase-ai/butterbase-skills
Other skills on butterbase-skills.
- /agents
Use when designing, deploying, or debugging a Butterbase Agent (declarative LLM/tool graph), registering an MCP server for tool use, or wiring access controls and rate limits. Agents are first-class app resources defined by a `graph_spec` and invoked over
Open skill - /ai
Use when calling the app's AI gateway from agent tools — chat completions, embeddings, listing models, configuring defaults or BYOK, reading token/cost usage
Open skill - /auth-setup
Use when configuring OAuth providers (Google/GitHub/Apple/X/etc.), setting up post-login auth hooks, tuning JWT lifetimes, or generating service API keys
Open skill - /build-app
Use when building a new Butterbase app from scratch, creating a full-stack application, or when the user asks to set up a complete backend with database, auth, and deployment
Open skill - /contributing
Use when contributing to the Butterbase codebase, adding new MCP tools, creating API routes, writing migrations, or understanding the monorepo architecture
Open skill - /debug-rls
Use when users report access denied errors, see wrong data, RLS policies are not working, or when troubleshooting Row-Level Security issues in Butterbase
Open skill

