Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Use when developing, deploying, or debugging Butterbase serverless functions, or when the user needs to add backend logic like webhooks, scheduled jobs, or custom API endpoints
.claude/skills/butterbase-ai-function-dev/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-14 | ✗→✓ | ▲ Improved | 114% | 0% |
| case-01 | ✗→✓ | ▲ Improved | 129% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 93% | 0% |
| case-03 | ✗→✓ | ▲ Improved | 169% | 0% |
| case-07 | ✗→✓ | ▲ Improved | 108% | 0% |
Guide for developing and deploying serverless functions on Butterbase's Deno runtime. Covers handler signatures, trigger types, database access, environment variables, and testing.
Every function exports a single handler function with this signature:
typescriptexport async function handler( request: Request, context: { 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 } ): Promise<Response>
CRITICAL: The handler MUST return new Response() (Web API standard). Do NOT return plain objects.
Correct:
typescriptreturn new Response(JSON.stringify({ message: "ok" }), { status: 200, headers: { "Content-Type": "application/json" } });
Wrong (will fail):
typescriptreturn { status: 200, body: "ok" }; // NOT a Response object!
Invoke the function via an HTTP request.
json{ "trigger": { "type": "http", "config": { "method": "POST", "path": "/my-endpoint", "auth": "required" } } }
Auth options:
"required" — request must include a valid JWT; ctx.user is always set"optional" — JWT is parsed if present; ctx.user may be null"none" — public endpoint; no auth needed; ctx.user is always nullExecute the function on a schedule.
json{ "trigger": { "type": "cron", "config": { "schedule": "0 9 * * *", "timezone": "UTC" } } }
Uses standard 5-field cron expressions:
"*/5 * * * *" — every 5 minutes"0 0 * * 0" — weekly, Sunday at midnight"0 3 * * *" — daily at 3am UTC"0 9 * * 1-5" — weekdays at 9amCron functions run as butterbase_service (RLS bypassed). ctx.user is always null.
Fire when a connected client sends a matching event over the realtime WebSocket.
json{ "trigger": { "type": "websocket", "config": { "event": "chat-message" } } }
Fires when client sends matching event via realtime WebSocket connection. The request body contains the event payload sent by the client.
json{ "trigger": { "type": "s3_upload", "config": { "prefix": "uploads/", "contentTypes": ["image/*"] } } }
Use ctx.db.query(sql, params) for all database queries. Always use parameterized queries to prevent SQL injection — NEVER use string interpolation.
typescript// Always use $1, $2 placeholders — never string interpolation const { rows } = await ctx.db.query( 'SELECT * FROM posts WHERE author_id = $1', [ctx.user.id] // params array );
typescriptconst { rows } = await ctx.db.query( 'SELECT * FROM posts WHERE author_id = $1 AND published = true', [ctx.user.id] );
typescriptawait ctx.db.query( 'INSERT INTO logs (event, user_id) VALUES ($1, $2)', ['page_view', ctx.user.id] );
typescriptawait ctx.db.query( 'UPDATE posts SET title = $1, updated_at = now() WHERE id = $2 AND author_id = $3', [newTitle, postId, ctx.user.id] );
| Invocation | Role | RLS | |------------|------|-----| | End-user JWT | butterbase_user | Enforced — ctx.db queries filtered by policies | | API key (bb_sk_) | butterbase_service | Bypassed — sees all data | | Cron trigger | butterbase_service | Bypassed — sees all data |
envVars parameter to deploy_functionupdate_function_envctx.env.VARIABLE_NAMECommon uses: API keys, webhook secrets, external service URLs.
typescriptconst apiKey = ctx.env.OPENAI_API_KEY; const webhookSecret = ctx.env.WEBHOOK_SECRET; const serviceUrl = ctx.env.EXTERNAL_SERVICE_URL;
Returns the authenticated user's posts.
typescriptexport async function handler(req, ctx) { const { rows } = await ctx.db.query( 'SELECT id, title, created_at FROM posts WHERE author_id = $1 ORDER BY created_at DESC', [ctx.user.id] ); return new Response(JSON.stringify(rows), { headers: { "Content-Type": "application/json" } }); }
Deploy:
deploy_function(
app_id,
name: "my-posts",
code: ...,
trigger: {
type: "http",
config: { method: "GET", path: "/my-posts", auth: "required" }
}
)Accepts an incoming webhook, validates the signature, and stores the event.
typescriptexport async function handler(req, ctx) { const body = await req.json(); const signature = req.headers.get("x-webhook-signature"); // Validate signature against ctx.env.WEBHOOK_SECRET await ctx.db.query( 'INSERT INTO webhook_events (event_type, payload) VALUES ($1, $2)', [body.type, JSON.stringify(body)] ); return new Response("ok", { status: 200 }); }
Deploy with: trigger: { type: "http", config: { method: "POST", path: "/webhook", auth: "none" } }
Deletes expired sessions on a nightly schedule.
typescriptexport async function handler(req, ctx) { const result = await ctx.db.query( "DELETE FROM sessions WHERE expires_at < now() RETURNING id" ); return new Response(JSON.stringify({ deleted: result.rowCount }), { headers: { "Content-Type": "application/json" } }); }
Deploy with: trigger: { type: "cron", config: { schedule: "0 3 * * *" } }
Proxies a request to an external AI service using a stored API key.
typescriptexport async function handler(req, ctx) { const { prompt } = await req.json(); const response = await fetch("https://api.openai.com/v1/chat/completions", { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${ctx.env.OPENAI_API_KEY}` }, body: JSON.stringify({ model: "gpt-4", messages: [{ role: "user", content: prompt }] }) }); const data = await response.json(); return new Response(JSON.stringify(data), { headers: { "Content-Type": "application/json" } }); }
Deploy with envVars: { OPENAI_API_KEY: "sk-..." } and trigger: { type: "http", config: { method: "POST", auth: "required" } }
Always wrap handler logic in try/catch and return a proper error Response.
typescriptexport async function handler(req, ctx) { try { const { id } = await req.json(); const { rows } = await ctx.db.query( 'SELECT * FROM items WHERE id = $1', [id] ); if (rows.length === 0) { return new Response(JSON.stringify({ error: "Not found" }), { status: 404, headers: { "Content-Type": "application/json" } }); } return new Response(JSON.stringify(rows[0]), { status: 200, headers: { "Content-Type": "application/json" } }); } catch (err) { return new Response(JSON.stringify({ error: "Internal server error" }), { status: 500, headers: { "Content-Type": "application/json" } }); } }
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_function(
app_id: "app_abc123",
function_name: "my-function",
method: "POST",
body: { key: "value" }
)Returns the full HTTP response (status, headers, body, duration_ms). Use this immediately after deploying to verify behavior.
manage_function(
app_id: "app_abc123",
action: "get_logs",
function_name: "my-function",
level: "error"
)Returns recent invocations with errors, stack traces, and captured console.log/warn/error output.
manage_function(
app_id: "app_abc123",
action: "get_logs",
function_name: "my-function",
limit: 100,
since: "2026-01-15T00:00:00Z"
)Filters: limit (default 100), since (ISO timestamp), level ("error" or "all").
manage_function(app_id: "app_abc123", action: "list")Returns each function's name, trigger, URL, status, and metrics (invocationCount, errorRate, avgDuration, lastInvoked).
| Mistake | Fix | |---------|-----| | Returning plain object instead of Response | Always use new Response(JSON.stringify(data), { headers: {...} }) | | SQL injection via string interpolation | Use parameterized queries: $1, $2 placeholders | | Not wrapping in try/catch | Always catch errors and return a Response with error status | | Forgetting async on handler | Handler must be async function handler(...) | | Exceeding timeout (30s default) | Increase timeoutMs in deploy_function or optimize the function | | Not setting Content-Type header | Always include "Content-Type": "application/json" for JSON responses |
deploy_function(
app_id: "app_abc123",
name: "my-function",
code: "export async function handler(req, ctx) { ... }",
trigger: { type: "http", config: { method: "POST", auth: "required" } },
envVars: { MY_SECRET: "value" },
timeoutMs: 30000, // default: 30s, max: 300s
memoryLimitMb: 128 // default: 128MB
)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
)manage_function(
app_id: "app_abc123",
action: "delete",
function_name: "my-function"
)https://api.butterbase.ai/v1/{app_id}/fn/{function-name}For HTTP triggers, this is the URL clients call directly.
If a docs/butterbase/00-state.md exists in the working directory, prefer invoking via /butterbase-skills:journey-functions so the journey orchestrator stays in sync.
Other measured skills in the registry, with their headline benchmark lift.