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:

  1. Write the function in functions/{name}.js
  2. Add it to gipity.yaml under function_definitions (name, auth, tables/fetch_domains)
  3. Write tests in tests/api/{name}.test.js
  4. Deploy: project_deploy target=dev
  5. 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:

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:

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}`)`.

fetch is for third-party APIs only. Don't fetch the platform's own services at a.gipity.ai (llm/image/tts/location/…) - the sandbox blocks every *.gipity.ai fetch, and fetch_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

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:

- 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

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 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: