Using manggaleh — guide for AI agents

A practical reference for another Claude Code session to build on / integrate with a manggaleh backend. manggaleh is a multitenant backend-as-a-service: each project gets an isolated Postgres database with end-user auth, an auto-generated REST Data API, file storage, server-side functions, scheduled jobs, realtime, webhooks and email — all behind one SDK (@manggaleh/sdk) and one CLI (mg / @manggaleh/cli).

If you (the agent) just need to use an existing manggaleh service, you mostly need: the API origin (e.g. https://api.manggaleh.com), the project slug (the tenant), the environment (dev/staging/prod/…), and an API key. Get the rest from this doc.


0. Capabilities — what manggaleh can do, and how to reach it

Capability What you can do SDK (@manggaleh/sdk) CLI (mg)
End-user auth password sign up/in/out + sessions; passwordless: email-OTP; on-demand email verification (OTP), forgot/reset & change password; optional signup codes; per-environment client.auth.signUp/signIn/signOut/getSession + sendOtp/signInWithOtp + verifyEmail/sendPasswordResetOtp/resetPasswordWithOtp/changePassword (app-side; CLI is owner-side)
Admin user-management list / look-up by email / set-password / disable (revoke sessions) / delete end-users (service key only) client.admin.users.list/findByEmail/setPassword/disable/delete
Auth config (per env, owner) allowed auth redirect origins (for app-hosted password-reset redirectTo); branded auth emails (sender name + body = your app, not manggaleh) — (dashboard Sign-up tab / API) mg env auth-origins · mg env email
Data API (CRUD) create / read / update / delete rows, typed; atomic $inc + $guard (race-safe writes) client.data.from<T>(c).insert/get/update/remove/list
Query filter (eq + operators), sort, cursor pagination, count, column projection, embed (belongs-to + one-to-many), aggregate list/page({…, embed }), aggregate({ groupBy, count, sum, avg, min, max })
Transactions atomic multi-op (all or nothing) + interactive read-then-write w/ row lock & serializable (in Functions) client.tx([ … ]) · ctx.db.transaction(async t => …, { isolation })
Storage upload / list / download / remove; owner/ABAC-scoped; on-the-fly image transforms + signed URLs client.storage.upload/list/download/getSignedUrl/remove
Functions server-side JS (trusted logic); invoke from app/server client.functions.invoke(name, input) mg functions push/list/delete
Scheduled jobs (cron) run a function on a schedule (admin mode) — (dashboard)
Realtime subscribe to insert/update/delete over WebSocket client.realtime.subscribe(c, handler)
Live data (optimistic) self-syncing store: optimistic insert/update/remove + rollback + realtime reconcile; useSyncExternalStore-ready client.data.from<T>(c).live(opts)
Webhooks (outbound) HMAC-signed POST to your URL when data changes — (dashboard)
Email transactional email (needs a service key); auth emails (OTP/reset) auto-branded per env as your app client.notifications.email.send / ctx.email.send mg env email (branding)
Projects & envs create projects, environments, clone/reset, production flag mg projects … / mg env …
Collections (schema) define tables/columns/relations, set RLS owner/ABAC — (dashboard) mg collections create --columns … --owner-column …
API keys mint publishable / service / function-scoped, revoke mg keys create/list/revoke
Types codegen generate TS interfaces from the live schema use the output with data.from<T>() mg types --out db.d.ts
RLS & ABAC per-owner or tag-based row security, enforced server-side automatic once configured --owner-column / --permission-column

Division of labour: the CLI is for the owner/admin (provision projects, schema, keys, push functions, gen types — see §2). The SDK is for the application you build (auth, data, storage, functions, realtime — see §3). End-users authenticate through the SDK, not the CLI.


1. Mental model (learn these 5 words)

Auto-managed system columns (don't define these, they're added for you): id (uuid), created_at, created_by, updated_at, updated_by.

Auth model for requests: a request usually carries the API key and (for end-user context) the signed-in user's Bearer token. A service key runs as admin (no user needed). RLS is keyed on the user, not the key.

⚠️ There is no “dev” vs “production” mode

manggaleh has no application-wide dev/prod switch (no NODE_ENV that changes behavior). Two things people mistake for one:

When this doc (or anyone) says “in dev,” read it as “when no real email provider is configured” — that’s the actual condition, not a mode.


2. Fast path: provision a backend from the CLI

npm install -g @manggaleh/cli

# 1. Account (or `mg login` if it exists). Session saved at ~/.manggaleh/config.json.
mg signup --url https://api.manggaleh.com --email you@acme.com
# (non-interactive: set MANGGALEH_EMAIL / MANGGALEH_PASSWORD, or pass --email/--password)

# 2. Project (also provisions a "dev" environment)
mg projects create --name "Acme" --slug acme

# 3. A collection. On a column type: "!" = NOT NULL, "^" = UNIQUE.
#    --owner-column turns on per-user RLS.
mg collections create --project acme --env dev --name todos \
  --columns "title:text!,code:text^,done:boolean,priority:integer" \
  --owner-column owner_id

# 4. API keys
mg keys create --project acme --env dev --type publishable            # mgpk_… (browser)
mg keys create --project acme --env dev --type service --name server   # mgsk_… (shown once)

# 5. Generate TS types for the SDK
mg types --project acme --env dev --out src/db.d.ts

Column types: text, integer, bigint, numeric, boolean, timestamptz, date, uuid, jsonb, plus reference (a FK — set its target collection). Identifiers are lowercase / digits / underscore (my_table); project slugs are kebab-case (my-app).

CLI reference

Account & projects
  mg signup --url <baseUrl> [--name <n>] [--email <e>] [--password <p>]
  mg login  --url <baseUrl> [--email <e>] [--password <p>]
  mg projects
  mg projects create --name <name> --slug <slug>
  mg projects delete --slug <slug> --yes        (hapus SEMUA env & data, permanen — owner)
  mg env list   --project <slug>
  mg env create --project <slug> --name <env> [--production]
  mg env clone  --project <slug> --from <env> --to <env>
        (default "promote": structure + functions/secrets/webhooks/jobs/signup, NO data/users)
        [--full] | pick: [--structure] [--data] [--end-users] [--functions]
                         [--secrets] [--webhooks] [--scheduled-jobs] [--signup-code]
  mg env delete --project <slug> --name <env> --yes
  mg env reset  --project <slug> --env <env> --yes        (empty ALL data; prod protected)
  mg env signup-code  --project <slug> --env <env> [--set <code> | --open]
  mg env auth-origins --project <slug> --env <env> [--set "https://app.example.com,…" | --clear]
  mg env email        --project <slug> --env <env>        (auth email brand/sender name)
        [--set-brand "<Your App>"] [--clear]              (default = project slug; sender address fixed)
  mg env usage  --project <slug> --env <env> [--since <ISO date>]

Schema & keys
  mg collections list   --project <slug> --env <env>
  mg collections create --project <slug> --env <env> --name <name>
        [--columns "title:text,done:boolean!,code:text^"]   (! = NOT NULL, ^ = UNIQUE)
        [--owner-column <col>] [--permission-column <col>]
  mg collections add-column --project <slug> --env <env> --name <c> --columns "note:text,qty:integer!"
  mg collections delete --project <slug> --env <env> --name <name> --yes
  mg keys list   --project <slug> --env <env>
  mg keys create --project <slug> --env <env> --type <publishable|service|function> [--name <n>]
        [--functions "fnA,fnB"]   (required for --type function)
  mg keys revoke --project <slug> --env <env> --id <id> --yes

End-users, webhooks, data & billing (full dashboard parity)
  mg users list|create|delete|permissions --project <slug> --env <env>
        create: --email <e> --password <p> --name <n>     delete: --id <id> --yes
        permissions: --id <id> --permissions "team:sales,tier:pro"   (ABAC tags)
  mg webhooks list|create|update|delete|test --project <slug> --env <env>
        create: --url <https://…> [--collections "a,b"] [--events "insert,update,delete"]
        update: --id <id> [--enable|--disable] [--url <u>] [--collections …] [--events …]
  mg data list|insert|update|delete --project <slug> --env <env> --collection <c>
        insert/update: --input '{"title":"x"}' (update/delete need --id; delete needs --yes)
        list: [--limit <n>] [--order "col.desc"]
  mg storage list|rm --project <slug> --env <env> [--id <id> --yes]   (upload/download: use the SDK)
  mg billing --project <slug> [--set-plan <free|pro|business>]

Functions & types
  mg functions list   --project <slug> --env <env>
  mg functions push   --project <slug> --env <env> --file <path> [--name <name>]
        [--allow-domain "api.stripe.com,api.tabby.ai"]   (egress allowlist for ctx.fetch)
        [--runtime vm|deno] [--timeout <ms>] [--memory <mb>]   (per-function config)
  mg functions invoke --project <slug> --env <env> --name <fn> [--input '{…}' | --file <f.json>]
  mg functions delete --project <slug> --env <env> --name <name> --yes
  mg types            --project <slug> --env <env> [--out <file.d.ts>]

Function secrets (injected as ctx.secrets; needs MASTER_ENCRYPTION_KEY on server)
  mg secrets list --project <slug> --env <env>
  mg secrets set  --project <slug> --env <env> --name STRIPE_KEY --value <value>
  mg secrets rm   --project <slug> --env <env> --name STRIPE_KEY --yes

Personal access tokens (owner-level, non-interactive auth for CLI/CI; revocable)
  mg tokens list
  mg tokens create --name <n> [--expires-days <N>]   (value shown ONCE)
  mg tokens rm     --id <id> --yes

Authenticating the CLI (interactive vs CI)

The CLI is owner / control-plane tooling (manages projects, schema, keys, functions) — it does not use tenant API keys (mgsk_/mgpk_; those are for the Data API at runtime). Two ways to authenticate:


2b. Environments: create, clone/promote, and keys

Who does this: managing environments is an owner / control-plane action — done in the dashboard, the CLI (mg env …), or the control-plane API with an owner PAT. The app SDK cannot and must not do it: the SDK talks to one environment's Data API with a tenant key (mgpk_/mgsk_) and has no project/environment/clone operations by design (an app holding a publishable key must never be able to create or wipe environments).

An environment (dev/staging/prod/custom) is an isolated schema — its own data, its own end-users, and its own API keys. Typical "set up a new environment" flow:

# 1. Create an empty environment (gets a fresh publishable key automatically)
mg env create --project acme --name staging          # add --production to flag it protected

# 2. Promote your structure + config into it WITHOUT dragging dev's test data/users.
#    Default preset = "promote": structure + functions + secrets + webhooks +
#    scheduled jobs + signup code; NO data, NO end-users → a clean target.
mg env clone --project acme --from dev --to staging

#    …or pick exactly what to copy:
mg env clone --project acme --from dev --to prod --structure --functions
mg env clone --project acme --from dev --to staging --full      # everything incl. data + users

# 3. Mint the keys THIS environment needs (see below — keys are NOT cloned).
mg keys create --project acme --env staging --type service --name server

Clone components (structure, data, endUsers, functions, secrets, webhooks, scheduledJobs, signupCode):

⚠️ API keys are per-environment and are NOT cloned. A service/publishable key created for dev only works for dev — using it against staging/prod returns 401. After creating/cloning an environment, mint new keys in that environment (dashboard API Keys tab, or mg keys create --env <env> --type service). This is intentional: a leaked dev key can't touch prod.

(Control-plane API equivalents, owner-authenticated: POST …/environments, POST …/environments/clone with {source,target,include}, POST …/environments/:env/keys.)


3. SDK (@manggaleh/sdk)

Framework-agnostic (browser or Node 18+; needs global fetch + WebSocket).

npm install @manggaleh/sdk

Create one client per project + environment

import { createClient } from "@manggaleh/sdk";

export const client = createClient({
  baseUrl: "https://api.manggaleh.com", // API origin (no trailing slash needed)
  tenant: "acme",                       // project slug
  env: "dev",                           // "dev" | "staging" | "prod" (default "prod")
  apiKey: "mgpk_xxx",                   // publishable for browser, service for server
  // storage: tokenStorage,             // persist the session across reloads (see Gotchas)
});

End-user auth

const { user } = await client.auth.signUp({ email, password, name, /* code? */ });
await client.auth.signIn({ email, password });
await client.auth.signOut();
const session = await client.auth.getSession(); // SessionResult | null

The session token is captured & attached automatically. Auth is per environment.

Passwordless sign-in (per environment) — via the SDK:

await client.auth.sendOtp(email);                          // emails a one-time code
const { user } = await client.auth.signInWithOtp({ email, otp });  // verify → signed in

Whether the code is actually emailed depends only on the server's EMAIL_PROVIDER (see “There is no dev/prod mode” below) — not on which environment you call:

(Raw endpoints, if not using the SDK: POST /auth/email-otp/send-verification-otp/auth/sign-in/email-otp.)

If the environment has a signup code, passwordless is gated like sign-up: OTP won't register new users (existing users still sign in) — new accounts must come through the gated /sign-up.

Email verification (on-demand OTP — no link). Sign-up does not auto-send a verification email, and verification is not required to sign in — a new user is signed in immediately, just with emailVerified=false. Verify on demand: when the user taps "verify email" in your app, send an email-verification OTP and confirm it with verifyEmail, which (unlike signInWithOtp) does not create an account or mint a session:

await client.auth.sendOtp(email, "email-verification"); // emails a code when the user asks
await client.auth.verifyEmail({ email, otp });          // sets emailVerified=true only

Use verifyEmail to verify and signInWithOtp only to actually log a user in passwordlessly — don't use signInWithOtp to "verify": for an unknown email it creates an orphan passwordless account and issues a session. Because nothing is auto-sent at sign-up, there's no double-send footgun — you send exactly one code, when the user asks.

Password reset (forgot password) — OTP. The standard reset flow: manggaleh emails the user a one-time code that they type back into your app; you set the new password in one call. App-side (publishable key, no service key), and it always resolves (200) regardless of whether the email exists — no account-enumeration:

await client.auth.sendPasswordResetOtp({ email });                    // emails a 6-digit code
await client.auth.resetPasswordWithOtp({ email, otp, newPassword });  // verify code + set the new password

Same UX as sign-in OTP — no email link to click, no reset page to host — so it works identically on web and mobile/native, with no redirect origins to allowlist. The code respects DEV_OTP_CODE in dev and expires in 5 minutes.

Change password (while signed in):

await client.auth.changePassword({ currentPassword, newPassword, revokeOtherSessions: true });

Auth email branding. All auth emails to end-users (sign-in codes, email verification, password reset) are sent from your app's brand, not manggaleh, using a professional template. There is one setting — brandName — used as the sender display name (email header) and in the body (e.g. "Sent by <brand>"). It defaults to the project slug, so it's already your project's name out of the box. Configure per environment (owner) in the dashboard (Sign-up tab → "Auth email branding"), via mg env email --set-brand "<Your App>", or PUT /api/projects/:slug/environments/:env/email { brandName }.

The From header becomes e.g. <Your App> <no-reply@updates.manggaleh.com> — your brand shows in the inbox while the sending address stays on manggaleh's verified domain (this address is fixed and can't be changed, which is what keeps deliverability working).

