Everything you need to build a backend on Gipity: serverless functions, databases, and REST APIs. This is the guide for the web-fullstack template (frontend in src/ + functions + a migrations/ database) and the api template (functions only, no frontend). Both are blank-but-wired: they deploy green with one example function, its smoke test, and (web-fullstack) a commented-out example migration. Those files exist to prove the wiring - replacing or deleting them is the expected first step of a real app, so clear them out without ceremony.
Templates install real files - Read one before you change it. web-fullstack/api write a full set of files (gipity.yaml, frontend, an example functions/example.js, and more); they already exist on disk. To change one, file_read it first, then file_write - a blind file_write on a file you haven't read fails with "File has not been read yet". Don't treat template files as new.
Workflow
For every new function:
- Write the function in
functions/{name}.js - Add it to
gipity.yamlunderfunction_definitions(name, auth, tables/fetch_domains) - Write tests in
tests/api/{name}.test.js - Deploy:
project_deploy target=dev - Run tests:
test_run
Strongly recommended: write a test file for every new function. Use your judgment - skip tests only for trivial glue code (one-liners, obvious passthroughs), scratch/throwaway experiments, or when the user explicitly opts out. Default is: write tests. Always deploy and test after creating functions - don't ask permission, just do it.
Where the testing details live: load the app-testing skill - the test() / ctx.fn.call contract, calling authenticated functions as a fixture user (ctx.fn.callAs(ctx.users.alice, …)), and testing auth: user functions for the signed-in happy path and cross-user isolation.
In test files, do NOT import node:test or node:assert - the harness provides test() and assert as globals. Writing import { test } from 'node:test' or import assert from 'node:assert/strict' will cause a duplicate-identifier SyntaxError.
After every deploy, read the phase results. Any phase with status: warning starts with "FIX" and means something is deprecated or shaped wrong - usually in gipity.yaml. Treat these as TODOs, not info: fix the underlying file and redeploy. status: failed phases block the deploy from completing correctly - fix before moving on.
Writing Functions
Functions are JavaScript files in functions/. Each exports a default async handler:
// functions/get-items.js
export default async function getItems(ctx, { db }) {
const { category } = ctx.body;
const { rows } = await db.query(
'SELECT * FROM items WHERE category = $1', [category]
);
return rows;
}
Arguments:
- First:
ctx= request context with{ body, query, headers, method, auth } - Second: services object
{ db, fetch, secrets, env, console, jobs, guid, randomToken, storage, llm, image, tts, sound, music, video, transcribe, location, notify, email }- the last ten are app services (see "App services from a function" below); each only works if declared ingipity.yamlservices: [...].storage.delete(fileGuid)removes an uploaded file's bytes + variants (see theapp-filesskill); likedb/jobsit needs no declaration.guid('prefix')mints short ids;randomToken(chars?)mints crypto-random SECRETS (API keys, invite codes; 16-64 chars, default 40) - the runtime has nocrypto, so this is the one entropy source.
Return value becomes { data: <your return> } in the HTTP response. The data wrapper is always present on 2xx, so client code must read json.data, never json directly. On 4xx/5xx the body is { error: { code, message } } instead.
Imports must be relative (./helper.js) - bare imports (import _ from 'lodash') and Node built-ins (fs, path, crypto) are rejected; there is no node_modules. Full ES-module rules, multi-file functions (functions/<name>/index.js + helpers), shared functions/_lib/ modules, and kits that ship backend pieces: read app-development-reference.
Request context (ctx)
ctx.body // request arguments land here - both Gipity.fn(name, body) and the test harness ctx.fn.call(name, body) send args in the POST body, so read every param (including a limit/page/filter) from ctx.body
ctx.query // URL query string (?a=1) - only populated if you build a URL with one yourself; the SDK and test harness never use it, so don't reach for ctx.query expecting a passed-in arg
ctx.headers // HTTP headers (safe subset - content-type, accept, user-agent, x-request-id, origin, referer, x-forwarded-for, x-real-ip)
// x-forwarded-for is the original caller's IP chain - use ctx.headers['x-forwarded-for']?.split(',')[0].trim() to get the user's IP,
// which you pass to the injected location.ip({ ip }) capability from a function (see app-location)
ctx.method // HTTP method (always uppercase, e.g. "POST")
ctx.auth // { userId, userGuid, displayName, role } - populated when auth_level is 'user' or 'member'
// Key per-user data on userGuid (stable external id). userId is an internal numeric id - don't store it. See "Using auth".
Calling functions from the client
Every Gipity template auto-injects the client SDK (gipity.js), which exposes Gipity.fn(name, body?, opts?) for calling functions. Always use Gipity.fn - it handles the { data: ... } envelope unwrap, throws on non-2xx with the server's error message, and routes failures through the auto-monitor so they show up in gipity logs app. Hand-rolling fetch and reading json.field instead of json.data.field is the most common bug agents ship - it silently returns undefined on a 200, the save looks "rejected", and nothing reaches gipity logs app because the request succeeded on the wire.
// Inside your entry script - no setup needed; the SDK reads data-app from its script tag.
const data = await Gipity.fn('list-items');
document.getElementById('count').textContent = `${data.items.length} items`;
If a 200 comes back with a shape you didn't expect (missing field, wrong type), report it explicitly so it lands in gipity logs app:
const data = await Gipity.fn('add-post', { x, y, text });
if (!data?.post) {
Gipity.captureError(new Error('add-post returned no post field'));
return;
}
The web-fullstack template is the canonical reference. See app-debugging for everything else the auto-monitor catches and what it doesn't.
Signaling failure / status codes
Two ways a function reports a problem, and they map to different HTTP statuses:
- Validation / business errors →
return { error: 'name is required' }on a 200. The client readsdata.errorafterGipity.fn(the{ data }wrapper still applies). Use this for expected, recoverable conditions ("name is required", "item not found", "already claimed"). - System / unexpected errors →
throw. The runtime catches it and returns a 500{ error: { code, message } }(the envelope from the Return value note above). Use this for bugs and broken invariants - don't swallow them into{ error }on a 200, that hides real failures from the monitor.
There is no first-class API for returning an arbitrary 4xx status (e.g. 403/404/422) from app code - a function either returns 200 (with or without an error field) or throws → 500. Auth gating (401/403) is the platform's job: declare auth: user / auth: member in gipity.yaml and the runtime rejects unauthenticated callers before your handler runs. So: validate-and-return { error } for "the request was understood but can't be fulfilled", throw for "something is broken".
Database
Functions read and write a per-app PostgreSQL database through the injected db helper - db.query plus findOne/findMany/insert/update/delete, db.tx transactions, and db.describe. Schema lives in migrations/; each function's table access is granted with tables: in gipity.yaml (below).
Full reference → the app-database skill: setup and PostgreSQL types, the db helpers and db.tx, per-invocation limits (and the limits: block to raise them), RETURNING / array-param / upsert patterns, views and * globs in tables:, and why schema must live in migrations/ rather than a one-off db_sql.
External HTTP (via fetch)
export default async function getWeather(ctx, { fetch }) {
const { zip } = ctx.body;
const res = await fetch('https://api.example.com/weather?zip=' + zip);
return await res.json();
}
Limits: max_fetches 10. 10s timeout. 1MB response limit — watch this with real third-party APIs; a "give me everything" / ?detail=full query easily blows the cap and the call throws. Ask for the narrowest response (paginate, pick fields, a smaller mode=) rather than fetching big and trimming.
Security: Only declared fetch_domains are allowed. Private IPs blocked (SSRF protection).
Response shape: res is WHATWG-style — res.ok (true only on 2xx), res.status, res.statusText, res.headers.get('content-type'), await res.text(), await res.json(). The usual guard works: if (!res.ok) throw new Error(\HTTP ${res.status}`)`.
fetchis for third-party APIs only. Don'tfetchthe platform's own services ata.gipity.ai(llm/image/tts/location/…) - the sandbox blocks every*.gipity.aifetch, andfetch_domains: ['a.gipity.ai']does not unblock it. Call those through the injected app-service capabilities below instead.
App services from a function (via llm, image, tts, …)
The platform's own services are injected as capabilities on the second argument - peers of db and fetch - so a function calls them server-to-server with no token and no fetch:
export default async function summarize(ctx, { db, llm }) {
const { rows } = await db.query('SELECT body FROM notes WHERE user_guid = $1', [ctx.auth.userGuid]);
const { text } = await llm({ model: 'gpt-5.4-mini', messages: [
{ role: 'user', content: 'Summarize:\n' + rows.map(r => r.body).join('\n') },
]});
await db.query('INSERT INTO summaries (user_guid, summary) VALUES ($1, $2)', [ctx.auth.userGuid, text]);
return { summary: text };
}
- name: summarize
auth: user
tables: ['notes', 'summaries']
services: ['llm'] # required - declares which services this function may call
- Available:
llm·image·tts·sound·music·video·transcribe·location.ip()/location.geocode()·notify({ to, notification })·email({ to, subject, text }). Each returns the same shape the HTTP service does ({ text, usage, … },{ url, … }, …). Seeapp-llm,app-image,app-tts,app-audio,app-video,app-location,app-notify,emailfor per-service params. email()sends from a deployed app (declareservices: ['email']):await email({ to, subject, text?, html?, replyTo?, fromName? })→{ sent, skipped, results }. Platform-brokered and owner-billed (one credit per recipient sent); the From address is fixed togipity@gipity.ai(the app sets onlyfromName+replyTo). This replaces hand-built outbox tables + cron workflows - just call it where the send decision is made. Verify after deploy withgipity email test <to>. Full guide: theemailskill.- Declare each one in
services: [...]- calling an undeclared service throws. Calls are capped per invocation (max_llm_callsforllm,max_service_callsfor the rest). - Calling a billed service from a function requires owner_pays. Services now default to
user_pays(safe-to-share - the app's users pay for their own usage). A function has no browser session for user_pays consent, so auser_paysservice call from it throws - switch the service toowner_pays(viaproject_settings) to call it from a function, or make the call from the browser with a signed-in user. Exceptions:location,notify, andemailare exempt from this gate -locationis never credit-billed, andnotify/emailare always owner-billed (there's no "user pays to be notified/emailed" model), so all three work from a function regardless of billing mode. location.ip()needs an explicit public IP (e.g.location.ip({ ip: ctx.headers['x-forwarded-for']?.split(',')[0].trim() })) - the server can't see the user's IP on its own.
Secrets (via secrets)
Never hardcode an API key, password, or token in source. Store it as a secret and read it at runtime — encrypted at rest (AES-256-GCM), write-only (values never leave the server), and never shipped to the browser.
const apiKey = await secrets.get('OPENAI_API_KEY'); // null if unset
Set one from the CLI or the secret_manage tool (UPPER_SNAKE_CASE names):
gipity secrets set OPENAI_API_KEY sk-... # this project only
gipity secrets set OPENAI_API_KEY sk-... --account # shared across ALL your projects
gipity secrets list # names + masked previews (never values)
Scope: a secret is either project-scoped (this app) or account-scoped (shared across all your projects — set a key like OPENAI_API_KEY once and every app inherits it). secrets.get(name) resolves the project secret first, then falls back to the account secret of the same name, so a project can override the shared default.
Environment Variables (via env)
const mode = env.APP_MODE; // Read-only
Logging (via console)
console.log('Processing', { orderId });
console.warn('Rate limit approaching');
console.error('Failed', { error: err.message });
Captured in execution logs (fn_manage logs).
Using auth
ctx.auth is { userId, userGuid, displayName, role }, populated when the function's auth is user or member.
Key all per-user data on ctx.auth.userGuid. userGuid (e.g. u_qpx38ujc) is the stable external identity; userId is an internal numeric DB id that isn't stored in app data or returned to clients (same rule as everywhere else on the platform - external code uses guids, not ids). Store it in a user_guid TEXT column and filter every read/write by it. If userGuid is missing the caller isn't signed in - return the auth error:
export default async function myTodos(ctx, { db }) {
const userGuid = ctx.auth?.userGuid;
if (!userGuid) return { error: 'Sign in required.' };
const { rows } = await db.query(
'SELECT * FROM todos WHERE user_guid = $1',
[userGuid]
);
return { todos: rows };
}
gipity.yaml - function permissions
Functions auto-deploy as public endpoints, and the deploy pipeline auto-adds an entry to gipity.yaml for any undeclared function. For the full manifest format, phases, and deploy flags see deploy; this section covers only function_definitions (per-function permissions).
Declare in function_definitions only when you need to:
- Change auth level (
user,member) - Grant database access (
tables) - Allow external HTTP calls (
fetch_domains) - third-party APIs only (nota.gipity.ai; see External HTTP above) - Declare app services (
services: [...]) - which platform services this function may call (llm/image/tts/sound/music/video/transcribe/location/notify/email). These are injected as capabilities on the second arg ((ctx, { llm })) and run server-to-server - nofetch, no token. An undeclared service throws; an unknown name fails the deploy. See "App services from a function" above andapp-llm/app-location.
- name: functions
type: functions
source: functions
function_definitions:
- name: get-items
auth: public
tables: [items] # DB access
- name: get-quote
auth: public
fetch_domains: [api.example.com] # HTTP access
- name: my-todos
auth: user # Requires login
tables: [todos]
- name: hello
auth: public # Auto-added by deploy (no special permissions)
Writing Tests
Write a test file per function in tests/api/<name>.test.js and run test_run. ctx.fn.call(name, params) invokes your deployed function with the { data } envelope unwrapped (read result.field; it throws on 4xx/5xx) against an isolated, auto-reset test database - no teardown to write. For auth: user functions, ctx.fn.call is the logged-out path; use ctx.fn.callAs(ctx.users.alice, …) for the signed-in happy path and cross-user isolation.
Full reference → the app-testing skill: the test() / ctx.fn.call / callAs contract, the node:test import gotcha, ctx.isTest and what's isolated during a run, concurrency-safe fixtures, testing auth: user functions, and testing file serving with ctx.upload.
Calling Functions
Public functions need no authentication:
curl -s -X POST https://a.gipity.ai/api/<PROJECT_GUID>/fn/<name> \
-H 'Content-Type: application/json' -d '{"key": "value"}'
Always use the project GUID in URLs - never the slug.
gipity fn call <name> [body] is the quick CLI way to hit a deployed function - but note it runs authenticated as the logged-in CLI user, so calling a mutating auth: user function writes real rows owned by your account (real production state on prod). To test the logged-out path, call the endpoint without an auth header (plain curl). See app-debugging.
Auth Levels
- public - no auth needed, call directly
- user - requires X-App-Token, X-Api-Key, or Authorization header (load
app-authfor details) - member - auth header + project membership
Versioning, Management Commands & Rate Limits
Function versioning & rollback (fn_manage rollback), management commands (fn_manage list/logs/delete), and API rate limits: read app-development-reference.
On-demand actions: button → function vs. a Gipity workflow
When the user wants to "trigger" or "run on demand" something, pick by where it runs:
- A button in the app → frontend button calls
Gipity.fn(name, body)→ your function (the pattern above). Use this whenever the action needs the browser: the user's current GPS location, a file they picked, form input - anything client-side. "Record my location on a button" is this: only the browser has the device's location. - A Gipity workflow (
workflowskill) → server-side automation that runs with no browser, on atrigger:schedule(cron),manual(gipity workflow run <name>),webhook, or arecord.after_insert/update/deletedata change. Author it asworkflows/<name>.yamlwith aworkflowsdeploy phase. Use it for recurring / time-driven / event-driven server jobs ("every morning", "when a row lands").
A workflow can't read the user's device or DOM, so don't model a browser-driven button as one.
Related Skills
Load these for specific features:
app-development-reference- the long tail of this guide: ES module rules, multi-file functions,_libshared modules, backend-bearing kits, versioning & rollback, management commands, rate limitsapp-database- the per-app Postgres database: migrations, thedbhelper, transactions, table permissionsapp-testing- testing deployed functions:ctx.fn.call/callAs, the isolated test DB, fixturesworkflow- scheduled / manual / webhook / data-change server automation (workflows/*.yaml)realtime-scheduled-app- recipe wiring functions + DB + realtime + a scheduled poster end-to-endweb-app-basics- HTML/CSS/JS patterns and templatesdeploy- the deploy pipeline andgipity.yamlmanifestapp-debugging- inspect deployed pages, screenshots, function logsapp-auth- user authentication (Sign in with Gipity)app-realtime- real-time multiplayer rooms and WebSocketapp-llm- AI/LLM service for your appapp-image- image generation (Gipity Image)app-video- video generation and understandingapp-audio- sound effects, music, transcriptionapp-tts- text-to-speech (Gipity Speech)app-files- file uploads (Gipity Storage, up to 30GB)app-location- user location & geocoding for deployed apps3d-engine- minimal 3D multiplayer template (Three.js + Rapier + Gipity Realtime)3d-world- playable 3D multiplayer starter on top of 3d-engine2d-game- 2D games (Phaser 3)