Big News: Socket raises $60M Series C at a $1B valuation to secure software supply chains for AI-driven development.Announcement
Sign In

@butterbase/plugin

Package Overview
Dependencies
Maintainers
1
Versions
2
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@butterbase/plugin - npm Package Compare versions

Comparing version
0.1.0
to
0.1.1
+20
.claude-plugin/marketplace.json
{
"name": "butterbase",
"description": "Butterbase plugin marketplace",
"owner": {
"name": "Butterbase",
"email": "ken@butterbase.ai"
},
"plugins": [
{
"name": "butterbase",
"description": "Build full-stack apps with Butterbase — AI-Native Backend-as-a-Service. Skills for app creation, schema design, frontend deployment, RLS debugging, serverless functions, and contributing.",
"version": "0.1.0",
"source": "./",
"author": {
"name": "Butterbase",
"email": "ken@butterbase.ai"
}
}
]
}
---
description: "Use the app's AI gateway: chat completions, embeddings, list models, configure defaults and BYOK, check usage"
---
Use the butterbase:ai skill to guide this process.
---
description: "Configure OAuth providers, auth hooks, JWT lifetimes, and service keys for a Butterbase app"
---
Use the butterbase:auth-setup skill to guide this process.
---
description: "Build a complete Butterbase app from scratch (init → schema → RLS → auth → functions → deploy)"
---
Use the butterbase:build-app skill to guide this process step by step.
---
description: "Learn how to contribute to the Butterbase codebase (add MCP tools, routes, migrations)"
---
Use the butterbase:contributing skill for guidance on the monorepo architecture and development workflow.
---
description: "Debug Row-Level Security issues (access denied, users seeing wrong data, policy problems)"
---
Use the butterbase:debug-rls skill to systematically diagnose and fix the issue.
---
description: "Deploy a frontend (React, Next.js, or static HTML) to a live URL on Butterbase"
---
Use the butterbase:deploy-frontend skill to guide this process step by step.
---
description: "Build a stateful Durable Object on Butterbase: chat rooms, multiplayer, rate limiters, long-running agents"
---
Use the butterbase:durable-objects skill to guide this process.
---
description: "Develop, deploy, or debug a Butterbase serverless function"
---
Use the butterbase:function-dev skill to guide this process.
---
description: "Move an app between regions, check migration status, abort or reverse a move, manage retained source replicas"
---
Use the butterbase:migrations skill to guide this process.
---
description: "Build a RAG knowledge base on Butterbase: ingest documents, run semantic search, generate synthesized answers"
---
Use the butterbase:rag-dev skill to guide this process.
---
description: "Enable WebSocket realtime subscriptions on Butterbase tables, or debug clients receiving no events"
---
Use the butterbase:realtime skill to guide this process.
---
description: "Design or modify a Butterbase database schema using the declarative DSL"
---
Use the butterbase:schema-design skill to guide this process.
---
description: "Upload, download, or manage files in Butterbase storage (presigned URLs, ACLs, object IDs)"
---
Use the butterbase:storage skill to guide this process.
MIT License
Copyright (c) 2026 Butterbase
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
name: ai
description: Use when calling the app's AI gateway from agent tools — chat completions, embeddings, listing models, configuring defaults or BYOK, reading token/cost usage
---
# Butterbase AI Gateway
Every app has an LLM gateway with chat, embeddings, model listing, configuration, and usage reporting. One umbrella tool: **`manage_ai`**.
| Action | What it does | Returns |
|---|---|---|
| `chat` | Synchronous chat completion (no streaming) | OpenAI-shaped `{ choices: [...] }` |
| `embed` | Vector embeddings for string or string[] | OpenAI-shaped `{ data: [{ embedding: [...] }] }` |
| `list_models` | Available models with capabilities | `{ models: AiModel[] }` |
| `get_config` | Current AI config (default model, BYOK key flag, etc.) | `AiConfig` |
| `update_config` | Set defaults, allowed models, max tokens, BYOK | `AiConfig` |
| `get_usage` | Token + cost aggregate over a window | usage record |
---
## 1. Chat
```
manage_ai({
action: "chat",
app_id,
messages: [
{ role: "system", content: "You are a helpful assistant." },
{ role: "user", content: "What's RAG?" }
],
model: "openai/gpt-4o-mini", // optional — falls back to app's default
temperature: 0.2, // optional
max_tokens: 500 // optional
})
```
This action sets `stream: false` deliberately — agent tools don't stream. If you need partial-token deltas, drive the SDK's `ai.chatStream(…)` from inside a function or DO instead.
`messages[].content` can be a string or an array of content parts (`{ type: "text", text }`, `{ type: "image_url", image_url: {...} }`, `{ type: "video_url", video_url: {...} }`).
---
## 2. Embed
```
manage_ai({
action: "embed",
app_id,
input: "hello world", // or ["a", "b", "c"]
model: "openai/text-embedding-3-small", // optional
encoding_format: "float" // or "base64"
})
```
---
## 3. List models
```
manage_ai({ action: "list_models", app_id })
// → { models: [{ id, provider, capabilities: ["chat", "embed", ...], context_window, pricing }, ...] }
```
Use this to discover what the app can call — capabilities + context window matter when picking a model.
---
## 4. Configure
```
manage_ai({
action: "update_config",
app_id,
config: {
defaultModel: "openai/gpt-4o-mini",
allowedModels: ["openai/gpt-4o-mini", "anthropic/claude-haiku-4-5"],
maxTokensPerRequest: 4000,
byokKey: "..." // optional — rotates the customer-supplied OpenRouter / Anthropic key
}
})
```
- `maxTokensPerRequest` is server-clamped to 1–100000.
- `allowedModels` is a whitelist — empty means all models the provider exposes.
- Setting `byokKey` switches the app to route through that customer key. Clear it by passing `byokKey: ""` (returns to platform pool).
---
## 5. Usage
```
manage_ai({
action: "get_usage",
app_id,
startDate: "2026-05-01",
endDate: "2026-05-31"
})
```
Returns aggregate token counts + cost. Useful for billing reconciliation, spending-cap diagnostics, and showing dashboards.
---
## 6. Common pitfalls
- **Trying to stream from a tool** — `manage_ai` is synchronous. Use the SDK inside a function for streamed deltas.
- **Sending `stream: true` in the body** — the tool ignores it; always wired to `false`.
- **Hardcoding `model`** — better to omit, let the app's `defaultModel` win, and surface that knob via `update_config`.
- **Skipping `list_models` before suggesting one** — model availability shifts; verify before recommending.
---
## 7. What this skill does NOT cover
- Streaming chat — use the SDK (`ai.chatStream`) inside a function or DO.
- Vector storage / retrieval — see `butterbase:rag-dev` (RAG collections wrap embeddings + search together).
- AI in deployed functions — they import `@butterbase/sdk` and call `client.ai.*` directly; no MCP needed at runtime.
---
name: auth-setup
description: Use when configuring OAuth providers (Google/GitHub/Apple/X/etc.), setting up post-login auth hooks, tuning JWT lifetimes, or generating service API keys
---
# Butterbase Auth Setup
Two umbrella tools cover end-user authentication:
- **`manage_oauth`** — provider configuration (Google, GitHub, Apple, X, custom)
- **`manage_auth_config`** — auth hooks, JWT lifetimes, service key generation
For broad app build-out, see also `butterbase:build-app`. This skill is the deep dive.
---
## 1. The role model
Every request runs under one of three database roles:
| Auth header | Role | `current_user_id()` | RLS |
|-------------|------|---------------------|-----|
| _none_ | `butterbase_anon` | NULL | enforced; default deny |
| End-user JWT (issued by `manage_oauth` or email login) | `butterbase_user` | user UUID | enforced |
| Service key (`bb_sk_*`) | `butterbase_service` | NULL | bypassed |
Auth is what transforms a request into the right role. RLS is what filters the data. Both must be configured.
---
## 2. Configure an OAuth provider
```js
manage_oauth({
app_id: "app_abc123",
action: "configure",
provider: "google",
client_id: "123456789.apps.googleusercontent.com",
client_secret: "GOCSPX-...",
redirect_uris: ["https://api.butterbase.ai/auth/app_abc123/oauth/google/callback"]
// scopes / authorization_url / token_url / userinfo_url / provider_metadata are auto-filled for built-in providers
})
```
**Built-in providers (URLs and scopes pre-filled):** `google`, `github`, `discord`, `facebook`, `linkedin`, `microsoft`, `apple`, `x`.
**Custom providers:** pass `authorization_url`, `token_url`, `userinfo_url`, and `scopes` explicitly.
### Redirect URI format
```
https://api.butterbase.ai/auth/{app_id}/oauth/{provider}/callback
```
Register **this exact URI** in the provider's developer console. Mismatch is the most common reason OAuth flows fail.
### Provider quirks
| Provider | Quirk |
|----------|-------|
| `apple` | Requires `provider_metadata: { teamId, keyId, privateKey }`. Apple only returns the user's name on **first** auth and uses POST callback (handled automatically). |
| `x` | Does not return email. Butterbase synthesises `{username}@users.noreply.x.local` for the user record. |
| `facebook` | Default scopes `email`, `public_profile`. |
| `google` | Standard. |
| `github` | Standard. |
### List, update, delete
```js
manage_oauth({ app_id, action: "get" }) // list all providers (secrets redacted)
manage_oauth({ app_id, action: "get", provider: "google" }) // single provider
manage_oauth({ app_id, action: "update", provider: "google", client_secret: "new-secret" })
manage_oauth({ app_id, action: "delete", provider: "google" }) // disables future logins; existing sessions valid until expiry
```
### Frontend flow
```
GET https://api.butterbase.ai/auth/{app_id}/oauth/{provider}?redirect_to=https://yourapp.com/auth/callback
```
User signs in at the provider, gets bounced back to `redirect_to` with `access_token` and `refresh_token` as query params. The Butterbase SDK wraps this:
```ts
await client.auth.signInWithOAuth({ provider: "google" });
const { user, accessToken } = await client.auth.getSession();
```
---
## 3. Tune JWT lifetimes
```js
manage_auth_config({
app_id: "app_abc123",
action: "update_jwt",
accessTokenTtl: "15m", // formats: "15m", "1h", "2h", "1d"
refreshTokenTtlDays: 30 // integer days
})
```
Defaults: 15-minute access tokens, 7-day refresh tokens.
| Use case | `accessTokenTtl` | `refreshTokenTtlDays` |
|----------|------------------|------------------------|
| High-security (banking, admin) | `5m`–`15m` | `1`–`7` |
| Standard SaaS | `15m` (default) | `30` |
| Low-friction consumer apps | `1h` | `90` |
**Important:** changes apply only to **new** tokens. Active tokens keep their original expiration — there is no global revoke. Treat TTL changes as forward-looking only.
---
## 4. Auth hooks (run code after every login)
A post-auth function is a deployed Butterbase function invoked **fire-and-forget** after every successful auth event (OAuth login, email login, email signup).
### Wire it up
```js
// 1. Deploy the function first (see butterbase:function-dev)
deploy_function({
app_id: "app_abc123",
name: "after-auth",
code: postAuthHandlerCode,
trigger: { type: "http", config: { auth: "none" } }
})
// 2. Register it as the auth hook
manage_auth_config({
app_id: "app_abc123",
action: "configure_auth_hook",
post_auth_function: "after-auth"
})
// To remove the hook later: pass post_auth_function: null
```
The function **must already exist** when you configure the hook.
### Payload shape
The function receives a POST with this body:
```json
{
"event": "oauth_login | login | signup",
"user": {
"id": "uuid",
"email": "...",
"provider": "google | github | email | ...",
"display_name": "...",
"avatar_url": "..."
},
"isNewUser": true,
"provider": "google"
}
```
The function runs as `butterbase_service` (RLS bypassed, `ctx.user` is `null`). Use `body.user.id` to know who just logged in.
### Common uses
```ts
// after-auth/index.ts
export async function handler(req, ctx) {
const { user, isNewUser, event } = await req.json();
if (isNewUser) {
// 1. Create profile row
await ctx.db.query(
"INSERT INTO profiles (user_id, display_name) VALUES ($1, $2) ON CONFLICT DO NOTHING",
[user.id, user.display_name]
);
// 2. Send welcome email (via env-stored API key)
ctx.waitUntil(sendWelcomeEmail(ctx.env.RESEND_API_KEY, user.email));
}
// 3. Audit log on every login
await ctx.db.query(
"INSERT INTO login_log (user_id, event, provider) VALUES ($1, $2, $3)",
[user.id, event, ctx.user ?? null]
);
return new Response("ok", { status: 200 });
}
```
> Auth hooks are fire-and-forget. Don't return data the user needs — they won't see it. Use them for side effects only.
---
## 5. Service keys (`bb_sk_*`)
Service keys grant **full access** to all your apps and bypass RLS. Treat them like passwords.
### Generate
```js
manage_auth_config({
action: "generate_service_key",
name: "CI/CD pipeline"
})
// → { key: "bb_sk_a1b2c3...", key_id, prefix, name, created_at }
```
> The full key is returned **once**. Store it immediately in your secret manager — you cannot retrieve it again. If you lose it, generate a new one and revoke the old.
### List & revoke
```js
manage_api_keys({ action: "list" })
manage_api_keys({ action: "revoke", key_id: "uuid-..." })
```
`list` returns metadata only (prefix, name, last_used_at), never the secret. `revoke` is **immediate and irreversible**.
### Rotation workflow
1. `manage_auth_config` (`generate_service_key`) — create the new key.
2. Update CI/CD, MCP config, scripts to use the new key.
3. Verify with a smoke test (e.g. `manage_app` `list`).
4. `manage_api_keys` (`revoke`) — kill the old key.
Do steps 1–3 **before** step 4 to avoid downtime.
---
## 6. Anti-patterns
| Don't | Do |
|-------|----|
| Hardcode `bb_sk_*` keys in client code or commit them to git | Store in env vars / secret manager |
| Reuse one OAuth app between dev, staging, prod | Separate OAuth apps per environment, with their own redirect URIs |
| Use a service key from frontend code "for convenience" | Frontends use end-user JWTs; service keys are server-only |
| Increase `accessTokenTtl` to "fix" frequent re-auth | Use the refresh token; SDK handles this automatically |
| Forget that `manage_oauth` `delete` only stops *new* logins | Existing sessions remain valid until they expire — rotate JWT keys via support if you need a hard kill |
| Put critical logic in the auth hook | Hooks are fire-and-forget. Errors don't surface to the user. Keep them to side effects. |
| Log the full service key in audit / debug output | Log only the prefix (`bb_sk_a1b2c3`) — secrets must never appear in logs |
---
## 7. Quick reference
| Task | Tool |
|------|------|
| Add Google OAuth | `manage_oauth` (`configure`) |
| List OAuth providers | `manage_oauth` (`get`) |
| Set post-login hook | `manage_auth_config` (`configure_auth_hook`) |
| Change JWT lifetimes | `manage_auth_config` (`update_jwt`) |
| Create service key | `manage_auth_config` (`generate_service_key`) |
| List service keys | `manage_api_keys` (`list`) |
| Revoke service key | `manage_api_keys` (`revoke`) |
---
name: durable-objects
description: Use when building stateful per-key actors — chat rooms, multiplayer rooms, rate limiters, long-running agents, leaderboards — that need persistent in-memory + storage state across requests
---
# Butterbase Durable Objects
Durable Objects (DOs) are **stateful per-key actors** running on Cloudflare Workers. Each instance has its own in-memory state and a built-in transactional KV store. Use one when state must survive across requests for a single room/user/agent. For stateless work, use a serverless function instead (`butterbase:function-dev`).
One tool: **`manage_durable_objects`**.
---
## 1. The mental model
```
Class: ChatRoom (deployed once)
├── instance "lobby" ─► in-memory state + state.storage + WebSockets
├── instance "general" ─► separate state, separate sockets
└── instance "user-123" ─► separate again
Each URL https://<app>.butterbase.dev/_do/chat-room/<instance-id>
gets routed to the instance with that id. State is isolated per id.
```
A class is shared code; an **instance** is a unique key (`/lobby`, `/general`, `/user-123`). Different ids = different state. There is no shared cross-instance state.
---
## 2. Constraints (read these first)
- **One TypeScript file per class.** No npm imports. Only `import { ... } from 'cloudflare:workers'` is allowed.
- **Exactly one exported class.** `export class Foo { ... }` — no extra exports, no helpers re-exported.
- **PascalCase class name** in source; **kebab-case** for the URL name (e.g. `ChatRoom` ↔ `chat-room`).
- File size: ≤ 5 MB. Total of all DO classes per app: ≤ 10 MB compressed.
- ≤ 5 DO classes per app (v1).
- **No service bindings yet.** Functions reach DOs over HTTP, not via env binding.
- `state.storage` keys/values capped at 128 KB. Larger blobs → Butterbase Storage.
- **WebSockets need `access_mode: "public"`** because browsers can't send custom headers on WS upgrade. Validate auth tokens inside `fetch()` instead.
---
## 3. The class skeleton
```typescript
export class ChatRoom {
constructor(public state: DurableObjectState, public env: Env) {}
async fetch(req: Request): Promise<Response> {
if (req.headers.get("Upgrade") === "websocket") {
const pair = new WebSocketPair();
this.state.acceptWebSocket(pair[1]);
return new Response(null, { status: 101, webSocket: pair[0] });
}
if (req.method === "POST") {
// handle plain HTTP
}
return Response.json({ ok: true });
}
// Optional WebSocket lifecycle hooks — called by the runtime
async webSocketMessage(ws: WebSocket, msg: string | ArrayBuffer) {
if (typeof msg !== "string") return; // guard binary
for (const peer of this.state.getWebSockets()) {
try { peer.send(msg); } catch {}
}
}
async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean) {}
async webSocketError(ws: WebSocket, err: Error) {}
}
```
Key APIs:
| API | Purpose |
|-----|---------|
| `state.storage.get/put/delete/deleteAll/list` | Async transactional KV store |
| `state.acceptWebSocket(ws)` | Hold a WS connection; runtime routes messages to `webSocketMessage` |
| `state.getWebSockets()` | All active WS connections for this instance |
| `new WebSocketPair()` | Returns `[client, server]` — return `client` to browser, accept `server` |
| `this.env.KEY` | Read DO env vars (set via `set_env`) |
---
## 4. Deploy
```js
manage_durable_objects({
app_id: "app_abc123",
action: "deploy",
name: "chat-room", // kebab-case URL name
code: "<single TypeScript file>",
access_mode: "authenticated" // "public" | "authenticated" (default) | "service_key"
})
// → { id, name, class_name, status: "READY", access_mode, last_deployed_at }
```
Re-deploying with the same `name` updates the class; old in-memory state is evicted on next request. Storage persists across redeploys (same instance id = same `state.storage`).
### Access modes
| Mode | Auth required |
|------|---------------|
| `public` | None — validate tokens inside `fetch()` if you need any |
| `authenticated` (default) | End-user JWT in `Authorization: Bearer <token>` |
| `service_key` | Butterbase service key — backend-to-backend |
> The dispatcher only checks header **shape**, not validity. For real auth on production DOs, validate the token inside `fetch()`.
---
## 5. Address an instance
```
https://<your-subdomain>.butterbase.dev/_do/<name>/<instance-id>
```
- `<name>` = kebab-case DO name from deploy
- `<instance-id>` = anything you choose (`/lobby`, `/user-123`, `/main`)
Both HTTP and WebSocket upgrade work on the same URL.
```js
// HTTP
fetch("https://app.butterbase.dev/_do/chat-room/lobby", {
method: "POST",
body: JSON.stringify({ user: "alice", text: "hi" })
});
// WebSocket
const ws = new WebSocket("wss://app.butterbase.dev/_do/chat-room/lobby");
```
Different instance ids → completely separate state. There is no shared global view; if you need one, build it yourself (e.g. a `/registry` instance that other instances report into).
---
## 6. Env vars
Env vars are app-wide across all DO classes. Setting one redeploys the DO Worker — existing in-memory state is evicted, active WS connections drop.
```js
manage_durable_objects({ app_id, action: "list_env" }) // keys only, never values
manage_durable_objects({ app_id, action: "set_env", key: "AI_API_KEY", value: "sk-..." })
manage_durable_objects({ app_id, action: "delete_env", key: "AI_API_KEY" })
```
- Keys must match `^[A-Z_][A-Z0-9_]*$` (UPPER_SNAKE).
- A key can't collide with a DO class binding (e.g. `chat-room` reserves `CHAT_ROOM`).
- Read in code as `this.env.KEY_NAME`.
---
## 7. Lifecycle, listing, deletion
```js
manage_durable_objects({ app_id, action: "list" })
manage_durable_objects({ app_id, action: "get", name: "chat-room" }) // includes full source + status + error_message
manage_durable_objects({ app_id, action: "delete", name: "chat-room" }) // IRREVERSIBLE: purges all instances + storage
manage_durable_objects({ app_id, action: "usage", name: "chat-room" }) // do_requests, do_cpu_ms (refreshed every 15 min)
```
Status transitions: `PENDING → BUILDING → READY` or `ERROR` (with `error_message`).
---
## 8. Patterns
### Chat room (broadcast)
```typescript
export class ChatRoom {
constructor(public state: DurableObjectState, public env: any) {}
async fetch(req: Request): Promise<Response> {
if (req.headers.get("Upgrade") === "websocket") {
const pair = new WebSocketPair();
this.state.acceptWebSocket(pair[1]);
const history = (await this.state.storage.get("messages")) ?? [];
pair[1].send(JSON.stringify({ type: "init", messages: history }));
return new Response(null, { status: 101, webSocket: pair[0] });
}
return Response.json(await this.state.storage.get("messages") ?? []);
}
async webSocketMessage(ws: WebSocket, msg: string | ArrayBuffer) {
if (typeof msg !== "string") return;
const history: any[] = (await this.state.storage.get("messages")) ?? [];
const parsed = JSON.parse(msg);
history.push(parsed);
await this.state.storage.put("messages", history.slice(-200));
for (const peer of this.state.getWebSockets()) {
try { peer.send(msg); } catch {}
}
}
}
```
### Sliding-window rate limiter
```typescript
export class RateLimiter {
constructor(public state: DurableObjectState, public env: any) {}
async fetch(req: Request): Promise<Response> {
const now = Date.now();
const window = 60_000;
const limit = 100;
const requests: number[] = (await this.state.storage.get("requests")) ?? [];
const recent = requests.filter(t => now - t < window);
if (recent.length >= limit) return new Response("rate limit", { status: 429 });
recent.push(now);
await this.state.storage.put("requests", recent);
return Response.json({ ok: true, remaining: limit - recent.length });
}
}
```
Address one instance per actor: `/_do/rate-limiter/<user-id>` or `/_do/rate-limiter/<api-key-hash>`.
### Long-running AI agent
```typescript
export class Agent {
constructor(public state: DurableObjectState, public env: any) {}
async fetch(req: Request): Promise<Response> {
const { prompt } = await req.json();
const r = await fetch(this.env.AI_API_ENDPOINT, {
method: "POST",
headers: { Authorization: `Bearer ${this.env.AI_API_KEY}` },
body: JSON.stringify({ prompt })
});
const data = await r.json();
await this.state.storage.put("last_response", data);
return Response.json(data);
}
}
```
One DO instance per conversation; storage holds the rolling history.
### Counter / leaderboard
Each instance is its own counter. `/_do/leaderboard/main` and `/_do/leaderboard/season-2` have independent state — no coordination needed in v1.
---
## 9. Errors
Build-time (rejected on deploy):
| Code | Cause |
|------|-------|
| `NO_EXPORTED_CLASS` | Source doesn't export a class |
| `MULTIPLE_EXPORTS` | More than one export, or export of non-class |
| `INVALID_IMPORT` | Imported anything other than `cloudflare:workers` |
| `CLASS_NAME_PARSE_ERROR` | TS AST couldn't extract the class name |
| `NAME_REGEX_VIOLATION` | `name` doesn't match `^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$` |
| `QUOTA_DO_LIMIT` | Already 5 classes for this app |
| `BUNDLE_SIZE_EXCEEDED` / `SOURCE_SIZE_EXCEEDED` | Over the 10 MB / 5 MB limits |
Runtime / async-deploy:
- Status `ERROR` after a deploy → check `manage_durable_objects` (`get`) `error_message`.
- WebSocket message handler only fires if you called `state.acceptWebSocket(ws)` — easy to forget.
- `webSocketMessage` receives `string | ArrayBuffer`. Always guard before `JSON.parse`.
---
## 10. Anti-patterns
| Don't | Do |
|-------|----|
| Try to share state between instances directly | Pick a single "registry" instance and have others fetch into it |
| Use a DO for stateless HTTP work | Use a function — DOs cost more and have stricter constraints |
| Rely on dispatcher access_mode for real auth | Validate JWTs inside `fetch()` for production |
| Use `access_mode: "authenticated"` for browser WebSockets | Use `public` + token-in-query-string + manual validation; browsers can't set headers |
| Stuff > 128 KB blobs into `state.storage` | Use Butterbase Storage and store the `object_id` in DO state |
| Update env vars in tight loops | Each `set_env` redeploys the Worker — drops connections |
| Forget redeploy semantics | Code change or env change evicts all instances; storage survives but in-memory caches don't |
---
name: migrations
description: Use when moving a Butterbase app between regions, checking migration progress, aborting a stuck move, reverse-rolling a completed move, or tearing down retained source replicas after a move is stable
---
# Butterbase App Migrations (Multi-Region)
Moving an app between regions is an orchestrated migration: data dumped from source, restored in dest, blobs copied, runtime brought up, then traffic flipped. Source replica is retained for safe rollback until you tear it down.
Four small tools wrap the orchestration:
| Tool | Purpose |
|---|---|
| `list_regions` | Discover what regions exist before suggesting one. |
| `move_app` | Start a migration. Returns a `migration_id`. |
| `move_app_status` | Read the current step + replica state of a specific migration. |
| `manage_migrations` | Operational ops: `get_active`, `abort`, `reverse`, `list_source_replicas`. |
| `teardown_source_replica` | Decommission the retained source replica once you're confident. |
---
## 1. The step sequence
A successful move walks through 11 steps in order:
```
requested → reserving_dest → blocking_writes → dumping_data →
restoring_data → copying_blobs → copying_runtime → flipping_routing →
setting_up_reverse_replication → unblocking_writes → completed
```
The cutover happens at `flipping_routing`. Before that, the app is still served from the source. After that, traffic is on the destination.
Terminal states: `completed`, `aborted`, `failed`.
---
## 2. Start a move
```
list_regions → confirm dest region is supported
move_app(app_id, dest_region) → { migration_id, status: "queued" }
```
Then poll with `move_app_status({ app_id, migration_id })` every few seconds (or call `manage_migrations({ action: "get_active", app_id })` if you've forgotten the id).
---
## 3. Recovery: pick the right escape hatch
Stuck or wrong choice? Different state, different tool:
- **Before `flipping_routing`** (still in dump / restore / copy phases): use `manage_migrations` `action: "abort"`. Cancels cleanly; no data flipped.
- **After `flipping_routing`** (already cut over): abort is locked. Use `manage_migrations` `action: "reverse"`. This rolls back to source — works only while the source replica is still retained (i.e. you haven't called `teardown_source_replica` yet).
- **Move is done and stable** (the new region is serving traffic without issues for a while): use `teardown_source_replica({ migration_id })`. Stops paying for source-region storage. After this, `reverse` is no longer available.
---
## 4. Inspecting in-flight + retained state
- `manage_migrations({ action: "get_active", app_id })` → `{ migration: AppMigration | null }`. Tells you if a move is in progress right now.
- `manage_migrations({ action: "list_source_replicas" })` → all retained replicas the caller owns. Call this before any teardown so you know what you'd lose.
---
## 5. Common pitfalls
- **Calling `move_app` again while one is active** — backend returns 409 `ineligible`. Resolve by waiting for the active migration to terminate, or aborting it first.
- **Trying to abort after cutover** — returns 409. The path forward is `reverse`, not abort.
- **Tearing down a replica too early** — once it's gone, reverse-move is no longer possible. Verify the destination region has been serving traffic cleanly before teardown.
- **Forgetting `dest_region` is validated** — invalid slugs return 400. Always `list_regions` first if you're not sure.
---
## 6. What this skill does NOT cover
- Schema migrations (table/column changes) — use `butterbase:schema-design`.
- Region selection at app *creation* time — use `init_app` directly with the `region` parameter.
- Cross-region replica reads (none — Butterbase is single-region-active per app).
---
name: rag-dev
description: Use when building knowledge bases, ingesting documents, running semantic search, or adding LLM-synthesized Q&A over private content with Butterbase RAG
---
# Butterbase RAG (Retrieval-Augmented Generation)
Two tools cover the entire RAG surface:
- **`manage_rag_content`** — collections, document ingestion, status polling, deletion
- **`rag_query`** — semantic search, optional LLM synthesis
Documents are ingested asynchronously: text or files become embeddings stored in pgvector, and queries do a similarity search at runtime.
---
## 1. The mental model
```
Collection Documents Chunks
────────── ────────── ──────
"product-faq" ──────────────► doc_1 (PDF) ───────────► chunk 1, 2, 3...
doc_2 (text) ──────────► chunk 4, 5...
doc_3 (markdown) ──────► chunk 6...
```
A **collection** holds documents; a **document** is split into **chunks** and embedded; **`rag_query`** searches by cosine similarity across chunks within a collection.
`chunk_size` and `chunk_overlap` are set **once at collection creation** and immutable — to change them, delete and recreate the collection.
---
## 2. End-to-end workflow
```
┌────────────────────────────────────────────┐
│ 1. create_collection (once per knowledge) │
├────────────────────────────────────────────┤
│ 2. ingest_document (text OR storage_object)│
├────────────────────────────────────────────┤
│ 3. poll get_document_status until "ready" │
├────────────────────────────────────────────┤
│ 4. rag_query (with or without synthesis) │
└────────────────────────────────────────────┘
```
### Step 1 — create the collection
```js
manage_rag_content({
app_id: "app_abc123",
action: "create_collection",
name: "product-faq",
description: "Customer-facing product knowledge",
chunk_size: 512, // optional, default 512 tokens
chunk_overlap: 50, // optional, default 50 tokens
access_mode: "shared" // optional: "private" | "shared" | "custom"
})
```
| `access_mode` | Who can query |
|----------------|---------------|
| `private` (default) | Only the app owner / service key |
| `shared` | Any authenticated end-user with a valid JWT |
| `custom` | Respects RLS policies — for fine-grained control |
### Step 2a — ingest raw text
```js
manage_rag_content({
app_id: "app_abc123",
action: "ingest_document",
collection: "product-faq",
text: "Our return policy is 30 days from purchase...",
filename: "return-policy.txt", // optional, for display
metadata: { category: "returns", tier: "all" } // filter later in rag_query
})
// → { document_id: "doc_xyz", status: "pending" }
```
### Step 2b — ingest an uploaded file
Files come from `manage_storage` first. Two-step:
```js
// 1. Upload the file via the storage skill — get an object_id
const { object_id } = await uploadPdfViaStorage(...);
// 2. Hand that object_id to RAG ingestion
manage_rag_content({
app_id: "app_abc123",
action: "ingest_document",
collection: "product-faq",
storage_object_id: object_id,
filename: "manual.pdf",
metadata: { product: "v3" }
})
```
Supported file types: **PDF, TXT, Markdown, CSV, HTML, DOCX, XLSX, PPTX**.
### Step 3 — poll until ready
Ingestion is fire-and-forget. The document moves through `pending → processing → ready` (or `failed`). Poll:
```js
manage_rag_content({
app_id: "app_abc123",
action: "get_document_status",
collection: "product-faq",
document_id: "doc_xyz"
})
// → { id, filename, status: "processing", processedAt, errorMessage? }
```
Recommended cadence: poll every 2–5 seconds for the first minute, back off after that. Bigger files (large PDFs, XLSX) take longer.
### Step 4 — query
Two modes: raw retrieval (just chunks back) or synthesized (LLM answer + sources).
#### Raw retrieval
```js
rag_query({
app_id: "app_abc123",
collection: "product-faq",
query: "How long do I have to return an item?",
top_k: 5, // default 5, max 20
threshold: 0.7, // optional similarity floor (0..1)
filter: { category: "returns" } // optional metadata filter
})
// → { chunks: [{ text, score, document_id, metadata }, ...] }
```
#### Synthesized answer
```js
rag_query({
app_id: "app_abc123",
collection: "product-faq",
query: "How long do I have to return an item?",
synthesize: true,
model: "anthropic/claude-haiku-4.5" // default
})
// → { answer, chunks, model }
```
`synthesize: true` runs the retrieved chunks through an LLM and returns a grounded answer. `chunks` is still included so you can show citations.
---
## 3. Listing and cleanup
```js
manage_rag_content({ app_id, action: "list_collections" })
manage_rag_content({ app_id, action: "get_collection", name: "product-faq" })
manage_rag_content({ app_id, action: "list_documents", collection: "product-faq" })
manage_rag_content({ app_id, action: "delete_document", collection: "product-faq", document_id: "doc_xyz" })
manage_rag_content({ app_id, action: "delete_collection", name: "product-faq" })
```
`get_collection` returns `{ name, description, accessMode, chunkSize, chunkOverlap, createdAt, documentCount: { pending, processing, ready, failed } }` — handy for a dashboard view.
> **Both `delete_document` and `delete_collection` are irreversible** and remove embeddings. To replace a document, delete then re-ingest.
---
## 4. Choosing chunk size and overlap
| Use case | Suggested `chunk_size` | `chunk_overlap` |
|----------|------------------------|------------------|
| Q&A over short FAQs / docs | 256–512 | 50 |
| Long-form documentation, manuals | 512–1024 | 100 |
| Code or structured content | 1024–2048 | 0–50 |
| Conversational logs / transcripts | 256 | 50 |
Larger chunks preserve more context but reduce retrieval granularity (you may pull in irrelevant nearby content). Overlap prevents semantic splits at boundaries from losing meaning. **You can't change these without recreating the collection** — pick them deliberately the first time.
---
## 5. Metadata-driven filtering
Anything you pass in `metadata` at ingest time is available as a filter at query time. Use it to scope queries:
```js
// at ingest:
metadata: { product: "v3", region: "EU", language: "en" }
// at query:
filter: { product: "v3", language: "en" }
```
Filters are exact-match key/value. There's no full-text search beyond chunk content; design your metadata schema to match how you'll segment queries.
---
## 6. Common patterns
### Customer-support bot
1. Create `support-kb` (access_mode: `shared`).
2. Ingest your help-center articles (markdown) and product PDFs.
3. From a serverless function: `rag_query` with `synthesize: true`, return the answer + top 3 chunks as citations.
### Per-tenant knowledge base
1. Create one collection per tenant (`access_mode: "custom"`).
2. Tag every document with `metadata: { tenant_id }`.
3. Query with `filter: { tenant_id: ctx.user.tenant_id }` from a function.
4. Use RLS on the wrapping table to gate which tenant a user can query.
### Versioned docs
Tag with `metadata: { version: "v3" }`. Query with `filter: { version: "v3" }`. To deprecate `v2`, delete just those documents — no need to rebuild the collection.
---
## 7. Errors and pitfalls
| Error | Cause |
|-------|-------|
| `RESOURCE_NOT_FOUND` | App / collection / document doesn't exist |
| `VALIDATION_DUPLICATE_NAME` | Collection name already taken |
| `VALIDATION_ERROR` | `ingest_document` with neither `text` nor `storage_object_id` |
| `COLLECTION_EMPTY` | `rag_query` against a collection with no `ready` docs |
**Pitfalls:**
- Polling too aggressively wastes quota; backoff after the first minute.
- `chunk_size` / `chunk_overlap` are immutable — get them right up front.
- `synthesize: true` adds LLM latency + cost. For low-latency UX, do raw retrieval and synthesize on the frontend asynchronously.
- Metadata is exact-match only — no LIKE, no ranges. Pre-bucket continuous values (e.g. `tier: "free" | "pro"`) before ingesting.
- File ingestion **requires** prior upload to `manage_storage`; you cannot stream raw bytes into `ingest_document`.
- A failed document stays in the collection with `status: "failed"` and an `errorMessage`. Delete and re-ingest to retry.
---
name: realtime
description: Use when enabling WebSocket subscriptions for live database changes, presence/multiplayer state, or when debugging clients that connect but receive no events
---
# Butterbase Realtime
Live database change notifications over WebSocket, with per-row RLS enforcement. Once a table is enabled, INSERT/UPDATE/DELETE events stream to subscribed clients **filtered by the same RLS policies** that gate reads.
One tool: **`manage_realtime`** with two actions: `configure` and `get`.
---
## 1. The mental model
```
Postgres (data plane) Control API Browser
───────────────────── ──────────── ────────
INSERT/UPDATE/DELETE ──trigger──► realtime.changes ──WAL listener─► WebSocket ──► client
└── RLS check per (role, user) ──► filter rows
```
When you `configure` a table:
1. A Postgres trigger is installed → writes every change to `realtime.changes`.
2. A LISTEN connection in `RealtimeManager` reads those changes.
3. For each connected client, the change is RLS-checked **as that user** before broadcasting.
4. Clients only receive events for rows they could read with a regular SELECT.
---
## 2. Prerequisites
Before calling `configure`, the table must:
1. **Exist.** Run `manage_schema` (`action: "apply"`) first. Realtime won't auto-create it.
2. **Have RLS configured (if you care about isolation).** Realtime respects whatever policies exist via `manage_rls`. **No policies = all events flow to all users of that role.** This is the #1 silent leak.
3. **Have a primary key.** RLS checks query by PK. Tables without one can't be realtime-enabled cleanly.
---
## 3. Configure tables
```js
manage_realtime({
app_id: "app_abc123",
action: "configure",
tables: ["messages", "presence", "documents"]
})
// → [{ table: "messages", status: "enabled" }, ...]
```
- Idempotent — already-enabled tables are skipped.
- All three events (INSERT / UPDATE / DELETE) are enabled together; per-event filtering happens client-side via subscription `filter`.
- Validation: every named table must exist or you get `VALIDATION_TABLE_NOT_FOUND`.
### Inspect current state
```js
manage_realtime({ app_id: "app_abc123", action: "get" })
// → {
// tables: [{ table_name, enabled, trigger_installed, drift, created_at, updated_at }, ...],
// active_connection: true,
// websocket_url: "wss://api.butterbase.dev/v1/app_abc123/realtime"
// }
```
`drift: true` means the control-plane config says enabled but the data-plane trigger is missing — typically after a schema migration that dropped/recreated the table. Re-run `configure` to repair.
---
## 4. Connect from a client
```
wss://api.butterbase.dev/v1/{app_id}/realtime?token={JWT_or_API_KEY}
```
Browsers can't set custom headers on WebSocket upgrade, so the JWT goes in the query string. Server clients can use `Authorization: Bearer ...` instead.
```js
const ws = new WebSocket(
`wss://api.butterbase.dev/v1/${appId}/realtime?token=${userJwt}`
);
ws.onopen = () => {
ws.send(JSON.stringify({ type: "subscribe", table: "messages" }));
};
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === "change") handleChange(msg); // { type, table, op, record, old_record, timestamp }
};
```
The Butterbase SDK wraps this:
```ts
const realtime = client.realtime(appId, userJwt);
realtime.subscribe("messages", (change) => console.log(change.op, change.record));
```
### Welcome and protocol
On connect, the server sends:
```json
{ "type": "connected", "app_id": "app_abc123", "role": "butterbase_user" }
```
Then a heartbeat every 30s:
```json
{ "type": "heartbeat", "timestamp": "..." }
```
### Client → server messages
| Type | Body | Purpose |
|------|------|---------|
| `subscribe` | `{ table, filter? }` | Subscribe to changes; optional client-side filter `{ col: value }` |
| `unsubscribe` | `{ table }` | Stop receiving |
| `presence_track` | `{ metadata }` | Announce yourself with arbitrary metadata (cursor, status) |
| `event` | `{ event, payload }` | Trigger a function with `trigger: { type: "websocket", config: { event } }` |
### Server → client messages
| Type | Body |
|------|------|
| `change` | `{ table, op: "INSERT"\|"UPDATE"\|"DELETE", record, old_record, timestamp }` |
| `presence_state` | `{ clients: [{ client_id, user_id, metadata }] }` |
| `heartbeat` | `{ timestamp }` |
---
## 5. RLS enforcement (the critical pitfall)
For each broadcast, the server runs (roughly):
```sql
SET LOCAL ROLE butterbase_user;
SET LOCAL request.jwt.claim.sub = '{user_id}';
SELECT 1 FROM "{table}" WHERE "{pk}" = {record_pk} LIMIT 1;
```
If the row is **not visible** under RLS, the change is **silently dropped** for that client. There is no error.
**Common consequences:**
- Client connects, sees `connected`, subscribes — but receives no events. → RLS too restrictive (or no policies at all + access mode `authenticated`).
- Client receives some events but not others. → RLS works for those rows; others are filtered out (often correct).
- Service key clients see everything. → Service bypasses RLS. Don't use this to "verify realtime works" if testing user-scoped behaviour.
**Always test with a real end-user JWT**, not the service key.
### Anonymous clients
If `manage_app` access mode is `authenticated`, anonymous WebSocket connections are rejected with close code `1008 (Policy Violation)`. To allow anon, the app must be in `public` mode AND the table must have a permissive policy for `butterbase_anon`.
---
## 6. Connection lifecycle
| Close code | Meaning |
|------------|---------|
| `1008` | App requires authentication, no token provided |
| `1013` (try again later) | Plan limit hit (`maxRealtimeListenersPerApp`) — upgrade |
| `1013` ("Realtime disabled by plan") | Free / starter tiers may have realtime off entirely |
| Normal close | Heartbeat missed, client disconnected, or server eviction |
The server caches table primary keys for 60s and batches RLS checks per `(role, user)` group, so connection cost is amortised.
---
## 7. Common patterns
### Live chat
1. `manage_schema` apply with a `messages` table (`id`, `room_id`, `user_id`, `body`, `created_at`).
2. `manage_rls` `create_user_isolation` with `user_column: "user_id"` plus a custom policy that allows reading messages where `room_id IN (SELECT room_id FROM members WHERE user_id = current_user_id())`.
3. `manage_realtime` `configure` with `tables: ["messages"]`.
4. Client connects, subscribes to `messages`, optionally filters `{ room_id: "..." }`.
### Live dashboard / activity feed
1. Add a `notifications` table with `user_id`.
2. RLS `create_user_isolation` so each user only sees their own.
3. Realtime configure → notifications stream straight to the right user, no extra filtering needed.
### Multiplayer cursor sharing
Use **presence**, not table changes:
```js
ws.send(JSON.stringify({
type: "presence_track",
metadata: { cursor: { x: 100, y: 50 }, color: "#f00" }
}));
```
Other clients receive `presence_state` updates with everyone's metadata. No DB writes.
### Debugging "client connects but no events"
1. Confirm trigger installed: `manage_realtime` `get` → `trigger_installed: true`, `drift: false`.
2. Confirm RLS allows the user to SELECT the row: `select_rows` with `as_role: "user", as_user: "<id>"` → does the row appear?
3. If yes to both and still no events: check for plan limits in close codes; check that the change *actually* happened in Postgres (look at `realtime.changes`).
---
## 8. Anti-patterns
| Don't | Do |
|-------|----|
| Enable realtime before configuring RLS | Set up policies first; otherwise events leak across users |
| Use a service key from the frontend "to make it work" | Service bypasses RLS — ship-stopping leak. Use end-user JWTs. |
| Trust client-side `filter` for security | `filter` is just a convenience to reduce client-side work; RLS is the security boundary |
| Hold thousands of subscriptions per client | One connection, one or two subscribed tables — the server handles fan-out |
| Re-call `configure` in a loop on every page load | It's idempotent but each call still touches the DB. Configure once during app setup. |
| Send custom auth headers from the browser | WebSocket API can't set them — pass the JWT as `?token=` query param |
---
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
```js
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:
```json
{
"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.
```bash
curl -X PUT "{upload_url}" \
-H "Content-Type: image/jpeg" \
--data-binary @avatar.jpg
```
From the browser:
```js
await fetch(uploadUrl, {
method: "PUT",
headers: { "Content-Type": file.type },
body: file
});
```
### Step 3 — persist the `object_id`
```sql
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.
```js
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:
```js
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:
```js
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
```js
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
```js
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`.
```js
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_TYPE` (400) | `content_type` not in whitelist |
| `RESOURCE_NOT_FOUND` (404) | Object missing or caller unauthorized (deliberately ambiguous) |
| `S3_ERROR` (503) | Transient S3 failure — retry |
---
## 7. Common patterns
### User avatar
1. Upload form posts file → `manage_storage` (`upload_url`, `public: false`).
2. Browser PUTs to `upload_url`.
3. App calls `UPDATE users SET avatar_id = $object_id`.
4. On profile render: `manage_storage` (`download_url`, `object_id: user.avatar_id`).
### Public marketing image
Same flow, but `public: true` at upload time. Then the same download URL is reachable by anonymous visitors (no JWT needed) — useful for landing pages and OG images.
### File attachment with auth
For attachments where access depends on app-level rules richer than "owner or public" (e.g. "members of this workspace can read"), don't expose the presigned URL directly. Wrap downloads in a serverless function:
```ts
export async function handler(req, ctx) {
const { rows } = await ctx.db.query(
"SELECT object_id FROM attachments WHERE id = $1 AND workspace_id IN (SELECT workspace_id FROM members WHERE user_id = $2)",
[attachmentId, ctx.user.id]
);
if (!rows.length) return new Response("forbidden", { status: 403 });
const url = await getDownloadUrlServerSide(rows[0].object_id);
return Response.redirect(url, 302);
}
```
The function runs as `butterbase_user` and uses your tables' RLS to authorize, then mints a presigned URL with the service key.
---
## 8. Anti-patterns
| Don't | Do |
|-------|-----|
| Store the `s3_key` in your tables | Store the `object_id` UUID |
| Embed presigned URLs in HTML or DB rows | Re-fetch per render — they expire in 1 hour |
| Send the wrong `Content-Type` on PUT | Match exactly what you sent to `upload_url` |
| Call `upload_url` and forget step 3 | Always persist `object_id` after a successful PUT |
| Use `public: true` everywhere "just in case" | Default to private; opt files into public explicitly |
| Delete a file without clearing FKs | Update referencing rows first, then `action: "delete"` |
+2
-2

