Machine-translated draft — terminology + flow still being reviewed.

Mortar — AI cookbook

This doc is meant to be fed to AI coding assistants (Cursor / Claude Code / Cline / etc.) as part of their context, so they can generate working Mortar-using code without trial and error.

If you’re an AI: when generating code that uses Mortar, copy the patterns below. Don’t invent endpoints — if a pattern isn’t here, ask the user instead of guessing.

If you’re a human: paste this file into your AI tool’s “context” / “rules” / “knowledge base” — whatever it calls the always-on preamble. Refresh occasionally as Mortar evolves.


What Mortar is

Mortar is a Backend-as-a-Service, similar in shape to Supabase / Firebase but optimized for mainland China. Each developer signs up for a Mortar account, then creates one or more projects. Each project’s UUID doubles as its tenant_id — the data-isolation boundary key (project = the tenant).

One TypeScript package, two entry points:

  • @mortar-ai/client — for app code (mobile / web / server). Uses a project API key + tenant_id. The surface is supabase-js compatible: data / auth / storage calls resolve a { data, error } envelope and never throw — always destructure { data, error } and check error (do NOT use try/catch).
  • @mortar-ai/client/account — subpath export of the same package for control-plane code (CI, admin tools). Uses an account JWT instead.

There’s also a CLI, npx @mortar-ai/cli, that wraps the SDK for human + AI use. Every command supports --json for AI-friendly output.


Setup (every example assumes this prelude)

import { createClient } from '@mortar-ai/client';

const mortar = createClient({
  // The tenant lives in the host subdomain (Supabase's <ref>.supabase.co
  // model) — there is no separate `tenant` option.
  url: process.env.MORTAR_URL!,            // https://<tenant>.api.mortar.appunvs.com
  apiKey:    process.env.MORTAR_API_KEY!,     // mtr_live_...
});

CLI alternative — once mortar login + mortar use <project-id> are done:

mortar projects list --json

Pattern 1 — Sign up an end-user, save their session

// supabase-js shape: resolves `{ data: { user, session }, error }` — never throws.
const { data, error } = await mortar.auth.signUp({
  email:    'alice@example.com',
  password: 'hunter2-strong-please',
});
if (error) {
  // e.g. error.code '409' (email taken) / '400' (password too short)
  return showError(error.message);
}
// The session is persisted + auto-refreshed by the SDK, and attached to
// every subsequent call — you don't pass a token around by hand.
const { user, session } = data;

CLI version — there’s no mortar auth because end-user auth is a mobile/web app concern, not control plane. Use the HTTP API directly when scripting:

curl -X POST https://${MORTAR_TENANT}.api.mortar.appunvs.com/v1/auth/signup \
  -H "Authorization: Bearer ${MORTAR_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"email":"alice@example.com","password":"hunter2-strong-please"}'

Errors:

  • 409 email_taken — try sign-in instead
  • 400 password_too_short — Mortar requires ≥ 8 characters

Pattern 2 — Insert + read back a row

No tables.create from app code. Tables are schemaless JSONB collections, created implicitly on first insert — there is no DDL / migration step in app code. Non-default setup (access level, indexes, required columns) goes in a mortar.config.json manifest at the project root, which the platform applies with an admin key (see Mortar’s mortar.config.json docs). mortar.tables.create() is app/admin scope and 403s on the bundle’s public key — never call it here.

// 1. Insert — the `todos` table is auto-created on this first insert.
//    Chain `.select().single()` to get the created row back; resolves
//    `{ data, error }` (never throws). Rows come back FLAT: `row.title`,
//    `row.id` — there is no `row.data` wrapper.
const { data: row, error } = await mortar.from('todos')
  .insert({ title: 'buy milk', done: false })
  .select()
  .single();
if (error) return showError(error.message);
console.log(row.id, row.title);

// 2. List, newest first
const { data: recent, error: listErr } = await mortar.from('todos')
  .select('*')
  .order('created_at', { ascending: false })
  .limit(10);