Admin user-management (service key only)

Server-side only — requires a service key (publishable/function → 403). Manages the environment's end-user directory (e.g. to clean up an orphan account or rotate a password):

const admin = createClient({ tenant, env, apiKey: SERVICE_KEY, baseUrl });
await admin.admin.users.list({ limit: 50 });
const u = await admin.admin.users.findByEmail("alice@cust.com");
await admin.admin.users.setPassword(u.id, "newPassword123");
await admin.admin.users.disable(u.id);   // revokes sessions (soft — not a persistent ban)
await admin.admin.users.delete(u.id);     // permanent; cascades sessions + credentials

disable revokes all sessions (immediate logout) but is not a persistent ban — the end-user schema has no banned column. To truly lock someone out, delete them or setPassword to a value they don't know.

Data API (CRUD)

interface Todo { id: string; title: string; done: boolean; created_at: string }
const todos = client.data.from<Todo>("todos");

const created = await todos.insert({ title: "Buy milk", done: false }); // returns full row
const one     = await todos.get(created.id);   // null (not error) when missing
const updated = await todos.update(created.id, { done: true });          // partial patch
await todos.remove(created.id);                                           // -> void
const rows    = await todos.list();            // one page (default 50, server max 200)

Atomic updates (counters, balances, stock) — avoid lost updates. A normal update is a blind set: if you read a value, change it in JS, and write it back, two concurrent requests can clobber each other (lost update). Instead use the atomic $inc operator (evaluated in the DB under the row lock) and the optional $guard (compare-and-set) to enforce an invariant:

