| name | volcano-platform |
| description | Use for every Volcano project build, scaffold, local run, test, or deploy. Covers canonical project shape, Functions and frontends, migrations, volcano-config.yaml, environment variables, shared code, and the build and deploy workflow. Pair with volcano-sdk for application features. |
Volcano Platform Contract Skill
Before anything else: ensure the Volcano CLI is present
Every Volcano build and deploy goes through the volcano CLI, so make sure it
is present and up to date before writing or deploying anything:
- Run
which volcano.
- Found: run
volcano upgrade to keep it on the latest version. This is
a harmless, best-effort refresh — it no-ops when already current. Treat any
failure (e.g. a transient network/GitHub hiccup) as a no-op and continue:
the installed CLI still works, a failed upgrade is never a blocker, and it
is not worth troubleshooting.
- Missing: fetch
https://raw.githubusercontent.com/Kong/volcano-cli/main/docs/installation.md
(plain Markdown, readable without the CLI) and run whichever install method
it documents that matches a package manager already on PATH — check
which npm, which pnpm, which bun, which brew in that order, and only
use the documented manual curl install if none are present. A fresh
install is already the latest version. Re-run which volcano to confirm.
These are well-known commands to run as-is, not a script to reconstruct. Don't
assume a package manager that isn't installed, and don't invent steps beyond
what that doc lists. The install-volcano skill exposes this same flow as an
explicit command.
Role & Pairing
Defines the canonical project shape and deploy contract for any Volcano-Hosting-deployable codebase. Covers the volcano/functions/ deployment model, migrations, volcano-config.yaml, environment variables, shared-code conventions, and the build/deploy workflow.
Pair with:
volcano-sdk — the SDK entrypoint and skill router.
volcano-functions — function-writing internals: handler templates, invocation contract, user-context patterns, error handling.
This skill focuses on project shape and deploy mechanics, not on how to write individual function handlers.
Relationship to volcano init
volcano init (from the Volcano CLI) creates a minimal runtime skeleton in a volcano/ directory:
volcano/ # Volcano-managed project directory
volcano/.gitignore # ignores volcano.env, .env.local, logs
volcano/volcano.env # local environment variables (gitignored)
volcano/volcano.env.example # env var documentation (committed)
volcano/migrations/ # SQL migration files
volcano/migrations/README.md # migration conventions
volcano/README.md # next-step instructions
Templates add language-specific files on top of the base scaffold:
volcano init nextjs (aliases: next, next.js, next-js) — adds a minimal Next.js app under web/ plus example functions and migrations.
volcano init javascript (aliases: js, node, nodejs) — adds a starter function handler and volcano-config.yaml.
volcano init python (alias: py) — adds a Python function handler.
volcano init ruby (alias: rb) — adds a Ruby function handler.
Add --example for a more complete demo: volcano init nextjs --example notes, or volcano init javascript|python|ruby --example hello-world.
Note: volcano-config.yaml is created only by the javascript template, not by the base scaffold. You can also create it manually at volcano/volcano-config.yaml.
volcano init does not create an src/api/ directory, an OpenAPI dispatcher, or a single bundled entry point. Functions deploy individually from volcano/functions/.
Project Layout (canonical)
.
├── volcano/ # Volcano-managed directory (created by volcano init)
│ ├── functions/ # Function handlers (deployment root)
│ │ ├── hello.js # → deploys as function "hello"
│ │ ├── notes-summary.js # → deploys as function "notes-summary"
│ │ ├── api/ # → deploys as function "api" (directory function)
│ │ │ └── index.js # entry point inside a directory function
│ │ └── _shared/ # shared code (underscore-prefixed, auto-bundled)
│ │ └── volcano-client.js # canonical client factory
│ ├── migrations/
│ │ ├── 001_init.sql # numeric-prefix alphabetical ordering
│ │ └── 002_add_posts.sql
│ ├── volcano-config.yaml # declarative project config (see "volcano-config.yaml" below)
│ ├── volcano.env # local env vars (gitignored)
│ ├── volcano.env.example # env var documentation (committed)
│ └── .gitignore
├── package.json # optional — needed only for the build model
├── tsconfig.json # optional — needed only for the build model
└── src/ # optional — authoring source for the build model
├── functions/
│ ├── hello.ts
│ └── notes-summary.ts
└── lib/
└── volcano-client.ts
Hard rules:
- Function handlers live ONLY under
volcano/functions/.
- Migrations live ONLY under
volcano/migrations/.
- Shared code that functions import at runtime uses the
_-prefix convention (_shared/, _lib/) so the scanner skips it as a function candidate but the packager bundles it.
volcano-config.yaml is the declarative config for the full project (project settings, databases, variables, buckets/policies, realtime, auth, functions/schedulers, frontends) — see "volcano-config.yaml" below, not just buckets and function visibility.
Function Deployment Model
volcano functions deploy scans volcano/functions/ and deploys each handler as an individual function.
Scanner rules (source: volcano-cli/internal/function/scanner.go):
- Scans ONLY the
volcano/functions/ directory (hard-coded path).
- One function per file:
hello.js → function named hello.
- One function per directory:
api/index.js → function named api (the directory name).
- Entries starting with
_ are skipped as function candidates but bundled as shared code when imported.
- Supported runtimes:
nodejs22.x (minimum), nodejs24.x (default). Also Python and Ruby.
There is no router, no OpenAPI dispatcher, no single entry point. Each file is an independent function with its own URL.
Event & Response Shape
Event (IN)
Functions receive the bare caller payload as event. There is no httpMethod, path, or queryStringParameters. If the caller invokes with { name: 'Alice' }, the handler receives exactly that object.
When the request carries a valid Bearer token and the payload is an object/map, Volcano injects __volcano_auth into the event:
type VolcanoAuth = {
user_id: string;
email: string;
project_id: string;
role: string;
access_token: string;
};
Caveat: if the payload is not an object (string, number, array), __volcano_auth is not injected — the payload passes through unchanged. Always guard for its absence.
Response (OUT)
type FunctionResponse = {
statusCode: number;
body: string;
headers?: Record<string, string>;
};
Use statusCode: 200 for every successful response — not 201, 202, or
any other 2xx. volcano functions invoke reports any non-200 status as a
failed invocation (e.g. Error: failed to invoke function "x": HTTP 201),
so returning 201 Created for a create makes the CLI — and an agent reading
its output — treat a working function as broken and thrash trying to "fix"
it. Return 200 with the created/updated resource in the body; reserve
non-2xx for real failures (400 bad input, 401 unauthenticated, 500
internal).
Handler signature
exports.handler = async (event) => {
const auth = event && typeof event === 'object' ? event.__volcano_auth : undefined;
return { statusCode: 200, body: JSON.stringify({ ok: true }) };
};
For detailed handler templates, invocation patterns, and user-context guidance, see the volcano-functions skill.
Authoring Models
Model A — Native JavaScript (what volcano init scaffolds)
Author .js handlers directly in volcano/functions/. Share code via volcano/functions/_shared/ using the underscore-prefix convention. The packager bundles _shared/ into each function archive alongside the entrypoint, so require("./_shared/...") resolves at runtime.
exports.handler = async (event) => {
const name = event?.name || 'World';
return {
statusCode: 200,
body: JSON.stringify({ message: `Hello, ${name}!` }),
};
};
const { VOLCANO_API_URL, VOLCANO_ANON_KEY, VOLCANO_DATABASE = 'app' } = process.env;
function createVolcanoClient(VolcanoAuthClass, { apiUrl, anonKey, database = 'app', accessToken }) {
if (!apiUrl || !anonKey) throw new Error('Missing VOLCANO_API_URL or VOLCANO_ANON_KEY');
const volcano = new VolcanoAuthClass({ apiUrl, anonKey, ...(accessToken && { accessToken }) });
return volcano.database(database);
}
const createFunctionVolcanoClient = (VolcanoAuthClass, auth) => {
if (!auth?.access_token) throw new Error('Missing function auth access token');
return createVolcanoClient(VolcanoAuthClass, {
apiUrl: VOLCANO_API_URL,
anonKey: VOLCANO_ANON_KEY,
database: VOLCANO_DATABASE,
accessToken: auth.access_token,
});
};
module.exports = { createVolcanoClient, createFunctionVolcanoClient };
const { createFunctionVolcanoClient } = require('./_shared/volcano-client');
let VolcanoAuthClass;
async function getVolcanoAuthClass() {
if (!VolcanoAuthClass) {
const sdk = await import('@volcano.dev/sdk');
VolcanoAuthClass = sdk.VolcanoAuth || sdk.default;
}
return VolcanoAuthClass;
}
exports.handler = async (event) => {
const input = event && typeof event === 'object' ? event : {};
const auth = input.__volcano_auth;
if (!auth?.access_token) {
return { statusCode: 401, body: JSON.stringify({ error: 'Unauthorized' }) };
}
const VolcanoAuth = await getVolcanoAuthClass();
const volcano = createFunctionVolcanoClient(VolcanoAuth, auth);
const { data, error } = await volcano.from().().();
(error) { : , : .({ : error. }) };
{ : , : .({ : data ?? [] }) };
};
A Model A function that imports an npm package (including @volcano.dev/sdk, as notes-summary.js does) MUST declare it in a package.json, or the import fails at runtime with Cannot find package '@volcano.dev/sdk'. volcano functions deploy uploads dependency manifests (package.json) and lets the runtime install from them; it deliberately never uploads your local node_modules (it's excluded from the archive). Declaring the import is not enough on its own — there must be a manifest listing it:
{
"name": "functions",
"private": true,
"dependencies": { "@volcano.dev/sdk": "latest" }
}
The scanner skips package.json/node_modules as function candidates (they are not handlers), so this file sits alongside your .js handlers without becoming its own function. hello.js above needs no manifest because it imports nothing.
Model B — Build-into-functions (TypeScript)
Author TypeScript in src/functions/<name>.ts, bundle each to volcano/functions/<name>.js with esbuild, then deploy. This lets you use TypeScript, src/lib/ shared code, and npm dependencies that esbuild bundles.
package.json:
{
"name": "my-volcano-project",
"version": "1.0.0",
"private": true,
"scripts": {
"build:functions": "esbuild src/functions/*.ts --bundle --platform=node --target=node22 --format=cjs --outdir=volcano/functions",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@volcano.dev/sdk": "latest"
},
"devDependencies": {
"@types/node": "^22.10.2",
"esbuild": "^0.24.0",
"typescript": "^5.7.2"
}
}
tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "Node",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true,
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"]
}
Source handler (src/functions/hello.ts):
import { createFunctionVolcanoClient } from '../lib/volcano-client';
export const handler = async (event: { name?: string; __volcano_auth?: { access_token?: string } }) => {
const name = event.name || 'World';
return { statusCode: 200, body: JSON.stringify({ message: `Hello, ${name}!` }) };
};
After npm run build:functions, the output volcano/functions/hello.js is what deploys.
Critical: volcano functions deploy does NOT run your build. The built .js files must already exist under volcano/functions/ on disk. Always run npm run build:functions before deploying. Committing the built output is the safest approach — the packager applies project ignore rules when bundling directory contents and shared libraries, so gitignoring volcano/functions/ can cause packaging edge cases.
Canonical Shared Client Pattern
Both models use the same factory pattern, adapted from the nextjs-notes starter (volcano/functions/_shared/volcano-client.js):
| Factory | When to use |
|---|
createFunctionVolcanoClient(VolcanoAuthClass, auth) | Inside function handlers — uses __volcano_auth.access_token |
createWebVolcanoClient(VolcanoAuthClass) | Browser/Next.js — uses NEXT_PUBLIC_* env vars |
Both read VOLCANO_API_URL, VOLCANO_ANON_KEY, and VOLCANO_DATABASE from process.env and fall back to database name 'app'.
For browser-side client setup, see the volcano-nextjs skill.
Environment Variables
Functions receive only user-defined project variables. Volcano does not auto-inject VOLCANO_API_URL, VOLCANO_ANON_KEY, or VOLCANO_DATABASE into the function runtime. You must define these as project variables.
Deploy variables:
volcano variables deploy
This reads from volcano/volcano.env (or a specified file) and sets project-scoped environment variables that all functions in the project receive at runtime.
Canonical variable names (the shared client factory expects these):
| Variable | Purpose |
|---|
VOLCANO_API_URL | Volcano API endpoint |
VOLCANO_ANON_KEY | Public anon key |
VOLCANO_DATABASE | Database name (defaults to 'app' if unset) |
Custom secrets (any name): STRIPE_SECRET_KEY, SENDGRID_API_KEY, SLACK_WEBHOOK_URL, etc.
volcano/volcano.env.example (committed for documentation):
VOLCANO_API_URL=https://api.volcano.dev
VOLCANO_ANON_KEY=your-anon-key
VOLCANO_DATABASE=app
# Custom secrets (add as needed)
# STRIPE_SECRET_KEY=sk-...
Never hardcode secrets in handler code.
DATABASE_URL is auto-injected (unlike VOLCANO_API_URL/VOLCANO_ANON_KEY/VOLCANO_DATABASE above) and carries full admin access (application_name=volcano_full_access) by default. Always use the SDK client (volcano.from(...)), not DATABASE_URL. Direct Postgres access is a discouraged last resort with untested, unbounded surface area — see the volcano-database skill's "Direct Postgres Access" section before ever reaching for it.
Migrations & Row-Level Security
Migration conventions
- Location:
volcano/migrations/ (no subdirectories).
- Filename:
NNN_description.sql — numeric prefix for alphabetical ordering (e.g., 001_init.sql, 002_add_posts.sql). Ordering is alphabetical; the numeric prefix is a convention, not enforced.
- Idempotency: use
CREATE TABLE IF NOT EXISTS, DROP POLICY IF EXISTS before CREATE POLICY, and CREATE OR REPLACE FUNCTION.
- One statement per file:
volcano migrations deploy executes each file as a single SQL
statement — it does not split on ; or batch multiple statements, and
rejects a multi-statement body outright (ERROR: Multiple statements are not supported). Do NOT wrap a file in BEGIN; ... COMMIT;, and do NOT put
more than one statement in one file. A schema change that needs several
statements (create table, add indexes, enable RLS, add policies) is
several sequentially-numbered files, not one — see "Canonical migration
with RLS" below.
Deploy:
volcano migrations deploy --all -d app
Auth helper functions
Volcano automatically installs these SQL helper functions in every project database (source: volcano-hosting/internal/database/auth_helpers.go). They read JWT claims from PostgreSQL session variables set by the request pipeline.
| Helper | Session variable | Returns |
|---|
auth.uid() | request.jwt_sub | UUID — authenticated user's id (or NULL) |
auth.email() | request.jwt_email | TEXT — authenticated user's email |
auth.role() | request.jwt_role | TEXT — 'authenticated', 'anonymous', or 'anon' (see below) |
auth.is_authenticated() | (derived) | BOOLEAN — auth.uid() IS NOT NULL |
Important: the schema prefix auth. is required. Write auth.uid(), not bare uid().
auth.role() has three distinct values, not two:
'authenticated' — a real signed-up user (email/password or OAuth).
'anonymous' — a guest user created via signUpAnonymous(). They DO have a valid JWT and a real auth.uid(); RLS still applies to them like any authenticated caller.
'anon' — no token at all (the SQL function's fallback default, and also the underlying Postgres role name for unauthenticated access).
A policy that checks auth.role() = 'anon' to gate out guests is a common mistake — guest sessions report 'anonymous', not 'anon'. Prefer auth.uid() IS NOT NULL (or auth.is_authenticated()) when the intent is "any signed-in caller, including guests."
Canonical migration with RLS
One statement per file, numbered sequentially — each -- <filename> comment
below marks a separate file, not a section of one file:
CREATE TABLE IF NOT EXISTS posts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL DEFAULT auth.uid(),
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'draft',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS posts_user_id_idx ON posts(user_id);
CREATE INDEX IF NOT EXISTS posts_created_at_idx ON posts(created_at DESC);
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS posts_select_own ON posts;
CREATE POLICY posts_select_own ON posts
FOR SELECT USING (user_id = auth.uid());
DROP POLICY IF EXISTS posts_insert_own posts;
POLICY posts_insert_own posts
(user_id auth.uid());
POLICY IF posts_update_own posts;
POLICY posts_update_own posts
(user_id auth.uid())
(user_id auth.uid());
POLICY IF posts_delete_own posts;
POLICY posts_delete_own posts
(user_id auth.uid());
For public-read patterns, add a policy (its own file) with USING (status = 'published') alongside the owner policies.
volcano-config.yaml
Declarative configuration for the full project: project settings, database
assertions, variables, buckets/policies, realtime, auth (providers, email,
templates, managed pages), function visibility/schedulers, and frontend
custom domains. Located at volcano/volcano-config.yaml or root
volcano-config.yaml. Only declared sections are reconciled — everything
else is left untouched.
pull exports the target's current configuration to a file; deploy
uploads a file and reconciles the target to match it. Only the command
namespace changes between local and cloud:
| Target | Export to file | Apply from file |
|---|
Local (volcano start) | volcano config pull | volcano config deploy |
Cloud (volcano login + volcano use) | volcano cloud config pull | volcano cloud config deploy |
Use pull to seed a manifest from an existing project instead of
hand-writing one from scratch, and deploy --dry-run to preview reconcile
actions before applying.
Pull exports include plaintext variable values, but not other secrets.
Write-only fields (SMTP password, OAuth client secrets, custom domain TLS
material) are omitted from the export and stay unchanged unless set
explicitly, but variables[].value is included in the clear. Never
commit a pulled manifest as-is: replace secret values with ${ENV_VAR}
references (interpolated from the CLI environment before upload) before it
touches version control.
A declared variables section is a second, competing source of truth for
variables — don't run both. If a manifest declares variables, config deploy fully syncs the project's variables to exactly that list, deleting
any variable not listed. That conflicts with volcano variables deploy /
volcano cloud variables deploy (see "Environment Variables" above) if both
are used for the same variables: whichever runs last wins, and config deploy will delete anything only variables deploy set. Pick one path for
variables per project — typically variables deploy from volcano.env for
day-to-day secrets, and only add variables to the manifest if you want it
to be the single source of truth instead.
Partial example (see the full schema at
volcano-hosting/docs/projects/configuration.md — it also covers project,
databases, realtime, auth, and frontends, omitted here for brevity):
version: 1
variables:
- name: STRIPE_SECRET_KEY
value: ${STRIPE_SECRET_KEY}
buckets:
- name: avatars
file_size_limit: 5242880
allowed_mime_types: [image/jpeg, image/png]
policies:
- name: owner-read-write
operation: SELECT
definition: "auth.uid() IS NOT NULL"
functions:
- name: notes-summary
public: false
schedulers:
- name: refresh-cache
cron: "*/5 * * * *"
enabled: true
{ }
Hard rules:
version: 1 is required. It's a schema version, not a Terraform-style
state serial — it never needs bumping between deploys.
- Functions, frontends, databases, and buckets are never created or
deleted through the manifest — they must already exist. A declared entry
for a resource that doesn't exist is reported
skipped; a deployed
resource missing from a declared section is reported missing. Both are
non-fatal warnings.
- Omitted sections/fields are left untouched (patch semantics). An empty
declared list for a fully-synced collection —
variables,
buckets[].policies, auth.providers.oauth, auth.email.templates,
functions[].schedulers — deletes everything currently in it. These are
destructive by design; declaring the section at all means it's the source
of truth.
- Each bucket policy
operation must be SELECT, INSERT, UPDATE, or
DELETE. Each declared function needs public set (boolean).
- Function visibility can also be set imperatively via
volcano cloud functions update --public / --private; functions deploy
itself does not read volcano-config.yaml.
There is no state file. Don't invent one, and don't build a manual
rollback step for a failed config deploy — neither exists or is needed:
- Every
config deploy (--dry-run included) diffs the manifest against the
project's live configuration at request time. There's no cached snapshot of
a prior apply to reconcile against.
- A failed apply still attempts every other planned change and reports
created / updated / deleted / unchanged / error per entry;
already-applied changes are not rolled back.
- Re-running
config deploy with the same file is always safe: entries
that already landed report unchanged; only entries that failed or still
differ produce a new action. Fix the underlying issue (bad cron expression,
plan-gated field, etc.) and re-run the same command — don't try to track or
hand-edit applied state anywhere.
Deploy & Local-Dev Workflow
Local development
volcano start
The local stack reads volcano/volcano.env for environment variables. Functions can be invoked locally through the local API endpoint.
Local deploy sequence
volcano config pull
npm run build:functions
volcano variables deploy
volcano functions deploy --all
volcano config deploy
volcano migrations deploy --all -d app
Cloud deploy (requires volcano login + volcano use)
volcano cloud config pull
npm run build:functions
volcano cloud variables deploy
volcano cloud functions deploy --all
volcano cloud config deploy
volcano cloud migrations deploy --all -d app
Order matters: variables before functions (so handlers have env vars on first deploy), config after functions (so visibility targets exist), migrations last (schema is ready for runtime queries). Note that step 4 (config deploy) runs after step 2 (variables deploy): if the manifest declares a variables section, step 4 wins and will overwrite or delete variables set by step 2 — keep variables out of the manifest unless it is the single source of truth (see "volcano-config.yaml" above).
Verify a local deploy (smoke test)
Deploying is not the finish line — exercise each function and assert on the
result (this is AGENTS.md's "exercise what was built" step). Don't report
success from the deploy output alone.
One-line smoke test — volcano functions invoke. In local mode this runs the
function as the pre-provisioned local user, so event.__volcano_auth is
populated (id 11111111-…, clearwater@volcano.dev). It's the fastest way to
confirm a function deployed, runs, and receives an auth context:
volcano functions invoke <name> --payload '{"title":"Buy milk"}' --json
Check the printed status/body. A missing dependency, a broken handler, or a
bad payload shape surfaces here immediately.
Caveat — auth.uid() is NULL under this harness, so don't judge RLS/owner
logic by it. volcano functions invoke is a credential-free local harness
(see its --help): it populates event.__volcano_auth, but it does not
establish a database auth session, so SQL auth.uid() returns null here. A
function that inserts an owner-scoped row through a user_id column defaulting
to auth.uid(), or whose table has RLS policies using auth.uid(), will fail
with null value in column "user_id" or return zero rows when invoked this way
— that is the harness context, not a bug in the function, and it is not a
reason to rewrite a correct handler (e.g. do NOT start hand-setting user_id
just to satisfy the CLI). Verify per-user / RLS behavior with the SDK recipe
below (a real signed-in user), where auth.uid() resolves — exactly as it does
for real callers in the browser/app and in the cloud.
Multi-user / RLS checks — mint real users with the SDK. The CLI invoke is
always that single local user, so it can't verify per-user isolation (that user
A cannot see user B's rows). For that, sign up throwaway users with the SDK and
invoke as each. Read apiUrl/anonKey from volcano status:
import { VolcanoAuth } from '@volcano.dev/sdk';
const apiUrl = 'http://localhost:8000';
const anonKey = 'ak-0000000000000000000000000000000000000000';
async function tokenFor(email) {
const auth = new VolcanoAuth({ apiUrl, anonKey });
let { session } = await auth.signUp({ email, password: 'Smoke-Test-1!', signInWhenAllowed: true });
if (!session) ({ session } = await auth.signIn({ email, password: 'Smoke-Test-1!' }));
if (!session?.access_token) throw new Error(`no session for ${email}`);
return session.access_token;
}
const token = await tokenFor(`smoke-${Date.now()}@example.com`);
const volcano = ({ apiUrl, anonKey, : token });
{ data, status, error } = volcano..(, { });
.({ status, data, error });
(error || status < || status >= ) process.();
Easy wrong guesses to avoid (all verified against the SDK):
- The methods are
auth.signUp / auth.signIn — there is no signInWithPassword.
- Auth responses are
{ user, session, error } at the top level; the token
is session.access_token, not data.session.access_token.
functions.invoke(name, payload) returns { data, status, headers, version, error } — check error and status before trusting data.
End-to-end checklist: build → local deploy → verify (am I done?)
The single "am I done?" gate for a full build-to-local-deploy task. Each phase
links the detail above or the domain skill that owns it; walk it top to bottom
and don't report success until every applicable box holds (skip a box only when
the project genuinely doesn't use that resource).
Build
Local deploy — run the "Local deploy sequence" above, in that order:
Verify — deploying is not the finish line (see "Verify a local deploy"):
The per-domain skills carry their own "Verification Checklist" for domain-specific
asserts — consult the ones the build touched: volcano-functions, volcano-auth,
volcano-database, volcano-storage, volcano-realtime.
Cloud is out of scope here — never auto-deploy to cloud (see AGENTS.md safety model).
Forbidden Patterns
- Do NOT create an
src/api/index.ts route dispatcher or openapi.yaml — Volcano Functions deploy individually from volcano/functions/, not through a single entry point.
- Do NOT expect
VOLCANO_API_URL, VOLCANO_ANON_KEY, or VOLCANO_DATABASE to be auto-injected — define them as project variables via volcano variables deploy (local) or volcano cloud variables deploy (cloud).
- Do NOT use
VOLCANO_DB_NAME — the canonical variable is VOLCANO_DATABASE.
- Do NOT use bare
uid(), email(), role() — always use the auth. schema prefix: auth.uid(), auth.email(), auth.role().
- Do NOT use
pg/pg-pool/DATABASE_URL as a replacement for the SDK client — use VOLCANO_DATABASE and the SDK for all standard CRUD. Direct Postgres access is a discouraged, narrowly-scoped last resort ONLY for query-builder gaps that are provably impossible otherwise (joins/upserts/multi-statement transactions), NOT a general-purpose data layer or a reason to introduce an ORM as project architecture, and ONLY after rewriting application_name to volcano_user_access:{user_id} (raw DATABASE_URL bypasses RLS) — see volcano-database's "Direct Postgres Access" section.
- Do NOT use
jsonwebtoken or bcryptjs directly — Volcano Auth handles tokens and password hashing.
- Do NOT assume
__volcano_auth is always present — it is injected only when the payload is an object and the request carries a valid token.
- Do NOT expect
volcano functions deploy to run your build — built .js files must exist under volcano/functions/ on disk before deploy.
- Do NOT put more than one SQL statement in a migration file, and do NOT wrap one in
BEGIN; ... COMMIT; — volcano migrations deploy executes each file as a single statement and rejects multi-statement bodies (ERROR: Multiple statements are not supported). Split a multi-step schema change into several sequentially-numbered single-statement files instead.
Verification Checklist
- Each deployed function was smoke-tested, not just deployed —
volcano functions invoke <name> (runs as the local user) for a basic check, and the SDK recipe for multi-user/RLS isolation (see "Verify a local deploy").
- Function handlers exist under
volcano/functions/ and each exports handler.
- Shared code uses
_-prefix directories (_shared/, _lib/).
volcano/migrations/ contains .sql files with numeric-prefix alphabetical naming, each with exactly one SQL statement (no BEGIN/COMMIT, no multiple ;-terminated statements in one file).
- RLS policies use
auth.uid() (with schema prefix), not bare uid().
volcano-config.yaml (if present) has version: 1. There is no minimum
section requirement — a manifest can declare only variables, only
project, etc.; omitted sections are left untouched, not an error.
- Environment variables are deployed via
volcano variables deploy (local) or volcano cloud variables deploy (cloud) — not assumed auto-injected.
VOLCANO_DATABASE is used (not VOLCANO_DB_NAME).
- If using the build model:
npm run build:functions produces .js files under volcano/functions/ before deploy.
- Any npm package a native-JS function imports (e.g.
@volcano.dev/sdk) is declared in a volcano/functions/package.json — an undeclared import fails at runtime with Cannot find package.
- No
src/api/, openapi.yaml, dist/index.js, or dev-server.mjs in the project.
Companion Skills
This skill defines the platform deploy contract. For domain-specific guidance:
volcano-sdk — top-level orientation and skill router.
volcano-functions — handler templates, invocation contract, user context, error handling.
volcano-auth, volcano-database, volcano-storage, volcano-realtime — per-domain APIs and patterns.
volcano-nextjs — Next.js frontend patterns (AuthProvider, middleware, server actions).
volcano-typescript — canonical TypeScript type definitions.
volcano-error-handling — reusable error-handling infrastructure.