@@ -7,8 +7,8 @@ {

"name": "Butterbase",
"email": "support@butterbase.ai"
"email": "ken@butterbase.ai"
},
"homepage": "https://butterbase.ai",
"repository": "https://github.com/butterbase/butterbase",
"repository": "https://github.com/NetGPT-Inc/butterbase-plugin",
"license": "MIT",
"keywords": ["butterbase", "baas", "backend", "mcp", "database", "auth", "storage"]
}

@@ -19,14 +19,36 @@ # Butterbase

1. `init_app` — Create app, get `app_id` and `api_base`
2. `apply_schema` — Define tables declaratively (preview with `dry_run_schema`)
3. `create_user_isolation_policy` — Secure user-owned tables with RLS
4. `configure_oauth_provider` — Set up social sign-in (Google, GitHub, etc.)
2. `manage_schema` (`action: "apply"`) — Define tables declaratively (preview with `action: "dry_run"`)
3. `manage_rls` (`action: "create_user_isolation"`) — Secure user-owned tables with RLS
4. `manage_oauth` (`action: "configure"`) — Set up social sign-in (Google, GitHub, etc.)
5. `deploy_function` — Add backend logic (HTTP, cron, WebSocket triggers)
6. `create_frontend_deployment` + `start_frontend_deployment` — Deploy frontend to live URL
6. `create_frontend_deployment` + `manage_frontend` (`action: "start_deployment"`) — Deploy frontend to live URL
## Tool shape
Most operations live on a small set of `manage_*` umbrella tools and take an `action` enum:
- `manage_schema` — `get | dry_run | apply | list_migrations`
- `manage_rls` — `enable | create_policy | update_policy | create_user_isolation | list | delete`
- `manage_app` — `list | delete | pause | get_config | update_access_mode | secure | update_cors`
- `manage_oauth` — `configure | get | update | delete`
- `manage_auth_config` — `configure_auth_hook | update_jwt | generate_service_key`
- `manage_function` — `list | delete | get_logs | update_env`
- `manage_frontend` — `start_deployment | list_deployments | create_from_source | start_from_source | set_env | configure_custom_domain`
- `manage_edge_ssr` — `create | start | create_from_source | start_from_source | list`
- `manage_storage` — `upload_url | download_url | list | delete | update_config`
- `manage_rag_content` — `create_collection | list_collections | get_collection | delete_collection | ingest_document | list_documents | get_document_status | delete_document`
- `manage_realtime` — `configure | get`
- `manage_durable_objects` — `deploy | list | get | delete | usage | list_env | set_env | delete_env`
- `manage_migrations` — `get_active | abort | reverse | list_source_replicas`
- `manage_ai` — `chat | embed | list_models | get_config | update_config | get_usage`
- `manage_integrations`, `manage_billing`, `manage_api_keys`
Standalone tools (no `action`): `init_app`, `deploy_function`, `invoke_function`, `select_rows`, `insert_row`, `seed_database`, `create_frontend_deployment`, `rag_query`, `query_audit_logs`, `butterbase_docs`, `submit_suggestion`, `list_regions`, `move_app`, `move_app_status`, `teardown_source_replica`.
## Important Patterns
### Storage
- Persist `objectId` (UUID) from upload response — NOT `objectKey` (bucket path)
- `objectKey` is not a URL — it cannot be used as `img src` or `href`
- Resolve download URLs at render time via `generate_download_url(objectId)` — presigned URLs expire
- Persist `object_id` (UUID) from upload response — NOT `s3_key` (bucket path)
- `s3_key` is not a URL — it cannot be used as `img src` or `href`
- Resolve download URLs at render time via `manage_storage` (`action: "download_url"`, `object_id: ...`) — presigned URLs expire after 1 hour
- For lists with many files, resolve presigned URLs in parallel (`Promise.all`)