await posts.update(id, { views: { $inc: 1 } });        // exact even under heavy concurrency
await posts.update(id, { score: { $inc: -2 } });       // negative delta = decrement

// Oversell-proof decrement: only applies while stock >= 1, else throws 409 and
// leaves the row UNCHANGED (distinct from 404 "not found / not yours").
await products.update(id, { stock: { $inc: -1 }, $guard: { stock: "gte.1" } });

$guard takes PostgREST-style predicates ({ col: "op.value" }, eq/neq/gt/gte/lt/lte/like/ilike/in/is). It also powers optimistic concurrency — guard on a version/updated_at column ($guard: { version: "eq.3" }) and bump it in the same patch. $inc requires a numeric column. Works the same inside client.tx([...]) / ctx.db.tx([...]).

Query: filter / sort / paginate / embed

const open = await todos.list({
  filters: { done: false, title: "ilike.%milk%", priority: "gte.3" }, // plain value = eq
  order: "created_at.desc",   // add ".desc" for descending
  limit: 50,                  // capped at 200
});

// Cursor pagination + optional total
const p1 = await todos.page({ limit: 20, count: true });   // { data, nextCursor, count }
if (p1.nextCursor) await todos.page({ limit: 20, cursor: p1.nextCursor });

// Projection + embed relations (nested via dotted paths)
await client.data.from("orders").list({
  select: ["id", "total", "customer_id"],   // id always included
  embed: ["customer(name,phone)"],           // belongs-to: FK value → related row
});

