# 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(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(c).live(opts)` | — | | **Webhooks (outbound)** | HMAC-signed POST to your URL when data changes | — (dashboard) | — | | **Email** | 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()` | `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 the `tenant` you pass to the SDK. - **Environment** — `dev` / `staging` / `prod` or 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 ```bash 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 [--name ] [--email ] [--password

] mg login --url [--email ] [--password

] mg projects mg projects create --name --slug mg env list --project mg env create --project --name [--production] mg env delete --project --name --yes Schema & keys mg collections list --project --env mg collections create --project --env --name [--columns "title:text,done:boolean!,code:text^"] (! = NOT NULL, ^ = UNIQUE) [--owner-column ] [--permission-column ] mg collections delete --project --env --name --yes mg keys list --project --env mg keys create --project --env --type [--name ] [--functions "fnA,fnB"] (required for --type function) mg keys revoke --project --env --id --yes Functions & types mg functions list --project --env mg functions push --project --env --file [--name ] mg functions delete --project --env --name --yes mg types --project --env [--out ] ``` - Deletes require `--yes`. Production environments/collections are protected server-side. - Config lives at `~/.manggaleh/config.json` (override with `MANGGALEH_CONFIG`). - `mg functions push` upserts by name. `mg types` emits one `interface` per collection plus a `Collections` map. --- ## 3. SDK (`@manggaleh/sdk`) Framework-agnostic (browser or Node 18+; needs global `fetch` + `WebSocket`). ```bash npm install @manggaleh/sdk ``` ### Create one client per project + environment ```ts 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 ```ts 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) ```ts interface Todo { id: string; title: string; done: boolean; created_at: string } const todos = client.data.from("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 ```ts 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) ```ts 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) ```ts 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) ```ts 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 ```ts 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(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. ```ts const todos = client.data.from("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) ```ts const admin = createClient({ baseUrl, tenant, env, apiKey: process.env.MG_SERVICE_KEY }); await admin.notifications.email.send({ to: "customer@example.com", subject: "Receipt", html: "

Thanks!

", }); ``` ### Error handling ```ts 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) ```js // 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: ` **or** query `?apikey=`; end-user token via `Authorization: Bearer `. ```bash 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 with `data.from(c).get(id)`, or let `data.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 `storage` adapter with `get()`/`set()` — **not** `localStorage` directly (it has `getItem`/`setItem`): ```ts 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 `dev` does not exist in `prod`. - **Inside a function, `ctx.db.list` returns `{ data, ... }`**, not a bare array (unlike the SDK client's `.list()`). - **`embed` is one level deep**, and embedded rows respect RLS too. - **Email needs a service key** (publishable → 403). The `log` driver only writes to the server log (doesn't actually send). - **Column/type discipline**: `bigint`/`integer` need whole numbers; `numeric` allows decimals; `date` = `YYYY-MM-DD`; `timestamptz` = ISO. Sending a mismatched value → 400. - **`list()` default 50, max 200.** Use `page()` + 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) → ```ts 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: ```bash 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("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**.