@@ -48,3 +70,3 @@

- Destructive operations require explicit opt-in: `_drop: ["table"]` or `_dropColumns: ["col"]`
- Preview changes with `dry_run_schema` before applying
- Preview changes with `manage_schema` (`action: "dry_run"`) before applying

@@ -63,3 +85,3 @@ ### Branding

| `overview` | Platform introduction and key features |
| `mcp` | All 42+ MCP tools with usage examples |
| `mcp` | All MCP tools with usage examples |
| `rest` | Auto-generated REST API (CRUD, filtering, sorting, pagination) |

@@ -96,1 +118,8 @@ | `auth` | End-user authentication (email/password, OAuth, JWT) |

| `butterbase:contributing` | Contributing to the Butterbase codebase (adding MCP tools, routes) |
| `butterbase:storage` | File uploads, downloads, presigned URLs, ACLs |
| `butterbase:rag-dev` | RAG collections, document ingestion, semantic search |
| `butterbase:auth-setup` | OAuth providers, auth hooks, JWT tuning, service keys |
| `butterbase:realtime` | WebSocket subscriptions for table changes (RLS-aware) |
| `butterbase:durable-objects` | Stateful per-key actors for chat, multiplayer, rate limiters |
| `butterbase:migrations` | Moving apps between regions and managing migrations |
| `butterbase:ai` | Using the AI gateway — chat, embeddings, models, BYOK |
{
"name": "@butterbase/plugin",
"version": "0.1.0",
"version": "0.1.1",
"description": "Claude Code plugin for Butterbase — AI-Native Backend-as-a-Service. Skills for app creation, schema design, deployment, debugging, and contributing.",
"keywords": ["butterbase", "claude-code-plugin", "mcp", "baas", "backend", "skills"],
"keywords": [
"butterbase",
"claude-code-plugin",
"mcp",
"baas",
"backend",
"skills"
],
"author": "Butterbase",

@@ -11,4 +18,3 @@ "license": "MIT",

"type": "git",
"url": "https://github.com/butterbase/butterbase",
"directory": "packages/plugin"
"url": "https://github.com/NetGPT-Inc/butterbase-plugin"
},