// belongs-to (object) + one-to-many (array) + nesting, in one request:
await client.data.from("properties").get(id, {
  embed: ["owner(name)", "photos(url)", "reviews.author"],
  // owner   → object (this property's FK)
  // photos  → array  (rows that FK back to this property)
  // reviews.author → array of reviews, each with its author object
});

// Aggregation — count/sum/avg/min/max + group by, computed in the DB (RLS-respected):
await client.data.from("reservations").aggregate({
  groupBy: "status",                     // omit for a single summary row
  count: true,                           // → count
  sum: ["amount"], avg: ["amount"],      // → sum_amount, avg_amount
  filters: { created_at: "gte.2026-01-01" },
});
// → [ { status: "confirmed", count: 320, sum_amount: 145000, avg_amount: 453.1 }, … ]

Filter operators: plain value = eq; gt/gte/lt/lte; like/ilike (ilike.%x%); in.a,b,c / nin.x,y; is.null / is.notnull. Filtering & sorting run on the server over the whole dataset.

Transactions (ACID — all or nothing)

const results = await client.tx([
  // Atomic + guarded: concurrent transfers can't lose an update or overdraw.
  { op: "update", collection: "accounts", id: a, patch: { balance: { $inc: -25 }, $guard: { balance: "gte.25" } } },
  { op: "update", collection: "accounts", id: b, patch: { balance: { $inc: 25 } } },
  { op: "insert", collection: "ledger",   values: { from: a, to: b, amount: 25 } },
  // ops: "insert" | "update" | "delete" | "get"
]);
// results[i].data (the row, for insert/update/get) or results[i].deleted (for delete)