if (listErr) return showError(listErr.message);

// 3. One row by id
const { data: one } = await mortar.from('todos').select('*').eq('id', row.id).single();

CLI:

mortar db rows insert todos --data='{"title":"buy milk","done":false}' --json
mortar db rows list todos --limit=10 --json

Pattern 3 — Subscribe to realtime changes

// supabase-js channel shape. `payload.eventType` is "INSERT" | "UPDATE" |
// "DELETE"; `payload.new` / `payload.old` carry the flat row.
const channel = mortar
  .channel('todos-room')
  .on('postgres_changes', { event: '*', table: 'todos' }, (payload) => {
    console.log(payload.eventType, payload.new);
  })
  .subscribe();

// Tear down on unmount / signout
mortar.removeChannel(channel);

Notes for AI:

  • Subscriptions are one-directional (server → client). Writes go through the normal from(...).insert/update/delete() calls.
  • Mortar’s SSE drops events on slow consumers (matches Postgres NOTIFY semantics). Treat realtime as “advisory” — refetch authoritative state on reconnect.

Pattern 4 — Upload + serve a file

// supabase-js storage shape — `mortar.storage.from(bucket).upload(path, body)`.
// Every async method resolves `{ data, error }` and never throws.
const key = `users/${userId}.jpg`;
const { data: up, error } = await mortar.storage.from('avatars').upload(key, file);
if (error) return showError(error.message);
console.log('uploaded', up.path);

// A signed URL for temporary access (TTL in seconds):
const { data: signed } = await mortar.storage.from('avatars').createSignedUrl(key, 3600);
// …or a stable public URL (for public buckets) — synchronous, like supabase-js:
const { data: pub } = mortar.storage.from('avatars').getPublicUrl(key);
console.log(signed?.signedUrl, pub.publicUrl);

CLI:

mortar storage upload avatars users/alice.jpg ./alice.jpg --content-type=image/jpeg
mortar storage download avatars users/alice.jpg ./out.jpg

Re-uploading the same (bucket, key) overwrites — same convention as S3 / OSS.


Pattern 5 — Check current credit balance (UI / scripts)

const u = await mortar.usage.me();
console.log(`tier: ${u.tier}`);
console.log(`balance: ¥${u.credit.balance_yuan} / ¥${u.credit.granted_yuan}`);
console.log(`storage cap: ${u.tier_caps.storage_gb} GB`);

CLI:

mortar usage me

Sample output:

project_id:     abc-123
billing_period: 2026-05
tier:           tiny
credit_mode:    hard_budget
credit:
  granted_yuan: 99
  used_yuan:    14.5
  balance_yuan: 84.5
  used_by_resource:
    network: 8.2
    compute: 3.1
    database_baseline: 3.2
tier_caps:
  db_ram_gb:                1
  db_disk_gb:               10
  storage_gb:               10
  network_gb_per_month:     25

Pattern 6 — Handle the structured 5xx errors

Mortar returns these high-signal status codes; your code should branch on them:

StatuserrorWhen
401unauthorizedBad / missing API key or end-user JWT
403scope_insufficientAPI key has the wrong scope (e.g. public-scope trying to write)
403tenant_mismatchAPI key is for a different project than the URL
409tier_change_would_overflowDowngrade target is smaller than current usage
429rate_limit_exceededSlow down; honor Retry-After
402credit_exhaustedcredit + top-up exhausted; mode-dependent path forward
503database_busyConnection pool full; retry after a beat

Note: per-tier capacity-cap enforcement (tier_capacity_exhausted, 503/507) is on the roadmap (Phase W7.c). Until shipped, exceeding a tier limit silently overruns and shows up in usage analytics only.

Data / auth / storage calls never throw — they resolve { data, error }, where error is the supabase-js error shape ({ message, code, details, hint }) and error.code carries the HTTP status as a string. Branch on error.code, don’t wrap in try/catch:

const { data, error } = await mortar.from('todos').insert({ title: 'x' }).select();
if (error) {
  if (error.code === '402') {       // credit_exhausted
    return promptTopUp();
  }
  if (error.code === '429') {       // rate_limit_exceeded — Retry-After in seconds
    return scheduleRetry();
  }
  return showError(error.message);
}

Pattern 7 — Programmatically create a project (CI / control plane)

import { createAccountClient } from '@mortar-ai/client/account';

const account = createAccountClient({ url: 'https://api.mortar.appunvs.com' });
await account.signIn({
  email:    process.env.MORTAR_ACCOUNT_EMAIL!,
  password: process.env.MORTAR_ACCOUNT_PASSWORD!,
});

const proj = await account.projects.create({
  name:        'staging',
  tier:        'mini',
  credit_mode: 'manual_top_up',
});

console.log('project id:', proj.id);
// Now use this id as the tenant_id + create an API key via the
// Mortar dashboard (or POST /v1/{tenant}/_admin/keys with an admin
// scope key created at signup time).

CLI:

mortar login --email me@example.com
mortar projects create --name=staging --tier=mini --credit-mode=manual_top_up --json
# the new project becomes the "current" one automatically;
# subsequent db/storage/usage commands use it

Pattern 8 — Tier upgrade with downgrade-overflow handling

Upgrading is always safe. Downgrading can fail if your usage already exceeds the new tier’s caps:

try {
  await account.projects.update(proj.id, { tier: 'tiny' });
} catch (e) {
  if (e instanceof MortarApiError && e.status === 409) {
    const body = e.body as { error: string; blockers: Array<{ resource: string; current: number; new_limit: number }> };
    if (body.error === 'tier_change_would_overflow') {
      console.log('Cannot downgrade — clean these up first:');
      for (const b of body.blockers) {
        console.log(`  ${b.resource}: ${b.current} > ${b.new_limit}`);
      }
      return;
    }
  }
  throw e;
}

Pattern 9 — Use end-user JWT to enforce per-user data isolation

After signInWithPassword / signUp succeeds, the SDK persists the session and attaches it automatically to every subsequent from() / storage call — you don’t pass a token around. Mortar logs the user ID so your future RLS policies can key off it; today it’s logged + auditable but doesn’t yet gate row access.

const { data, error } = await mortar.auth.signInWithPassword({ email, password });
if (error) return showError(error.message);

// Now scoped to data.user — the session rides along automatically:
const { data: myTodos } = await mortar.from('todos').select('*').eq('user_id', data.user.id);

Pattern 10 — Self-host configuration

Mortar’s binary is open source and runs anywhere. For self-host, your SDK URL just changes; everything else is identical:

// Pointing at a self-hosted Mortar (tenant in the host subdomain):
const mortar = createClient({
  url: 'https://project-uuid.mortar.my-company-internal.com',
  apiKey:    'mtr_live_...',
});

The Mortar binary supports any Postgres-compatible DB; see deployment for the compatibility matrix (AWS RDS / Aurora / Aliyun RDS / Tencent Cloud CDB / Google Cloud SQL / self-managed).


Things NOT to do (common mistakes AI tools make)

  1. Don’t invent endpoints. Mortar’s API surface is exactly what’s in mortar/internal/api/routes.go. If your code calls something not documented in openapi.yaml, it’ll 404.
  2. Don’t pass JWT in the Authorization header. That slot belongs to the project API key. End-user JWT goes in X-Mortar-User-Token — and the SDK does this for you: after mortar.auth.signInWithPassword(...) the session is stored and attached to every later call automatically.
  3. Don’t use Mortar for binary-large-data realtime. SSE streams text; bytes-heavy fanout (video, screen sharing) needs a different transport — out of Mortar’s scope.
  4. Don’t rely on undocumented response fields. Mortar’s stable contract is the typed responses in @mortar-ai/client. Fields not exposed via TypeScript types may change.
  5. Don’t share API keys across environments. Mortar issues mtr_live_... for prod and mtr_test_... for staging — keep them separate.

Where to ask for help