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 | sign up / in / out, sessions, optional signup codes; per-environment | client.auth.signUp/signIn/signOut/getSession |
(app-side; CLI is owner-side) |
| Data API (CRUD) | create / read / update / delete rows, typed | client.data.from<T>(c).insert/get/update/remove/list |
β |
| Query | filter (eq + operators), sort, cursor pagination, count, column projection, embed FK | list/page({ filters, order, limit, cursor, select, count, embed }) |
β |
| Transactions | atomic multi-op (insert/update/delete/get) β all or nothing | client.tx([ β¦ ]) |
β |
| Storage | upload / list / download / remove files; owner/ABAC-scoped | client.storage.upload/list/download/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) | β |
| transactional email (needs a service key) | client.notifications.email.send / ctx.email.send |
β | |
| 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)
- Project β an isolated "database" (a better-auth organization). Its slug (e.g.
acme) is thetenantyou pass to the SDK. - Environment β
dev/staging/prodor custom (e.g.qa). Each is a fully isolated Postgres schema: separate data and separate end-user accounts. Flag one as production to protect it from delete/reset. - Collection β a table (with columns + relations). Defining one instantly exposes the REST Data API for it (list/get/insert/update/delete).
- API Key β every tenant request needs one:
publishable(mgpk_β¦) β safe in a browser/frontend.service(mgsk_β¦) β secret, full admin (bypasses RLS). Server-side only.function(mgfk_β¦) β scoped: may only invoke an allowlist of functions, nothing else. Safe to give to a 3rd party.
- RLS / ABAC β row-level security enforced on the server. A collection with an
owner column scopes rows per end-user; a permission column (
text[]) does tag-based (ABAC) access. The frontend physically cannot read rows it isn't allowed to.
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.
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 env list --project <slug>
mg env create --project <slug> --name <env> [--production]
mg env delete --project <slug> --name <env> --yes
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 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
Functions & types
mg functions list --project <slug> --env <env>
mg functions push --project <slug> --env <env> --file <path> [--name <name>]
mg functions delete --project <slug> --env <env> --name <name> --yes
mg types --project <slug> --env <env> [--out <file.d.ts>]
- Deletes require
--yes. Production environments/collections are protected server-side. - Config lives at
~/.manggaleh/config.json(override withMANGGALEH_CONFIG). mg functions pushupserts by name.mg typesemits oneinterfaceper collection plus aCollectionsmap.
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.
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)
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 one-level FK relations
await client.data.from("orders").list({
select: ["id", "total", "customer_id"], // id always included
embed: ["customer(name,phone)"], // FK value replaced by the related row
});
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([
{ op: "update", collection: "accounts", id: a, patch: { balance: 75 } },
{ op: "update", collection: "accounts", id: b, patch: { balance: 25 } },
{ op: "insert", collection: "ledger", values: { from: a, to: b, amount: 25 } },
// ops: "insert" | "update" | "delete" | "get"
]);
// results[i].data (row) or results[i].deleted (for delete)
A tx is a fixed list β you can't read-then-branch mid-transaction. For that, use a Function.
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);
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 });
- From the browser: publishable key + signed-in user β runs as that user (RLS applies).
- From a server: service key β admin (bypasses RLS).
- A function-scoped key (
mgfk_) may only invoke its allowlisted functions.
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, RLS-aware),
email.send(...), log(...).
Sandbox limits (important): no require/import, no fetch/network, no process/fs;
JS stdlib only. ~5s timeout. Return value is JSON-serialized. Cannot read request headers or
verify HMAC signatures inside a function.
Functions can be scheduled (cron) β those runs are admin (RLS bypassed), so guard sensitive logic inside the function body, not just with RLS.
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)
- Realtime carries no row data β only
{ op, id }. Refetch withdata.from(c).get(id), or letdata.from(c).live()handle the refetch + optimistic UI + rollback for you. - Session persistence: by default the token is in-memory (lost on reload). Pass a
storageadapter withget()/set()β notlocalStoragedirectly (it hasgetItem/setItem):storage: { get: () => localStorage.getItem("mg_token"), set: (t) => t ? localStorage.setItem("mg_token", t) : localStorage.removeItem("mg_token"), } - Auth is per-environment. A user in
devdoes not exist inprod. - Inside a function,
ctx.db.listreturns{ data, ... }, not a bare array (unlike the SDK client's.list()). embedis one level deep, and embedded rows respect RLS too.- Email needs a service key (publishable β 403). The
logdriver only writes to the server log (doesn't actually send). - Column/type discipline:
bigint/integerneed whole numbers;numericallows decimals;date=YYYY-MM-DD;timestamptz= ISO. Sending a mismatched value β 400. list()default 50, max 200. Usepage()+ cursor for feeds.- Identifiers: collections/columns/env names are lowercase + digits +
_; project slugs are kebab-case; function names may keep case (camelCase ok).
8. Recipes (agent intent β exact steps)
"Stand up a backend for a new app" β use the CLI bootstrap in Β§2 (signup β projects create β collections create β keys create β types). 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
- Simple CRUD with per-user isolation β collection + owner column + publishable key, no function needed.
- Logic across collections / secrets / must-not-bypass rules β Function.
- 3rd-party access β function-scoped key (never service).
- Your own backend/cron β service key.