Semantics & limits

A tx is a fixed list — you can't read a value mid-transaction and branch on it, and there's no upsert op. For simple conditional writes (counters, balances, stock, optimistic-concurrency version bumps), reach for atomic $inc + $guard above — they're race-safe without a read step, so they beat any read-then-write pattern under load. For richer read → decide → write logic, do it inside a Function (it runs server-side; read with ctx.db, then commit the writes with ctx.db.tx — all under the caller's RLS):

⚠️ Don't read a value, compute a new total in JS, and write it back as a literal (patch: { balance: prev - 25 }) — two concurrent requests will clobber each other (lost update). Use { balance: { $inc: -25 }, $guard: { balance: "gte.25" } } instead. Note a bare ctx.db.get + ctx.db.update are two separate transactions, so they have the same race — for a true locked read-then-write, use ctx.db.transaction(async t => …) with t.get(coll, id, { forUpdate: true }) (see §4).

// Function: confirm a payment atomically (read → decide → write in one unit)
module.exports = async (input, ctx) => {
  const order = await ctx.db.get("orders", input.orderId);
  if (!order || order.paid) throw new Error("not payable");
  await ctx.db.tx([
    { op: "update", collection: "orders",       id: order.id,        patch: { paid: true } },
    { op: "update", collection: "availability", id: order.slotId,    patch: { held: false } },
    { op: "insert", collection: "fee_breakdown", values: { order_id: order.id, fee: input.fee } },
  ]);
  return { ok: true };
};

Server-first tip: combine actAsUser + tx to run an atomic multi-table flow as a specific user with RLS enforced — no service-key bypass needed.

Storage (files)

const obj  = await client.storage.upload(file, { name: "receipt.pdf" }); // file: Blob/File
const list = await client.storage.list();
const blob = await client.storage.download(obj.id);
await client.storage.remove(obj.id);

// Images: transform on the fly (server-side). Great for thumbnails / LQIP blur.
const thumb = await client.storage.download(obj.id, { width: 400, fit: "cover", format: "webp" });
const lqip  = await client.storage.download(obj.id, { width: 24, blur: 8 });   // tiny blurred placeholder
// params: width, height, fit (cover|contain|fill|inside|outside), quality (1-100),
//         format (webp|jpeg|png|avif), blur. Non-images are returned unchanged.

