From butterbase-skills
Builds a complete Butterbase app from scratch: provisions backend, designs schema, sets up auth, and deploys frontend. Use for full-stack apps with database and auth.
How this skill is triggered — by the user, by Claude, or both
Slash command
/butterbase-skills:build-appThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
> **Prefer `/butterbase-skills:journey`** — for a fully guided multi-stage build with preflight, planning, deployment verification, and (optionally) hackathon submission. This one-shot skill remains for users who want the legacy linear setup.
Prefer
/butterbase-skills:journey— for a fully guided multi-stage build with preflight, planning, deployment verification, and (optionally) hackathon submission. This one-shot skill remains for users who want the legacy linear setup.
This skill walks through all seven phases of building a production-ready Butterbase application — from provisioning a backend to deploying a live frontend. Follow each phase in order; later phases depend on artifacts (app_id, schema, RLS policies) produced by earlier ones.
Convention: every JSON body below is the argument object for the tool named in its Tool: header. When the header reads
manage_schemawithaction: "apply", include"action": "apply"alongside the other fields when you make the call.
Use init_app to provision an isolated backend with its own database and auto-generated REST API.
Tool: init_app
{
"name": "my-blog"
}
Returns:
{
"app_id": "app_abc123",
"api_base": "https://api.butterbase.ai/v1/app_abc123"
}
Important: Save app_id and api_base — every subsequent tool call requires app_id.
If the user needs programmatic access (CI/CD pipelines, server-to-server calls, admin scripts), generate a service key now.
Tool: manage_auth_config with action: "generate_service_key"
{
"name": "Production Deploy Key"
}
⚠️ The full key (
bb_sk_...) is shown only once. Store it securely — it cannot be retrieved again.
Work with the user to understand their data model before writing any SQL. Ask:
published, is_public)?Always preview schema changes before applying them.
Tool: manage_schema with action: "dry_run"
{
"app_id": "app_abc123",
"schema": {
"tables": {
"posts": {
"columns": {
"id": { "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()" },
"author_id": { "type": "uuid", "nullable": false },
"title": { "type": "text", "nullable": false }
}
}
}
}
}
Review the generated SQL — make sure it matches intent before applying.
Tool: manage_schema with action: "apply"
Below is a complete example for a blog app with posts and comments:
{
"app_id": "app_abc123",
"schema": {
"tables": {
"posts": {
"columns": {
"id": { "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()" },
"author_id": { "type": "uuid", "nullable": false },
"title": { "type": "text", "nullable": false },
"body": { "type": "text" },
"published": { "type": "boolean", "default": "false" },
"created_at": { "type": "timestamptz", "default": "now()" }
}
},
"comments": {
"columns": {
"id": { "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()" },
"post_id": { "type": "uuid", "nullable": false, "references": "posts.id" },
"author_id": { "type": "uuid", "nullable": false },
"body": { "type": "text", "nullable": false },
"created_at": { "type": "timestamptz", "default": "now()" }
}
}
}
}
}
Tool: manage_schema with action: "get"
{
"app_id": "app_abc123"
}
Confirm every table and column is present before moving to Phase 3.
id as UUID with gen_random_uuid() defaultcreated_at with now() defaultauthor_id / user_id UUID columns on user-owned tables — RLS will reference thesereferences: "table.column" for foreign keys (cascades must be set carefully)manage_schema action apply is idempotent — safe to call again if schema is unchangedRow-Level Security (RLS) ensures users can only access their own data. This phase is not optional for any table that holds user-generated content.
Call create_user_isolation_policy for each user-owned table. This single call:
Tool: manage_rls with action: "create_user_isolation"
{
"app_id": "app_abc123",
"table_name": "posts",
"user_column": "author_id"
}
For tables where some rows should be publicly visible (e.g. published blog posts), add public_read_column. This creates extra SELECT policies for both authenticated and anonymous users.
{
"app_id": "app_abc123",
"table_name": "posts",
"user_column": "author_id",
"public_read_column": "published"
}
Repeat for every user-owned table. For the blog example:
{
"app_id": "app_abc123",
"table_name": "comments",
"user_column": "author_id"
}
After applying policies, verify they work correctly by simulating user requests.
Test SELECT as a specific user — should only see that user's rows:
Tool: select_rows
{
"app_id": "app_abc123",
"table": "posts",
"as_role": "user",
"as_user": "11111111-1111-1111-1111-111111111111"
}
Test SELECT as anonymous — should only see published/public rows (or nothing if no public policy):
{
"app_id": "app_abc123",
"table": "posts",
"as_role": "anon"
}
Test INSERT as a specific user — author_id should be auto-populated by the trigger (do not include it in data):
Tool: insert_row
{
"app_id": "app_abc123",
"table": "posts",
"data": {
"title": "My First Post",
"body": "Hello world!",
"published": false
},
"as_role": "user",
"as_user": "11111111-1111-1111-1111-111111111111"
}
Confirm the returned row has author_id set to 11111111-1111-1111-1111-111111111111 automatically.
Tool: manage_rls with action: "list"
{
"app_id": "app_abc123"
}
Review the policy list and confirm every user-data table has at least one policy.
Butterbase uses OAuth 2.0 for end-user authentication. Users sign in via a provider (Google, GitHub, Discord, etc.) and receive a JWT to authenticate subsequent API calls.
Built-in providers (google, github, discord, facebook, linkedin, microsoft, apple, x) only require three fields — URLs and scopes are auto-filled.
Tool: manage_oauth with action: "configure"
Google example:
{
"app_id": "app_abc123",
"provider": "google",
"client_id": "123456789.apps.googleusercontent.com",
"client_secret": "GOCSPX-xxxxxxxxxxxxxxxxxxxxxxxx",
"redirect_uris": ["https://api.butterbase.ai/auth/app_abc123/oauth/google/callback"]
}
GitHub example:
{
"app_id": "app_abc123",
"provider": "github",
"client_id": "Iv1.xxxxxxxxxxxxxxxx",
"client_secret": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"redirect_uris": ["https://api.butterbase.ai/auth/app_abc123/oauth/github/callback"]
}
Redirect URI format:
https://api.butterbase.ai/auth/{app_id}/oauth/{provider}/callback
Replace {app_id} and {provider} with real values. Register this exact URI in the OAuth provider's developer console.
client_id and client_secretconfigure_oauth_provider with those credentialsInstall the Butterbase SDK in the frontend:
npm install @butterbase/sdk
Initialize the client and trigger OAuth sign-in:
import { createClient } from '@butterbase/sdk'
const client = createClient({
appId: 'app_abc123',
apiBase: 'https://api.butterbase.ai/v1/app_abc123'
})
// Initiate OAuth sign-in (redirects to provider)
await client.auth.signInWithOAuth({ provider: 'google' })
// After redirect back, get the current session
const { user, accessToken } = await client.auth.getSession()
// Use the access token in API calls — the SDK handles this automatically
const posts = await client.from('posts').select('*')
Tool: manage_auth_config with action: "update_jwt"
{
"app_id": "app_abc123",
"accessTokenTtl": "15m",
"refreshTokenTtlDays": 30
}
Short access tokens (15m) with longer refresh tokens (30 days) balance security and user experience.
Deploy serverless functions for business logic that cannot run in the browser: sending emails, processing payments, calling third-party APIs with secrets, scheduled jobs, and more.
Tool: deploy_function
Critical rules:
export async function handlernew Response() object — never return a plain objectdb (Postgres), env (encrypted env vars), user (authenticated user or null)HTTP function example:
{
"app_id": "app_abc123",
"name": "create-post",
"description": "Create a new blog post and notify subscribers",
"trigger": {
"type": "http",
"config": {
"method": "POST",
"path": "/create-post",
"auth": "required"
}
},
"code": "export async function handler(request, ctx) {\n const { title, body } = await request.json();\n if (!title) {\n return new Response(JSON.stringify({ error: 'title is required' }), {\n status: 400,\n headers: { 'Content-Type': 'application/json' }\n });\n }\n const result = await ctx.db.query(\n 'INSERT INTO posts (title, body, author_id) VALUES ($1, $2, $3) RETURNING *',\n [title, body, ctx.user.id]\n );\n return new Response(JSON.stringify(result.rows[0]), {\n status: 201,\n headers: { 'Content-Type': 'application/json' }\n });\n}"
}
Cron function example (runs daily at 9 AM UTC):
{
"app_id": "app_abc123",
"name": "daily-digest",
"description": "Send a daily digest email to subscribers",
"trigger": {
"type": "cron",
"config": {
"schedule": "0 9 * * *",
"timezone": "UTC"
}
},
"code": "export async function handler(request, ctx) {\n const result = await ctx.db.query(\n 'SELECT * FROM posts WHERE created_at > NOW() - INTERVAL \\'24 hours\\''\n );\n // send digest email with result.rows...\n return new Response(JSON.stringify({ sent: result.rows.length }), {\n status: 200,\n headers: { 'Content-Type': 'application/json' }\n });\n}"
}
Never hardcode API keys. Pass them as envVars:
{
"app_id": "app_abc123",
"name": "send-email",
"envVars": {
"SENDGRID_API_KEY": "SG.xxxxxxxxxxxxxxxx",
"FROM_EMAIL": "[email protected]"
},
"trigger": { "type": "http", "config": { "method": "POST", "auth": "required" } },
"code": "export async function handler(request, ctx) {\n const apiKey = ctx.env.SENDGRID_API_KEY;\n // use apiKey...\n return new Response('ok', { status: 200 });\n}"
}
To rotate secrets without redeploying code, call manage_function with action: "update_env".
Tool: invoke_function
{
"app_id": "app_abc123",
"function_name": "create-post",
"method": "POST",
"body": {
"title": "Hello World",
"body": "This is a test post."
}
}
If a function returns an unexpected response or error, check logs immediately.
Tool: manage_function with action: "get_logs"
{
"app_id": "app_abc123",
"function_name": "create-post",
"level": "error",
"limit": 20
}
Logs include stack traces, duration, memory used, and status codes. Logs are retained for 7 days.
| Invoked with | Role assigned | RLS enforced? |
|---|---|---|
| End-user JWT | butterbase_user | Yes — sees only user's data |
| Platform API key | butterbase_service | No — sees all data |
| Cron trigger | butterbase_service | No — sees all data |
Deploy the frontend as a static site. Butterbase hosts it on a CDN with SPA routing support.
Allow the frontend domain to call the API.
Tool: manage_app with action: "update_cors"
{
"app_id": "app_abc123",
"allowed_origins": [
"http://localhost:3000",
"https://my-app.pages.dev"
]
}
Add both local dev and production URLs. Update again after you know the final deployment URL.
Tool: manage_frontend with action: "set_env"
{
"app_id": "app_abc123",
"vars": {
"VITE_API_BASE": "https://api.butterbase.ai/v1/app_abc123",
"VITE_APP_ID": "app_abc123"
}
}
Prefix variables for your framework:
VITE_NEXT_PUBLIC_REACT_APP_npm run build
This produces a dist/ (Vite) or out/ (Next.js static) folder.
Tool: create_frontend_deployment
{
"app_id": "app_abc123",
"framework": "react-vite"
}
Returns:
{
"deployment_id": "dep_xyz789",
"uploadUrl": "https://s3.amazonaws.com/...",
"expiresIn": 900
}
Save deployment_id and uploadUrl.
⚠️ Windows warning: Use Git Bash or WSL to create the zip. Windows built-in zip uses backslashes, which breaks MIME types and causes JS/CSS to be served as
text/html.
# From the project root — use Git Bash on Windows
cd dist && zip -r ../frontend.zip . && cd ..
Upload the zip:
curl -X PUT "https://s3.amazonaws.com/..." \
-H "Content-Type: application/zip" \
--data-binary @frontend.zip
Replace the URL with the uploadUrl returned in the previous step.
Tool: manage_frontend with action: "start_deployment"
{
"app_id": "app_abc123",
"deployment_id": "dep_xyz789"
}
This polls until the deployment reaches READY status (up to 5 minutes) and returns the live URL.
{
"deployment_id": "dep_xyz789",
"url": "https://my-app.pages.dev",
"status": "READY"
}
Visit the URL to verify the frontend is live.
Once you have the live deployment URL, add it to CORS if it wasn't already included:
{
"app_id": "app_abc123",
"allowed_origins": [
"http://localhost:3000",
"https://my-app.pages.dev"
]
}
| Framework | Build command | Zip folder | framework param |
|---|---|---|---|
| React + Vite | npm run build | dist/ | react-vite |
| Next.js (static) | next build && next export | out/ | nextjs-static |
| Plain HTML/CSS/JS | n/a | root | static |
| Other | varies | build output | other |
Before announcing the app as production-ready, verify each item:
update_cors includes the live frontend URL (not just localhost)manage_rls (action: "list") shows policies for every table holding user-generated content; no table is accidentally wide-openVITE_API_BASE (or equivalent) points to https://api.butterbase.ai/v1/{app_id}, not a localhost URLdeploy_function handler returns appropriate HTTP status codes (400 for bad input, 401 for auth failures, 500 for unexpected errors) rather than throwing unhandled exceptionsmanage_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)manage_storage (action: "list") and app config reviewed; storage usage is within plan limits and allowedContentTypes are restricted to what the app actually needsinvoke_function — Every HTTP function has been invoked with realistic payloads and edge cases (missing fields, invalid auth, large inputs) and returned correct responsesmanage_frontend (action: "list_deployments") shows a READY deployment; the live URL loads correctly in a browser and all API calls succeedquery_audit_logs shows no unexpected login failures or suspicious activity; manage_function (action: "get_logs") shows no recurring errors in production traffic| Phase | Tools Used |
|---|---|
| 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) |
Schema
manage_schema (action: "apply") with the new column name and migrate data separatelyaction: "dry_run" — always preview before applyingRLS
manage_rls (action: "create_user_isolation") — a table without RLS is readable by all authenticated usersauthor_id / user_id in INSERT bodies when a trigger is installed — it will be set automaticallyFunctions
return { status: 200 }) — always return new Response(...)envVars and access via ctx.envFrontend
Auth
localhost redirect URIs in the production OAuth app — keep dev and prod OAuth apps separate, or add both URIs to the same appnpx claudepluginhub butterbase-ai/butterbase-skills --plugin butterbase-skillsDesigns database schemas using Butterbase declarative DSL. Covers column types, constraints, indexes, and safe migration workflows.
Integrates Better Auth TypeScript authentication for Cloudflare D1 via Drizzle/Kysely, Next.js, Nuxt, and 15+ frameworks. Use for auth setup, D1 adapter errors, OAuth/2FA/RBAC.
Generates full-stack TanStack Start app on Cloudflare Workers: SSR, routing, D1+Drizzle DB, better-auth, Tailwind v4+shadcn/ui. Fresh file generation per project.