@@ -19,2 +25,3 @@ "files": [

"CLAUDE.md",
"commands",
"skills",

@@ -21,0 +28,0 @@ "README.md"

@@ -10,2 +10,4 @@ ---

> **Convention:** every JSON body below is the argument object for the tool named in its **Tool:** header. When the header reads `manage_schema` with `action: "apply"`, include `"action": "apply"` alongside the other fields when you make the call.
---

@@ -39,3 +41,3 @@

**Tool:** `generate_service_key`
**Tool:** `manage_auth_config` with `action: "generate_service_key"`

@@ -65,3 +67,3 @@ ```json

**Tool:** `dry_run_schema`
**Tool:** `manage_schema` with `action: "dry_run"`

@@ -89,3 +91,3 @@ ```json

**Tool:** `apply_schema`
**Tool:** `manage_schema` with `action: "apply"`

@@ -125,3 +127,3 @@ Below is a complete example for a **blog app** with posts and comments:

**Tool:** `get_schema`
**Tool:** `manage_schema` with `action: "get"`

@@ -142,3 +144,3 @@ ```json

- Use `references: "table.column"` for foreign keys (cascades must be set carefully)
- `apply_schema` is idempotent — safe to call again if schema is unchanged
- `manage_schema` action `apply` is idempotent — safe to call again if schema is unchanged

@@ -159,3 +161,3 @@ ---

**Tool:** `create_user_isolation_policy`
**Tool:** `manage_rls` with `action: "create_user_isolation"`

@@ -242,3 +244,3 @@ ```json

**Tool:** `get_rls_policies`
**Tool:** `manage_rls` with `action: "list"`

@@ -263,3 +265,3 @@ ```json

**Tool:** `configure_oauth_provider`
**Tool:** `manage_oauth` with `action: "configure"`

@@ -333,3 +335,3 @@ **Google example:**

**Tool:** `update_jwt_config`
**Tool:** `manage_auth_config` with `action: "update_jwt"`

@@ -413,3 +415,3 @@ ```json

To rotate secrets without redeploying code, use `update_function_env`.
To rotate secrets without redeploying code, call `manage_function` with `action: "update_env"`.

@@ -436,3 +438,3 @@ ### Test a Function

**Tool:** `get_function_logs`
**Tool:** `manage_function` with `action: "get_logs"`

@@ -468,3 +470,3 @@ ```json

**Tool:** `update_cors`
**Tool:** `manage_app` with `action: "update_cors"`

@@ -485,3 +487,3 @@ ```json

**Tool:** `set_frontend_env`
**Tool:** `manage_frontend` with `action: "set_env"`

@@ -554,3 +556,3 @@ ```json

**Tool:** `start_frontend_deployment`
**Tool:** `manage_frontend` with `action: "start_deployment"`

@@ -606,11 +608,11 @@ ```json

- [ ] **1. CORS configured for production domain** — `update_cors` includes the live frontend URL (not just localhost)
- [ ] **2. RLS enabled on all user-data tables** — `get_rls_policies` shows policies for every table holding user-generated content; no table is accidentally wide-open
- [ ] **2. RLS enabled on all user-data tables** — `manage_rls` (`action: "list"`) shows policies for every table holding user-generated content; no table is accidentally wide-open
- [ ] **3. OAuth redirect URIs point to production** — Provider developer consoles have the Butterbase callback URL registered; no localhost URIs are the only option in production
- [ ] **4. Frontend env vars set for production API URL** — `VITE_API_BASE` (or equivalent) points to `https://api.butterbase.ai/v1/{app_id}`, not a localhost URL
- [ ] **5. Error handling in all functions** — Every `deploy_function` handler returns appropriate HTTP status codes (400 for bad input, 401 for auth failures, 500 for unexpected errors) rather than throwing unhandled exceptions
- [ ] **6. JWT config reviewed** — `update_jwt_config` has been called with intentional token lifetimes; access token TTL is appropriate for the security sensitivity of the app (default 15m is reasonable)
- [ ] **7. Storage quotas checked** — `get_storage_objects` and app config reviewed; storage usage is within plan limits and `allowedContentTypes` are restricted to what the app actually needs
- [ ] **8. Functions tested with invoke_function** — Every HTTP function has been invoked with realistic payloads and edge cases (missing fields, invalid auth, large inputs) and returned correct responses
- [ ] **9. Frontend deployed and verified** — `list_frontend_deployments` shows a `READY` deployment; the live URL loads correctly in a browser and all API calls succeed
- [ ] **10. Monitoring and audit logs reviewed** — `query_audit_logs` shows no unexpected login failures or suspicious activity; `get_function_logs` shows no recurring errors in production traffic
- [ ] **6. JWT config reviewed** — `manage_auth_config` (`action: "update_jwt"`) has been called with intentional token lifetimes; access token TTL is appropriate for the security sensitivity of the app (default 15m is reasonable)
- [ ] **7. Storage quotas checked** — `manage_storage` (`action: "list"`) and app config reviewed; storage usage is within plan limits and `allowedContentTypes` are restricted to what the app actually needs
- [ ] **8. Functions tested with `invoke_function`** — Every HTTP function has been invoked with realistic payloads and edge cases (missing fields, invalid auth, large inputs) and returned correct responses
- [ ] **9. Frontend deployed and verified** — `manage_frontend` (`action: "list_deployments"`) shows a `READY` deployment; the live URL loads correctly in a browser and all API calls succeed
- [ ] **10. Monitoring and audit logs reviewed** — `query_audit_logs` shows no unexpected login failures or suspicious activity; `manage_function` (`action: "get_logs"`) shows no recurring errors in production traffic

@@ -623,9 +625,9 @@ ---

|---|---|
| 1 — Create App | `init_app`, `generate_service_key` |
| 2 — Schema | `dry_run_schema`, `apply_schema`, `get_schema` |
| 3 — RLS | `create_user_isolation_policy`, `create_policy`, `enable_rls`, `get_rls_policies`, `select_rows`, `insert_row` |
| 4 — Auth | `configure_oauth_provider`, `update_jwt_config` |
| 5 — Functions | `deploy_function`, `update_function_env`, `invoke_function`, `get_function_logs` |
| 6 — Frontend | `update_cors`, `set_frontend_env`, `create_frontend_deployment`, `start_frontend_deployment`, `list_frontend_deployments` |
| 7 — Production | `get_rls_policies`, `query_audit_logs`, `get_function_logs`, `get_storage_objects` |
| 1 — Create App | `init_app`, `manage_auth_config` (`generate_service_key`) |
| 2 — Schema | `manage_schema` (`dry_run`, `apply`, `get`) |
| 3 — RLS | `manage_rls` (`create_user_isolation`, `create_policy`, `enable`, `list`), `select_rows`, `insert_row` |
| 4 — Auth | `manage_oauth` (`configure`), `manage_auth_config` (`update_jwt`) |
| 5 — Functions | `deploy_function`, `invoke_function`, `manage_function` (`update_env`, `get_logs`, `list`, `delete`) |
| 6 — Frontend | `manage_app` (`update_cors`), `manage_frontend` (`set_env`, `start_deployment`, `list_deployments`), `create_frontend_deployment` |
| 7 — Production | `manage_rls` (`list`), `query_audit_logs`, `manage_function` (`get_logs`), `manage_storage` (`list`) |

@@ -637,7 +639,7 @@ ---

**Schema**
- Do not drop and recreate tables to rename a column — use `apply_schema` with the new column name and migrate data separately
- Do not skip `dry_run_schema` — always preview before applying
- Do not drop and recreate tables to rename a column — use `manage_schema` (`action: "apply"`) with the new column name and migrate data separately
- Do not skip `action: "dry_run"` — always preview before applying
**RLS**
- Do not forget to call `create_user_isolation_policy` — a table without RLS is readable by all authenticated users
- Do not forget `manage_rls` (`action: "create_user_isolation"`) — a table without RLS is readable by all authenticated users
- Do not include `author_id` / `user_id` in INSERT bodies when a trigger is installed — it will be set automatically

@@ -644,0 +646,0 @@

@@ -21,3 +21,3 @@ ---

| `services/control-api` | `@butterbase/control-api` | Fastify API server — the brain. Routes, plugins, services. Port 4000 |
| `services/mcp-server` | `@butterbase/mcp-server` | MCP server with 42+ tools. Runs via stdio or HTTP (served by control-api at `/mcp`) |
| `services/mcp-server` | `@butterbase/mcp-server` | MCP server with ~28 tools (consolidated `manage_*` action-based tools + a few standalone ones like `init_app`, `deploy_function`, `select_rows`). Runs via stdio or HTTP (served by control-api at `/mcp`) |
| `services/deno-runtime` | — | Serverless function executor. Deno-based worker isolation. Port 7133 |

@@ -29,3 +29,3 @@ | `services/cron-scheduler` | `@butterbase/cron-scheduler` | Cron job runner using node-cron + cron-parser |

| `services/storage-indexer` | — | Cloudflare Worker for S3 event indexing |
| `db/control-plane` | — | SQL migrations (001_ through 027_). Control plane database schema |
| `db/control-plane` | — | SQL migrations (sequential numbering, `001_` upward). Control plane database schema |
| `db/data-plane` | — | Per-app database initialization scripts |

@@ -39,4 +39,6 @@

Follow the pattern from existing tools like `init-app.ts`:
Decide whether the new capability is a standalone tool (single, self-contained operation like `init_app`) or another action on an existing umbrella tool (`manage_schema`, `manage_function`, etc). Most new operations should be added as actions on an existing `manage_*` tool — this keeps the surface area small for AI agents.
For a brand-new standalone tool, follow the pattern from `init-app.ts`:
```typescript

@@ -104,4 +106,4 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';

- **IMPORTANT**: Use `scripts/migrate.ts` or `scripts/backfill-migrations.ts`, NEVER raw `psql`
- Migration files: `db/control-plane/NNN_description.sql` (sequential numbering)
- Current range: `001_initial_schema.sql` through `027_storage_object_visibility.sql`
- Migration files: `db/control-plane/NNN_description.sql` (sequential numbering, starting at `001_initial_schema.sql`)
- Pick the next free three-digit prefix; never edit a committed migration
- Run migrations: `npx tsx scripts/migrate.ts`

@@ -115,3 +117,3 @@

|-----------|---------|
| MCP tool names | `snake_case`: `init_app`, `apply_schema`, `get_rls_policies` |
| MCP tool names | `snake_case`. Two flavours: standalone (`init_app`, `deploy_function`, `select_rows`) and `manage_*` umbrella tools that take an `action` enum (`manage_schema`, `manage_rls`, `manage_function`, `manage_frontend`, etc.) |
| App IDs | `app_` prefix: `app_abc123` |

@@ -118,0 +120,0 @@ | Service keys | `bb_sk_` prefix: `bb_sk_a1b2c3...` |

@@ -57,8 +57,10 @@ ---

Call `get_rls_policies` with the `app_id`:
Call `manage_rls` with `action: "list"` for the `app_id`:
```
get_rls_policies(app_id: "app_abc123")
manage_rls(app_id: "app_abc123", action: "list")
```
Returns `{ policies: [...], tables_with_rls: [...] }`. The `tables_with_rls` array shows which tables have RLS turned on but no policies yet (effective default deny).
- Look for the table in the response.

@@ -174,4 +176,5 @@ - If the table has **no policies**, RLS might not be enabled at all — or it was enabled but no policies were added, which causes a default deny for all non-service roles.

```
create_user_isolation_policy(
manage_rls(
app_id: "app_abc123",
action: "create_user_isolation",
table_name: "posts",

@@ -187,3 +190,3 @@ user_column: "author_id"

- Auto-populate trigger: sets `author_id` from the JWT on INSERT (clients don't need to send it)
- Service bypass policy: `butterbase_service` can still read/write all rows
- Service bypass: `butterbase_service` always passes through (built into the platform)

@@ -199,4 +202,5 @@ This is the recommended starting point for any user-owned data table.

```
create_policy(
manage_rls(
app_id: "app_abc123",
action: "create_policy",
table_name: "posts",

@@ -214,7 +218,8 @@ policy_name: "public_read_published",

Alternatively, if you haven't set up user isolation yet, you can use the shorthand in `create_user_isolation_policy`:
Alternatively, if you haven't set up user isolation yet, use the `public_read_column` shorthand:
```
create_user_isolation_policy(
manage_rls(
app_id: "app_abc123",
action: "create_user_isolation",
table_name: "posts",

@@ -226,3 +231,3 @@ user_column: "author_id",

This sets up user isolation **and** public read access in a single call.
This sets up user isolation **and** adds permissive SELECT policies for both `butterbase_user` and `butterbase_anon` to read rows where `published = true` — in a single call.

@@ -235,9 +240,10 @@ ---

**Option A — Recommended: Replace with `create_user_isolation_policy`**
**Option A — Recommended: replace with `create_user_isolation`**
This is the cleanest fix if you're starting fresh or can replace the existing policy:
The cleanest fix if you're starting fresh or can replace the existing policy:
```
create_user_isolation_policy(
manage_rls(
app_id: "app_abc123",
action: "create_user_isolation",
table_name: "posts",

@@ -250,9 +256,10 @@ user_column: "author_id"

**Option B — Additive: Add trigger to existing setup via `create_policy` with `user_column`**
**Option B — additive: pass `user_column` on `create_policy`**
Use this when you want to keep existing policies but just add the trigger:
Use this when you want to keep existing policies but just install the trigger:
```
create_policy(
manage_rls(
app_id: "app_abc123",
action: "create_policy",
table_name: "posts",

@@ -278,4 +285,5 @@ policy_name: "posts_user_insert",

```
create_policy(
manage_rls(
app_id: "app_abc123",
action: "create_policy",
table_name: "comments",

@@ -342,3 +350,3 @@ policy_name: "comments_on_public_posts_only",

- [ ] `get_rls_policies` shows the expected policies for the table
- [ ] `manage_rls` (action: "list") shows the expected policies for the table
- [ ] `select_rows` with `as_role: "user"` returns only the user's own rows

@@ -358,5 +366,5 @@ - [ ] `select_rows` with `as_role: "anon"` returns only publicly visible rows (or empty if no anon policy)

| Using `select_rows` without `as_role` to verify RLS | Service key bypasses RLS — result is meaningless for verification | Always use `as_role: "user"` or `as_role: "anon"` |
| `enable_rls` + `create_policy` without `user_column` | No auto-populate trigger; clients must send user column manually | Use `create_user_isolation_policy` or pass `user_column` to `create_policy` |
| `manage_rls` action `create_policy` without `user_column` | No auto-populate trigger; clients must send user column manually | Use `action: "create_user_isolation"` or pass `user_column` to `create_policy` |
| Single policy with `cmd: "ALL"` but no `WITH CHECK` | INSERT/UPDATE may silently pass or fail depending on expression | Explicitly provide `with_check_expression` for write commands |
| Relying on `butterbase_service` policies for end-user access | Service bypass is always on; end-users use `butterbase_user` or `butterbase_anon` | Write separate policies for each end-user role |
| Missing policy for one role while having it for another | Authenticated users may see data that anonymous users cannot, or vice versa — may be intentional but often a bug | Audit all roles with `get_rls_policies` |
| Missing policy for one role while having it for another | Authenticated users may see data that anonymous users cannot, or vice versa — may be intentional but often a bug | Audit all roles with `manage_rls` (action: "list") |

@@ -26,9 +26,12 @@ ---

Use `set_frontend_env` to configure the API URL and app ID before building. These variables are injected at build time by the framework.
Use `manage_frontend` with `action: "set_env"` to configure the API URL and app ID before building. These variables are injected at build time by the framework.
**Example for Vite:**
```json
{
"VITE_API_URL": "https://api.butterbase.ai/v1/app_abc123",
"VITE_APP_ID": "app_abc123"
"app_id": "app_abc123",
"action": "set_env",
"vars": {
"VITE_API_URL": "https://api.butterbase.ai/v1/app_abc123",
"VITE_APP_ID": "app_abc123"
}
}

@@ -41,3 +44,3 @@ ```

Call `set_frontend_env` with the `app_id` and `vars` object before running the build command.
`set_env` upserts; you can call it again to add or change variables.

@@ -74,3 +77,3 @@ ---

Call `update_cors` with the deployment URL (use the Butterbase Pages URL pattern) and any local dev origins:
Call `manage_app` with `action: "update_cors"`. Pass the deployment URL (use the Butterbase Pages URL pattern) and any local dev origins:

@@ -80,2 +83,3 @@ ```json

"app_id": "app_abc123",
"action": "update_cors",
"allowed_origins": [

@@ -156,3 +160,3 @@ "https://your-app.pages.dev",

Call `start_frontend_deployment` with the `app_id` and `deployment_id` from Step 4:
Call `manage_frontend` with `action: "start_deployment"` and the `deployment_id` from Step 4:

@@ -162,2 +166,3 @@ ```json

"app_id": "app_abc123",
"action": "start_deployment",
"deployment_id": "uuid-1234"

@@ -184,7 +189,35 @@ }

| MIME type errors / broken JS/CSS | Windows backslash in zip paths | Re-zip using Git Bash or WSL: `cd dist && zip -r ../frontend.zip .` |
| API calls return 403 | CORS not configured | Add deployment URL to `update_cors` |
| API calls return 403 | CORS not configured | Add deployment URL via `manage_app` action `update_cors` |
| Routes return 404 | SPA routing not set up | SPA routing is auto-handled for `react-vite` and `nextjs-static` framework flags |
| Deploy stuck in BUILDING | Build error | Check `list_frontend_deployments` for `error` field details |
| Deploy stuck in BUILDING | Build error | Check `manage_frontend` action `list_deployments` for `error` field |
| Upload fails or curl errors | Upload URL expired | Get a new URL by calling `create_frontend_deployment` again |
| Next.js pages not exporting | Missing static export config | Add `output: 'export'` to `next.config.js` and rebuild |
| Environment variables not found | Not set before build | Run `set_frontend_env` and rebuild — env vars are baked in at build time |
| Environment variables not found | Not set before build | Run `manage_frontend` action `set_env` and rebuild — env vars are baked in at build time |
---
## Bonus: server-side build (no local build needed)
If you want Butterbase to run `npm install` + build on the server, skip Steps 2–6 and use the source-build flow:
1. `manage_frontend` action `create_from_source` → returns presigned URL for a source-code zip (≤ 50 MB)
2. `cd <project> && zip -r ../source.zip . -x "node_modules/*" ".next/*" "dist/*"` — exclude build artefacts
3. `curl -X PUT "{uploadUrl}" -H "Content-Type: application/zip" --data-binary @source.zip`
4. `manage_frontend` action `start_from_source` with `deployment_id`, `lockfile_hash` (sha256 of `package-lock.json`), optional `build_command`, `output_dir`, `package_manager`, `user_env`
Same `lockfile_hash` across deploys = cached `node_modules` for faster rebuilds.
---
## Edge SSR (Next.js / Remix with server-side rendering)
Static deploys can't run server-side code. For Next.js (App Router with SSR/middleware) or Remix on Cloudflare Workers, use `manage_edge_ssr` instead of `create_frontend_deployment` + `manage_frontend`. Same shape, separate tool:
| Action | Purpose |
|--------|---------|
| `create` | Returns presigned URL for a pre-built Workers zip (framework default `nextjs-edge`) |
| `start` | Uploads + deploys; polls ≤ 60s |
| `create_from_source` / `start_from_source` | Server-side build flow (defaults: `npx @cloudflare/next-on-pages`, output `.vercel/output/static`) |
| `list` | Historical edge deployments |
Frameworks: `nextjs-edge`, `remix-edge`, `other-edge`.

@@ -20,5 +20,9 @@ ---

context: {
db: PostgresClient, // Query your app database
env: Record<string, string>, // Access environment variables
user: { id: string } | null // Current user (if auth: required)
db: PostgresClient, // RLS-aware DB client
env: Record<string, string>, // env vars set on the function
user: { id: string } | null, // present for HTTP+auth:required; null for cron
waitUntil: (p: Promise<unknown>) => void, // background work after Response (≤30s)
idempotency: {
claim: (key: string, opts?: { scope?: string; ttlSeconds?: number }) => Promise<boolean>
} // atomic dedup for webhook retries
}

@@ -323,9 +327,16 @@ ): Promise<Response>

The standalone tools `deploy_function` and `invoke_function` are unchanged. Everything else (logs, env updates, listing, deletion) is handled by `manage_function` with an `action` parameter.
### Invoke a Function
```
invoke_function(app_id, function_name, method: "POST", body: { key: "value" })
invoke_function(
app_id: "app_abc123",
function_name: "my-function",
method: "POST",
body: { key: "value" }
)
```
Returns the full HTTP response including status, headers, and body. Use this immediately after deploying to verify behavior.
Returns the full HTTP response (status, headers, body, duration_ms). Use this immediately after deploying to verify behavior.

@@ -335,6 +346,11 @@ ### View Error Logs

```
get_function_logs(app_id, function_name, level: "error")
manage_function(
app_id: "app_abc123",
action: "get_logs",
function_name: "my-function",
level: "error"
)
```
Returns recent invocations that resulted in errors, with stack traces.
Returns recent invocations with errors, stack traces, and captured `console.log/warn/error` output.

@@ -344,14 +360,20 @@ ### View All Logs

```
get_function_logs(app_id, function_name)
manage_function(
app_id: "app_abc123",
action: "get_logs",
function_name: "my-function",
limit: 100,
since: "2026-01-15T00:00:00Z"
)
```
Returns all recent invocations with timestamps, status codes, and durations.
Filters: `limit` (default 100), `since` (ISO timestamp), `level` (`"error"` or `"all"`).
### View Metrics
### List Functions & Metrics
```
list_functions(app_id)
manage_function(app_id: "app_abc123", action: "list")
```
Shows per-function metrics: invocation count, error rate, average duration.
Returns each function's name, trigger, URL, status, and metrics (invocationCount, errorRate, avgDuration, lastInvoked).

@@ -392,3 +414,8 @@ ---

```
update_function_env(app_id, function_name, envVars: { MY_SECRET: "new-value" })
manage_function(
app_id: "app_abc123",
action: "update_env",
function_name: "my-function",
env: { MY_SECRET: "new-value", DELETE_ME: null } // null deletes the key
)
```

@@ -399,3 +426,7 @@

```
delete_function(app_id, function_name)
manage_function(
app_id: "app_abc123",
action: "delete",
function_name: "my-function"
)
```

@@ -402,0 +433,0 @@

@@ -14,9 +14,19 @@ ---

Butterbase uses a **declarative schema DSL** — you describe the desired end state of your database, and the platform computes and applies the diff. You never write raw `ALTER TABLE` or `CREATE TABLE` SQL. Instead, call `apply_schema` with a JSON payload describing your tables, columns, and indexes.
Butterbase uses a **declarative schema DSL** — you describe the desired end state of your database, and the platform computes and applies the diff. You never write raw `ALTER TABLE` or `CREATE TABLE` SQL. Instead, call `manage_schema` with `action: "apply"` and a JSON payload describing your tables, columns, and indexes.
The single `manage_schema` tool exposes four actions:
| Action | Purpose |
|--------|---------|
| `"get"` | Read the current schema |
| `"dry_run"` | Preview SQL that `apply` would execute, without running it |
| `"apply"` | Apply a declarative schema (diffs against current, runs safe DDL) |
| `"list_migrations"` | List applied migrations, most recent first |
Key principles:
- **Idempotent**: applying the same schema twice is safe
- **Idempotent**: applying the same schema twice is safe — returns "Schema is up to date" if no changes needed
- **Additive by default**: new columns and tables are created automatically
- **Explicit drops**: destructive operations require opt-in via `_drop` / `_dropColumns`
- **Preview first**: use `dry_run_schema` to see what will change before committing
- **Preview first**: use `action: "dry_run"` to see what will change before committing
- **Transactional**: each migration runs in a single transaction — all changes commit or all roll back

@@ -38,3 +48,3 @@ ---

| `double precision` | DOUBLE PRECISION | 64-bit floating point |
| `vector(N)` | VECTOR(N) | Embeddings (pgvector) |
| `vector(N)` | VECTOR(N) | Embeddings (pgvector); e.g. `vector(1536)` for OpenAI |

@@ -56,17 +66,13 @@ > **Always use `timestamptz` instead of `timestamp`.** `timestamp` silently drops timezone info and causes subtle bugs with users in different time zones.

| `unique` | boolean | no | false | Add unique constraint |
| `references` | string | no | — | Foreign key target: `"table.column"` |
| `references` | string \| object | no | — | Foreign key target (see below) |
### Default expressions
### Foreign keys — short or long form
Pass SQL expressions as strings:
Short form (just the target):
```json
"default": "gen_random_uuid()" // UUID primary keys
"default": "now()" // Timestamps
"default": "false" // Booleans
"default": "0" // Integers
"default": "'draft'" // String literals (single-quoted)
"author_id": { "type": "uuid", "nullable": false, "references": "users.id" }
```
### Foreign keys
Long form (with cascade behavior):

@@ -77,6 +83,25 @@ ```json

"nullable": false,
"references": "users.id"
"references": {
"table": "users",
"column": "id",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION"
}
}
```
`onDelete` / `onUpdate` accept `CASCADE | SET NULL | SET DEFAULT | RESTRICT | NO ACTION` (default `NO ACTION`).
### Default expressions
Pass SQL expressions as strings:
```json
"default": "gen_random_uuid()" // UUID primary keys
"default": "now()" // Timestamps
"default": "false" // Booleans
"default": "0" // Integers
"default": "'draft'" // String literals (single-quoted)
```
---

@@ -108,4 +133,4 @@

| Type | Use case | Example opclass |
|------|----------|----------------|
| `method` | Use case | Example opclass |
|----------|----------|----------------|
| `btree` | Default, range queries, sorting | — |

@@ -127,7 +152,7 @@ | `hash` | Exact-match lookups | — |

"columns": ["author_id"],
"type": "btree"
"method": "btree"
},
"idx_posts_embedding": {
"columns": ["embedding"],
"type": "hnsw",
"method": "hnsw",
"opclass": "vector_cosine_ops"

@@ -137,3 +162,3 @@ },

"columns": ["content"],
"type": "gin"
"method": "gin"
}

@@ -151,3 +176,3 @@ }

"columns": ["workspace_id", "user_id"],
"type": "btree",
"method": "btree",
"unique": true

@@ -159,15 +184,24 @@ }

## 6. Using `apply_schema`
## 6. Using `manage_schema`
All schema operations go through one tool with an `action` parameter:
```js
manage_schema({ app_id, action: "get" })
manage_schema({ app_id, action: "dry_run", schema })
manage_schema({ app_id, action: "apply", schema, name }) // name is optional
manage_schema({ app_id, action: "list_migrations" })
```
### Creating new tables
Simply include the table definition in your schema payload. The platform creates it if it does not exist.
Include the table definition in your `schema` payload and call `action: "apply"`. The platform creates the table if it doesn't exist.
### Adding columns to existing tables
Add the new column(s) to the existing table definition and call `apply_schema`. Existing rows receive the column's `default` value (or NULL if no default).
Add the new column(s) to the existing table definition and call `action: "apply"`. Existing rows receive the column's `default` value (or NULL if no default).
### Destructive operations
Dropping tables and columns is opt-in and explicit:
Dropping tables and columns is opt-in and explicit. Without `_drop` / `_dropColumns`, the platform refuses with `STATE_PREREQUISITE_MISSING`.

@@ -195,14 +229,25 @@ ```json

> ⚠️ Drops are irreversible. Always run `dry_run_schema` first.
> ⚠️ Drops are irreversible. Always run `action: "dry_run"` first.
### Preview with `dry_run_schema`
### Preview before commit
Use the same payload with `dry_run_schema` to see a diff of what will be created, altered, or dropped — without touching the database:
Use the same payload with `action: "dry_run"` to see the SQL that `apply` would execute — without touching the database:
```json
// Same payload, different tool:
// dry_run_schema({ appId, schema }) — preview only
// apply_schema({ appId, schema }) — commits changes
```js
// Preview only:
manage_schema({ app_id, action: "dry_run", schema })
// Commit:
manage_schema({ app_id, action: "apply", schema, name: "add_posts_table" })
```
### Common errors
| Code | Meaning |
|------|---------|
| `VALIDATION_INVALID_SCHEMA` | Schema format doesn't match the DSL |
| `STATE_PREREQUISITE_MISSING` | Destructive op without `_drop` / `_dropColumns` |
| `QUOTA_TABLE_LIMIT` | Exceeds the per-app table limit |
| `RESOURCE_NOT_FOUND` | `app_id` does not exist |
---

@@ -216,3 +261,4 @@

{
"appId": "YOUR_APP_ID",
"app_id": "YOUR_APP_ID",
"action": "apply",
"schema": {

@@ -229,3 +275,3 @@ "users": {

"indexes": {
"idx_users_email": { "columns": ["email"], "type": "btree", "unique": true }
"idx_users_email": { "columns": ["email"], "method": "btree", "unique": true }
}

@@ -257,6 +303,6 @@ },

"indexes": {
"idx_posts_author_id": { "columns": ["author_id"], "type": "btree" },
"idx_posts_category_id": { "columns": ["category_id"], "type": "btree" },
"idx_posts_slug": { "columns": ["slug"], "type": "btree", "unique": true },
"idx_posts_published": { "columns": ["published", "published_at"], "type": "btree" }
"idx_posts_author_id": { "columns": ["author_id"], "method": "btree" },
"idx_posts_category_id": { "columns": ["category_id"], "method": "btree" },
"idx_posts_slug": { "columns": ["slug"], "method": "btree", "unique": true },
"idx_posts_published": { "columns": ["published", "published_at"], "method": "btree" }
}

@@ -274,4 +320,4 @@ },

"indexes": {
"idx_comments_post_id": { "columns": ["post_id"], "type": "btree" },
"idx_comments_author_id": { "columns": ["author_id"], "type": "btree" }
"idx_comments_post_id": { "columns": ["post_id"], "method": "btree" },
"idx_comments_author_id": { "columns": ["author_id"], "method": "btree" }
}

@@ -289,3 +335,4 @@ }

{
"appId": "YOUR_APP_ID",
"app_id": "YOUR_APP_ID",
"action": "apply",
"schema": {

@@ -305,4 +352,4 @@ "products": {

"indexes": {
"idx_products_sku": { "columns": ["sku"], "type": "btree", "unique": true },
"idx_products_metadata": { "columns": ["metadata"], "type": "gin", "opclass": "jsonb_path_ops" }
"idx_products_sku": { "columns": ["sku"], "method": "btree", "unique": true },
"idx_products_metadata": { "columns": ["metadata"], "method": "gin", "opclass": "jsonb_path_ops" }
}

@@ -321,4 +368,4 @@ },

"indexes": {
"idx_orders_user_id": { "columns": ["user_id"], "type": "btree" },
"idx_orders_status": { "columns": ["status"], "type": "btree" }
"idx_orders_user_id": { "columns": ["user_id"], "method": "btree" },
"idx_orders_status": { "columns": ["status"], "method": "btree" }
}

@@ -337,4 +384,4 @@ },

"indexes": {
"idx_order_items_order_id": { "columns": ["order_id"], "type": "btree" },
"idx_order_items_product_id": { "columns": ["product_id"], "type": "btree" }
"idx_order_items_order_id": { "columns": ["order_id"], "method": "btree" },
"idx_order_items_product_id": { "columns": ["product_id"], "method": "btree" }
}

@@ -354,4 +401,4 @@ },

"indexes": {
"idx_reviews_product_id": { "columns": ["product_id"], "type": "btree" },
"idx_reviews_user_id": { "columns": ["user_id"], "type": "btree" }
"idx_reviews_product_id": { "columns": ["product_id"], "method": "btree" },
"idx_reviews_user_id": { "columns": ["user_id"], "method": "btree" }
}

@@ -369,3 +416,4 @@ }

{
"appId": "YOUR_APP_ID",
"app_id": "YOUR_APP_ID",
"action": "apply",
"schema": {

@@ -383,3 +431,3 @@ "workspaces": {

"indexes": {
"idx_workspaces_slug": { "columns": ["slug"], "type": "btree", "unique": true }
"idx_workspaces_slug": { "columns": ["slug"], "method": "btree", "unique": true }
}

@@ -397,4 +445,4 @@ },

"indexes": {
"idx_members_workspace_user": { "columns": ["workspace_id", "user_id"], "type": "btree", "unique": true },
"idx_members_user_id": { "columns": ["user_id"], "type": "btree" }
"idx_members_workspace_user": { "columns": ["workspace_id", "user_id"], "method": "btree", "unique": true },
"idx_members_user_id": { "columns": ["user_id"], "method": "btree" }
}

@@ -413,3 +461,3 @@ },

"indexes": {
"idx_projects_workspace_id": { "columns": ["workspace_id"], "type": "btree" }
"idx_projects_workspace_id": { "columns": ["workspace_id"], "method": "btree" }
}

@@ -431,5 +479,5 @@ },

"indexes": {
"idx_tasks_project_id": { "columns": ["project_id"], "type": "btree" },
"idx_tasks_assignee_id": { "columns": ["assignee_id"], "type": "btree" },
"idx_tasks_status": { "columns": ["status"], "type": "btree" }
"idx_tasks_project_id": { "columns": ["project_id"], "method": "btree" },
"idx_tasks_assignee_id": { "columns": ["assignee_id"], "method": "btree" },
"idx_tasks_status": { "columns": ["status"], "method": "btree" }
}

@@ -447,3 +495,4 @@ }

{
"appId": "YOUR_APP_ID",
"app_id": "YOUR_APP_ID",
"action": "apply",
"schema": {

@@ -461,4 +510,4 @@ "profiles": {

"indexes": {
"idx_profiles_username": { "columns": ["username"], "type": "btree", "unique": true },
"idx_profiles_user_id": { "columns": ["user_id"], "type": "btree", "unique": true }
"idx_profiles_username": { "columns": ["username"], "method": "btree", "unique": true },
"idx_profiles_user_id": { "columns": ["user_id"], "method": "btree", "unique": true }
}

@@ -476,4 +525,4 @@ },

"indexes": {
"idx_posts_author_id": { "columns": ["author_id"], "type": "btree" },
"idx_posts_created_at": { "columns": ["created_at"], "type": "btree" }
"idx_posts_author_id": { "columns": ["author_id"], "method": "btree" },
"idx_posts_created_at": { "columns": ["created_at"], "method": "btree" }
}

@@ -489,4 +538,4 @@ },

"indexes": {
"idx_follows_follower_following": { "columns": ["follower_id", "following_id"], "type": "btree", "unique": true },
"idx_follows_following_id": { "columns": ["following_id"], "type": "btree" }
"idx_follows_follower_following": { "columns": ["follower_id", "following_id"], "method": "btree", "unique": true },
"idx_follows_following_id": { "columns": ["following_id"], "method": "btree" }
}

@@ -502,4 +551,4 @@ },

"indexes": {
"idx_likes_user_post": { "columns": ["user_id", "post_id"], "type": "btree", "unique": true },
"idx_likes_post_id": { "columns": ["post_id"], "type": "btree" }
"idx_likes_user_post": { "columns": ["user_id", "post_id"], "method": "btree", "unique": true },
"idx_likes_post_id": { "columns": ["post_id"], "method": "btree" }
}

@@ -506,0 +555,0 @@ }