// Signed URL — usable directly in <img src> with NO api-key/token, until it expires.
// (expiresIn seconds, default 1h, max 1y; image transform params allowed.)
const url = await client.storage.getSignedUrl(obj.id, { expiresIn: 3600, width: 400, format: "webp" });
// <img src={url} />  — works without the SDK (great for emails, CDNs, plain <img>).

Owner/ABAC-scoped by RLS — safe from the browser with a publishable key. (permissions tags on upload only apply with a service key.)

Functions (call server-side logic)

const { top } = await client.functions.invoke("topProducts", { n: 10 });

Act-as-user (server-first). A trusted server with a service key can run calls as a specific end-user so RLS still applies (instead of the admin bypass) — ideal for Next.js RSC / server actions. Pass actAsUser to the SDK (sends the x-act-as-user: <userId> header):

// per request, on your server:
const db = createClient({ baseUrl, tenant, env, apiKey: SERVICE_KEY, actAsUser: session.userId });
await db.data.from("orders").list();   // only this user's rows; inserts owned by them

Only service keys honor it (a publishable/function key can't impersonate). The user must exist.

Realtime

const unsub = client.realtime.subscribe("todos", async (e) => {
  // e = { type: "change", schema, collection, op, id }  — NO row data!
  if (e.op === "delete") { removeFromUI(e.id); return; }
  const row = await todos.get(String(e.id));   // refetch (also runs through RLS)
  if (row) upsertInUI(row);
});
// later: unsub();

Auto-reconnects. e.id may be a number → String(e.id) before get.

Live data + optimistic updates (.live())

Don't want to hand-write the subscribe→refetch→merge loop above? from<T>(c).live() returns an opt-in store that does it for you and applies mutations optimistically (instant UI, automatic rollback on failure). subscribe/getSnapshot match React's built-in useSyncExternalStore — no extra dependency; Zustand is an optional wrapper.

const todos = client.data.from<Todo>("todos").live({ order: "created_at.desc" });

// React:
const list = useSyncExternalStore(todos.subscribe, todos.getSnapshot);
useEffect(() => () => todos.close(), []);     // stop realtime on unmount

await todos.insert({ title });        // appears instantly → swapped for the server row
await todos.update(id, { done: true }); // toggles instantly → reverts if the server rejects
await todos.remove(id);               // disappears instantly → comes back on failure

live() is additive: it reuses list()/get()/realtime under the hood and changes nothing about the existing APIs. Also exported: createLiveCollection, LiveCollection, LiveOptions.

Email (server-side, service key required)

const admin = createClient({ baseUrl, tenant, env, apiKey: process.env.MG_SERVICE_KEY });
await admin.notifications.email.send({
  to: "customer@example.com", subject: "Receipt", html: "<p>Thanks!</p>",
});

Error handling

import { ManggalehError } from "@manggaleh/sdk";
try { await todos.insert({ title: "x" }); }
catch (err) {
  if (err instanceof ManggalehError) {
    // err.status: 401 (sign in), 403 (RLS / wrong key / scope), 404, 429 (quota), 400…
    // err.message, err.body
  } else throw err;
}

Note: get(id) on a missing row resolves to null; everything else throws.


4. Writing Functions (server-side JS, in the dashboard "Functions" tab)

// Function "topProducts"
module.exports = async (input, ctx) => {
  // ctx.db.list returns { data, nextCursor, count? } — note the .data
  const { data: orders } = await ctx.db.list("orders", { order: "total.desc", limit: input.n ?? 5 });
  ctx.log("rows", orders.length);          // appears in run logs (console.log works too)
  await ctx.email.send({ to: "ops@acme.com", subject: "Report", text: "..." });
  return { top: orders };                  // must be JSON-serializable
};

ctx: input (the invoke payload), db (list/get/insert/update/remove/tx/transaction, RLS-aware), email.send(...), fetch(url, init?) (outbound HTTP — allowlisted, below), secrets (your encrypted env secrets), request (raw HTTP request — for webhook receivers), log(...).

Interactive transactions — ctx.db.transaction. When you must read a row, decide, then write atomically (and a plain $inc/$guard isn't enough), open an interactive transaction. It holds one DB transaction across the callback, so t.get(coll, id, { forUpdate: true }) locks the row until you commit — concurrent invocations serialize on the lock instead of losing updates. The callback's return value is the result; throw to roll back (and it auto-rolls back if the function ends without committing). Inside: t.get/insert/update/remove (no nested tx).

// Safe money transfer: lock the source row, check funds, then move money.
module.exports = async (input, ctx) =>
  ctx.db.transaction(async (t) => {
    const from = await t.get("accounts", input.from, { forUpdate: true });
    if (!from || from.balance < input.amount) throw new Error("insufficient funds"); // → rollback
    await t.update("accounts", input.from, { balance: from.balance - input.amount });
    await t.update("accounts", input.to,   { balance: { $inc: input.amount } });
    return "ok";
  });

Multi-row invariants → { isolation: "serializable" }. A row lock (forUpdate) protects one row. When your rule spans different rows — "at least one admin must remain", "no double-booking a slot" — two transactions can each read the same rows, decide independently, and each write a different row, breaking the invariant (a write-skew). Pass { isolation: "serializable" } as the 2nd arg: Postgres detects the conflict and aborts one, and the runtime automatically retries the whole callback (up to 5×) so the loser re-reads the committed state and does the right thing.

module.exports = async (input, ctx) =>
  ctx.db.transaction(async (t) => {
    const a = await t.get("doctors", input.a);
    const b = await t.get("doctors", input.b);
    if ((a.on_call ? 1 : 0) + (b.on_call ? 1 : 0) <= 1) throw new Error("one must stay on call");
    await t.update("doctors", input.self, { on_call: false });
    return "ok";
  }, { isolation: "serializable" });   // ← also: "repeatable read"

⚠️ Because a serializable transaction may run more than once, keep external side effects (ctx.email, ctx.fetch, charging a card) outside the callback — do them after it commits. Only DB work belongs inside.

Each open transaction holds a pooled connection for the function's duration, so keep them short (the function timeout + server idle-transaction timeout are the backstops); max 4 open at once. For a fixed list of writes with no read step, use ctx.db.tx([...]) instead (lighter).

Caller identity — ctx.user. The invoking end-user is resolved server-side and exposed as ctx.user = { id, email } — no need to forward a token in the input or call /auth/get-session yourself. It is null when the caller is a service key (trusted server, not an end-user) or a non-HTTP invoke (cron). For act-as-user calls (x-act-as-user) it is { id, email: null }.

module.exports = async (input, ctx) => {
  if (!ctx.user) throw new Error("must be signed in");
  return ctx.db.list("orders", { filters: { user_id: ctx.user.id } });
};

Inbound webhooks — ctx.request. A function can receive a gateway's webhook: POST to its invoke URL (use a function-scoped key, e.g. …/functions/paymentWebhook?apikey=mgfk_…) and read ctx.request = { method, headers, rawBody, query }. rawBody is the exact bytes — verify the provider's HMAC signature over it (use crypto.subtle on the deno runtime). Your api-key / auth / cookie headers are redacted from ctx.request.headers (use ctx.user for caller identity). (Non-HTTP invokes like cron get ctx.request === null.)

Runtimes (set per function).

Outbound HTTP — ctx.fetch. Runs on the server with a per-function egress allowlist (the hosts a function may call). Calls to non-allowlisted hosts, or to private/loopback IPs, are rejected (SSRF-safe). Returns { status, ok, headers, text(), json() }. The global fetch() is blocked — always use ctx.fetch.

Secrets — ctx.secrets. Per-environment secrets (e.g. STRIPE_KEY), encrypted at rest, injected as a frozen object: ctx.secrets.STRIPE_KEY. Manage them in the Functions tab or with mg secrets set. (Requires MASTER_ENCRYPTION_KEY on the server.)

Imports (deno only). Use dynamic import()const { default: jsPDF } = await import("npm:jspdf"); static import statements aren't supported. Modules are cached server-side.

Per-function config: runtime (vm|deno), timeoutMs (default ~5s, clamped server-side), memoryMb, and the egress allowlist. Return value is JSON-serialized.

Functions can be scheduled (cron) — those runs are admin (RLS bypassed), so guard sensitive logic inside the function body, not just with RLS.

Example — charge a card via a gateway, then update data atomically:

// runtime: deno · egress allowlist: api.stripe.com · secret: STRIPE_KEY
module.exports = async (input, ctx) => {
  const res = await ctx.fetch("https://api.stripe.com/v1/charges", {
    method: "POST",
    headers: { authorization: "Bearer " + ctx.secrets.STRIPE_KEY },
    body: "amount=" + input.amount + "&currency=aed",
  });
  const charge = await res.json();
  await ctx.db.tx([
    { op: "update", collection: "orders", id: input.orderId, patch: { paid: true, charge_id: charge.id } },
  ]);
  return { ok: res.ok, chargeId: charge.id };
};

Example — receive & verify a gateway webhook (runtime: deno):

// secret: WEBHOOK_SECRET · gateway POSTs to …/functions/paymentWebhook?apikey=mgfk_…
module.exports = async (input, ctx) => {
  const enc = new TextEncoder();
  const key = await crypto.subtle.importKey(
    "raw", enc.encode(ctx.secrets.WEBHOOK_SECRET), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
  const mac = await crypto.subtle.sign("HMAC", key, enc.encode(ctx.request.rawBody));
  const hex = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, "0")).join("");
  if (hex !== ctx.request.headers["x-signature"]) throw new Error("bad signature");
  const event = JSON.parse(ctx.request.rawBody);
  await ctx.db.update("orders", event.orderId, { paid: true });
  return { ok: true };
};

5. Direct HTTP (no SDK — e.g. curl from an agent)

Base for tenant requests: {baseUrl}/api/t/{tenant}/{env}. Auth via header x-api-key: <key> or query ?apikey=<key>; end-user token via Authorization: Bearer <token>.

B=https://api.manggaleh.com/api/t/acme/dev

# sign in (returns a token; SDK manages this for you)
curl -s $B/auth/sign-in/email -H 'content-type: application/json' \
  -H 'x-api-key: mgpk_xxx' -d '{"email":"u@x.com","password":"secret"}'

# data
curl -s "$B/data/todos?done=false&order=created_at.desc&limit=20" -H 'x-api-key: mgpk_xxx' -H "authorization: Bearer $TOKEN"
curl -s -XPOST $B/data/todos -H 'content-type: application/json' -H 'x-api-key: mgsk_xxx' -d '{"title":"hi"}'
curl -s -XPATCH  $B/data/todos/$ID -H 'content-type: application/json' -H 'x-api-key: mgsk_xxx' -d '{"done":true}'
curl -s -XDELETE $B/data/todos/$ID -H 'x-api-key: mgsk_xxx'

# invoke a function (service or function-scoped key works without a user)
curl -s -XPOST "$B/functions/topProducts?apikey=mgsk_xxx" -H 'content-type: application/json' -d '{"n":10}'

Responses: data list = { data, nextCursor, count? }; get/insert/update = { data }; function = { result }. Realtime is a WebSocket at {wsBase}/api/t/{tenant}/{env}/realtime?collection=..&token=..&apikey=...


6. API keys & 3rd-party access

Type Prefix Use Power
publishable mgpk_ frontend / browser normal (RLS applies via the signed-in user)
service mgsk_ your own server full admin (bypasses RLS) — never ship to a browser
function mgfk_ give to a 3rd party / vendor scoped: only invokes its allowlisted functions; 403 on Data API / storage / email / other functions

To let an external system (payment gateway, vendor) call one of your functions, create a function-scoped key (mg keys create --type function --functions "paymentCallback") — never hand out a service key. The gateway POSTs its payload as the function input.


7. Gotchas (save yourself debugging)


8. Recipes (agent intent → exact steps)

"Stand up a backend for a new app" → use the CLI bootstrap in §2 (signup → projects createcollections createkeys createtypes). Hand the publishable key + tenant + env to the frontend; keep the service key on the server.

"Read/write data as the logged-in user" (frontend) →

const c = createClient({ baseUrl, tenant, env, apiKey: "mgpk_…" });
await c.auth.signIn({ email, password });
await c.data.from("todos").insert({ title: "x", done: false });  // RLS owner set automatically
const mine = await c.data.from("todos").list({ order: "created_at.desc" });

"Run trusted multi-step logic the client must not bypass" → write a Function (§4), then client.functions.invoke("placeOrder", { … }). Put the rules in the function; the client only sends intent.

"Let an external system (payment gateway / vendor) call one function" → mint a function-scoped key and give them the invoke URL — never a service key:

mg keys create --project acme --env prod --type function --functions "paymentCallback"
# they POST their payload to: {baseUrl}/api/t/acme/prod/functions/paymentCallback?apikey=mgfk_…

"Live-update the UI when data changes"realtime.subscribe(collection, e => …) then refetch the row by e.id (the event has no row data).

"Do an admin/server task (cron, migration, bulk import)" → server-side client with a service key (mgsk_…) → runs as admin, bypasses RLS.

"Send a receipt / notification email" → from a Function: ctx.email.send({ to, subject, html }), or server-side admin.notifications.email.send(...) with a service key.

"Give the SDK full type-safety"mg types --project … --env … --out src/db.d.ts, then client.data.from<Todo>("todos").

Decision shortcuts