Amba

Collections

Manage collection schemas — create a typed table, list and describe collections, alter columns and indexes, drop a collection, and read migration history.

Collections are schema-first, relational tables in your project's own managed Postgres database. These admin routes manage the schema — create a collection, add or drop columns and indexes, and inspect migration history. End-user row reads and writes (with automatic per-user scoping) are covered in the Collections SDK guide.

Schema changes are applied asynchronously through a managed migration; the create / alter / delete routes wait for the result and return the migration version and status.

Paths below are shown relative to the API version base. The canonical external prefix is https://api.amba.dev/v1, so /admin/projects/:projectId/... below means /v1/admin/projects/:projectId/... on the wire.

Endpoints

MethodPathDescription
POST/admin/projects/:projectId/collectionsCreate a collection.
GET/admin/projects/:projectId/collectionsList collections (paginated).
GET/admin/projects/:projectId/collections/:nameDescribe one (columns, indexes, latest migration).
PATCH/admin/projects/:projectId/collections/:nameAlter (add column / add index / drop column / relax user_id).
DELETE/admin/projects/:projectId/collections/:name?confirm=:nameDrop a collection.
DELETE/admin/projects/:projectId/collections/:name/dataReset: empty all rows but keep the collection (schema stays).
GET/admin/projects/:projectId/collections/:name/migrationsMigration history.
POST/admin/projects/:projectId/collections/transactionAtomic multi-collection write transaction.
POST/admin/projects/:projectId/collections/:name/rowsInsert one row (admin — bypasses user-scope).
POST/admin/projects/:projectId/collections/:name/rows:batchBulk-insert up to 500 rows. Canonical seeding path.
GET/admin/projects/:projectId/collections/:name/rowsFind rows (query DSL). Excludes soft-deleted by default.
GET/admin/projects/:projectId/collections/:name/rows/countCount rows. Excludes soft-deleted by default.
GET/admin/projects/:projectId/collections/:name/rows/:idFetch one row by id.
POST/admin/projects/:projectId/collections/:name/rows/aggregateGroup-by aggregation (count/sum/avg/min/max).
PATCH/admin/projects/:projectId/collections/:name/rows/:idUpdate one row by id.
PATCH/admin/projects/:projectId/collections/:name/rowsBulk update rows matching where.
DELETE/admin/projects/:projectId/collections/:name/rows/:idSoft-delete one row by id (?hard=true hard-deletes).
DELETE/admin/projects/:projectId/collections/:name/rowsBulk delete rows matching where with ?confirm=<count>.

Collection names must match ^[a-z][a-z0-9_]*$. Reserved prefixes (amba_, coll_amba_) are rejected with 400 RESERVED_NAME.

POST /admin/projects/:projectId/collections

Create a collection. You supply the columns; Amba adds id, user_id, created_at, updated_at, and deleted_at automatically, plus a primary key and a user_id index.

Request (CollectionSchema)

FieldTypeRequiredDescription
namestringyes^[a-z][a-z0-9_]*$.
columnsarrayyesColumn definitions (see below).
indexesarraynoExtra indexes beyond the defaults.
sharedbooleannotrue makes user_id nullable so rows aren't owned by any one user. See below.
owner_scope"user" | "group"noOwnership model. user (default) = each row owned by its user_id. group = rows owned by a group_id (team); see group ownership.
read_policy"owner" | "public" | "member"noWho can read rows. owner (default) = your own rows; public = every signed-in user; member = any member of the owning group (member:<role> adds a role floor; requires owner_scope: "group").
write_policy"owner" | "authenticated" | "member"noWho can write rows. owner (default); authenticated = any signed-in user; member / member:admin = group members at that role floor (requires owner_scope: "group").

Each column is { name, type, nullable?, references?, dimension? }. type is one of text, integer, bigint, numeric, boolean, timestamptz, date, uuid, jsonb, vector, or a native array type: text[], integer[], bigint[], numeric[], boolean[], uuid[]. vector columns require a dimension (1–4096).

Array columns hold a flat, uniformly-typed list (tags, tropes, category slugs, related ids) and unlock the contains / containedBy / overlaps query operators. Prefer an array column over jsonb for any flat list you filter on; keep jsonb for nested documents and mixed-shape objects.

Shared (global) collections

By default every row carries a non-null user_id — collections are per-user. For developer-seeded GLOBAL content that no single user owns (question banks, content libraries, lookup tables, reference data), create the collection with "shared": true. That drops the user_id NOT NULL constraint so you can insert rows without a user_id.

Insert into a per-user collection without a user_id (e.g. seeding from a function or the admin API) returns 400 NOT_NULL_VIOLATION. The fix is either "shared": true at create time, or "relax_user_id": true on an existing collection (see PATCH below). The end-user client SDK can never set user_id — the server injects it from the session — so shared/global data must be seeded through the admin API or MCP tools.

Access policies (public catalogs)

By default a collection is owner-scoped: client reads and writes are auto-scoped to the signed-in user's own rows (plus any shared null-owner rows on a shared collection). Two optional policies relax that per collection:

  • read_policy: "public" — every signed-in user can read all rows. This is how you build a public catalog (a shared product list, a global leaderboard table, community posts) that everyone reads but only the owner edits.
  • write_policy: "authenticated" — any signed-in user can write/update rows, not just the owner.

Policies are independent — a common pattern is read_policy: "public" with the default write_policy: "owner" (everyone reads, each user only edits their own). Omitting both keeps the historical owner/owner behavior. Set or change a policy at create time, or later via set_policy on PATCH (below);

Group-owned collections (teams)

For data owned by a team rather than one user, create the collection with owner_scope: "group" plus a member policy. Rows are owned by a group_id (a group); access is resolved from the caller's membership in that group, gated by role rank (owner > admin > member > viewer):

{
  "name": "team_docs",
  "columns": [{ "name": "title", "type": "text" }],
  "owner_scope": "group",
  "read_policy": "member",
  "write_policy": "member:admin"
}

The collection carries group_id as its owner column (and a nullable user_id for the creator). Clients act within a group via X-Group-Context: <groupId> (or amba.asUser(uid).asGroup(gid) in the Node SDK), verified server-side (403 NOT_A_GROUP_MEMBER if the caller isn't a member). Inserts require a group context (400 GROUP_CONTEXT_REQUIRED) at the write role floor (403 GROUP_ROLE_FORBIDDEN), and the owning group must be private (400 GROUP_NOT_PRIVATE for an open-join group). owner_scope is fixed at create time; set_policy can change the member role floors but not the scope. See team-owned collections for the full guide.

a bare re-create that omits the policy fields never clobbers a policy you set earlier.

Each index is { columns, name?, unique?, using? }, where using is one of btree (default), hash, gin, gist, brin, hnsw, ivfflat.

curl -X POST 'https://api.amba.dev/v1/admin/projects/$PROJECT_ID/collections' \
  -H 'Authorization: Bearer $AMBA_PAT' \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "notes",
    "columns": [
      { "name": "title", "type": "text" },
      { "name": "body", "type": "text", "nullable": true },
      { "name": "pinned", "type": "boolean", "nullable": true },
      { "name": "tags", "type": "text[]", "nullable": true }
    ],
    "indexes": [{ "columns": ["title"] }]
  }'

Response 201

{
  "data": {
    "name": "notes",
    "version": 1,
    "workflow_id": "…",
    "run_id": "…",
    "status": "applied"
  }
}

A DDL error returns 400 with error.code COLLECTION_MIGRATION_FAILED, INVALID_COLLECTION_SCHEMA, or INVALID_IDENTIFIER.

Try it:

POST/admin/projects/%7B%7BprojectId%7D%7D/collections
developer auth
curl -X POST 'https://api.amba.dev/v1/admin/projects/%7B%7BprojectId%7D%7D/collections'
Loading auth… Configure auth in the settings drawer (top-right) to run this request.

GET /admin/projects/:projectId/collections

List collections. Offset/limit pagination — limit defaults to 50 (max 200), offset defaults to 0.

Response 200

{
  "data": [{ "name": "notes", "created_at": "2026-05-27T10:00:00.000Z" }],
  "pagination": { "limit": 50, "offset": 0, "total": 1 }
}

Try it:

GET/admin/projects/%7B%7BprojectId%7D%7D/collections
developer auth
curl -X GET 'https://api.amba.dev/v1/admin/projects/%7B%7BprojectId%7D%7D/collections'
Loading auth… Configure auth in the settings drawer (top-right) to run this request.

GET /admin/projects/:projectId/collections/:name

Describe a collection: its live columns and indexes (read from the database catalog) plus the most recent migration record.

Response 200

{
  "data": {
    "name": "notes",
    "columns": [
      { "column_name": "id", "data_type": "uuid", "is_nullable": "NO", "column_default": "…" },
      { "column_name": "title", "data_type": "text", "is_nullable": "NO", "column_default": null }
    ],
    "indexes": [{ "indexname": "notes_pkey", "indexdef": "CREATE UNIQUE INDEX …" }],
    "latest_migration": { "version": 1, "status": "applied", "applied_at": "…", "error": null }
  }
}

404 NOT_FOUND if the collection doesn't exist.

Try it:

GET/admin/projects/%7B%7BprojectId%7D%7D/collections/%7B%7BcollectionName%7D%7D
developer auth
curl -X GET 'https://api.amba.dev/v1/admin/projects/%7B%7BprojectId%7D%7D/collections/%7B%7BcollectionName%7D%7D'
Loading auth… Configure auth in the settings drawer (top-right) to run this request.

PATCH /admin/projects/:projectId/collections/:name

Alter a collection. Exactly one operation per call: add_column, add_index, change_type, rename_column, drop_column, or relax_user_id. Chain multiple PATCHes for multiple changes.

FieldTypeDescription
add_columnobjectA column definition (same shape as create).
add_indexobjectAn index definition.
change_typeobjectChange an existing column's type. { column, to_type, from_type?, using? }. See cast safety below.
rename_columnobjectRename a column. { from, to }. Metadata-only and fast, but breaks code referencing the old name.
drop_columnstringColumn name to drop. Destructive — requires ?confirm=<column>.
relax_user_idbooleanDrop the user_id NOT NULL so the collection can hold shared/global rows. Post-hoc equivalent of shared.
set_policyobjectChange the access policy: { read_policy?, write_policy? }. See Access policies.
curl -X PATCH 'https://api.amba.dev/v1/admin/projects/$PROJECT_ID/collections/notes' \
  -H 'Authorization: Bearer $AMBA_PAT' \
  -H 'Content-Type: application/json' \
  -d '{ "add_column": { "name": "color", "type": "text", "nullable": true } }'

change_type

Migrates a column to a new type in place — no drop-and-re-add, so existing data is preserved.

# Safe cast (any type → text) — no `using` needed.
curl -X PATCH '…/collections/notes' -H 'Authorization: Bearer $AMBA_PAT' \
  -d '{ "change_type": { "column": "priority", "to_type": "text" } }'
 
# Non-trivial cast — supply a `using` expression.
curl -X PATCH '…/collections/notes' -H 'Authorization: Bearer $AMBA_PAT' \
  -d '{ "change_type": { "column": "score", "to_type": "integer", "using": "score::integer" } }'

change_type rewrites the entire table and holds an exclusive lock for the duration, so the collection is briefly unreadable and unwritable while it runs. On large collections, schedule it during a low-traffic window.

Cast safety. Only a subset of type pairs convert automatically. A using cast expression (e.g. "score::integer") is required for any cast that isn't trivially safe (such as text → integer); the request is rejected up front with 400 INVALID_COLLECTION_SCHEMA rather than failing mid-migration. Casts that are always safe don't need using: any type → text, and (when you pass from_type) the widening casts integer → bigint, integer → numeric, and bigint → numeric. If a using cast still fails on real row data at apply time, the call returns 400 COLLECTION_MIGRATION_FAILED and the collection is left unchanged.

rename_column

curl -X PATCH '…/collections/notes' -H 'Authorization: Bearer $AMBA_PAT' \
  -d '{ "rename_column": { "from": "body", "to": "content" } }'

A rename is a fast metadata-only change — no table rewrite. It breaks any app code that still references the old column name, so re-run amba types generate and update your call sites afterward. The new name can't collide with a server-managed column (id, user_id, created_at, updated_at, deleted_at) or a reserved query keyword (and, or, not).

Response 200

{
  "data": { "name": "notes", "version": 2, "workflow_id": "…", "run_id": "…", "status": "applied" }
}

400 INVALID_PATCH if not exactly one operation; 400 CONFIRMATION_REQUIRED if drop_column is set without a matching ?confirm=.

Try it:

PATCH/admin/projects/%7B%7BprojectId%7D%7D/collections/%7B%7BcollectionName%7D%7D
developer auth
curl -X PATCH 'https://api.amba.dev/v1/admin/projects/%7B%7BprojectId%7D%7D/collections/%7B%7BcollectionName%7D%7D'
Loading auth… Configure auth in the settings drawer (top-right) to run this request.

DELETE /admin/projects/:projectId/collections/:name

Drop a collection. Destructive — requires ?confirm=<name>.

curl -X DELETE 'https://api.amba.dev/v1/admin/projects/$PROJECT_ID/collections/notes?confirm=notes' \
  -H 'Authorization: Bearer $AMBA_PAT'
{ "data": { "name": "notes", "deleted": true, "version": 3 } }

Try it:

DELETE/admin/projects/%7B%7BprojectId%7D%7D/collections/%7B%7BcollectionName%7D%7D
developer auth
curl -X DELETE 'https://api.amba.dev/v1/admin/projects/%7B%7BprojectId%7D%7D/collections/%7B%7BcollectionName%7D%7D?confirm=%7B%7BcollectionName%7D%7D'
Loading auth… Configure auth in the settings drawer (top-right) to run this request.

DELETE /admin/projects/:projectId/collections/:name/data

Reset a collection's data — empty every row but keep the collection, its schema, indexes, and config. The collection-scoped analogue of a per-user reset; ideal for wiping a fixture table between test runs without rebuilding it. To delete the collection itself, use the DELETE …/collections/:name route above.

hard by default — the rows are gone, not tombstoned. Pass ?hard=false to soft-delete instead (stamps deleted_at on currently-live rows so they stay recoverable). No ?confirm= is required: the path already names the exact collection and this is a Bearer-authed admin-only surface.

curl -X DELETE 'https://api.amba.dev/v1/admin/projects/$PROJECT_ID/collections/fixtures/data' \
  -H 'Authorization: Bearer $AMBA_PAT'

Response 200

{ "data": { "collection": "fixtures", "deleted": 128, "mode": "hard" } }

Errors

  • 404 COLLECTION_NOT_FOUND — the collection doesn't exist.
  • 500 RESET_FAILED.

Try it:

DELETE/admin/projects/%7B%7BprojectId%7D%7D/collections/%7B%7BcollectionName%7D%7D/data
developer auth
curl -X DELETE 'https://api.amba.dev/v1/admin/projects/%7B%7BprojectId%7D%7D/collections/%7B%7BcollectionName%7D%7D/data'
Loading auth… Configure auth in the settings drawer (top-right) to run this request.

GET /admin/projects/:projectId/collections/:name/migrations

Migration history for the project, newest first (limit default 50, max 500). Each row carries the version, status, applied_at, sql_text, and error.

Response 200

{
  "data": [
    {
      "version": 3,
      "status": "applied",
      "applied_at": "…",
      "sql_text": "DROP TABLE …",
      "error": null
    }
  ]
}

Try it:

GET/admin/projects/%7B%7BprojectId%7D%7D/collections/%7B%7BcollectionName%7D%7D/migrations
developer auth
curl -X GET 'https://api.amba.dev/v1/admin/projects/%7B%7BprojectId%7D%7D/collections/%7B%7BcollectionName%7D%7D/migrations'
Loading auth… Configure auth in the settings drawer (top-right) to run this request.

Rows (admin)

These routes read and write rows as the developer/agent. Unlike the end-user client SDK, they bypass per-user row scoping: an admin insert can attribute a row to any user_id (pass it in the body) or leave it NULL on a shared collection.

POST /admin/projects/:projectId/collections/:name/rows

Insert one row. The body is a flat column → value map (no wrapper). Server-managed columns (id, created_at, updated_at, deleted_at) are stripped; user_id is honored if present. Two control fields are recognised on the body and are not treated as columns: as_system and idempotency_key.

curl -X POST 'https://api.amba.dev/v1/admin/projects/$PROJECT_ID/collections/notes/rows' \
  -H 'Authorization: Bearer $AMBA_PAT' \
  -H 'Content-Type: application/json' \
  -d '{ "title": "Welcome", "body": "First note", "pinned": true }'

Returns 201 { "data": { ...row } }.

Shared (system) rows (as_system)

Pass "as_system": true to write a global, null-owner row (user_id = NULL) — the developer-seeded-content shape. It's mutually exclusive with an explicit user_id (sending both is 400). The collection must allow a null owner: create it with "shared": true or PATCH "relax_user_id": true, otherwise an as_system insert returns 409 NOT_SHARED_COLLECTION.

curl -X POST '…/collections/announcements/rows' \
  -H 'Authorization: Bearer $AMBA_PAT' -H 'Content-Type: application/json' \
  -d '{ "as_system": true, "title": "Maintenance window Sunday" }'

Idempotency (Idempotency-Key)

To make a retried or double-submitted insert safe, supply an idempotency key — either as the Idempotency-Key request header or as an idempotency_key field on the body (if you send both, they must match). The first insert with a given key creates the row (201); any replay with the same key returns the original row unchanged (200) instead of a duplicate. The key is scoped to (collection, key). If the original row was since soft-deleted, the replay resolves to data: null.

curl -X POST '…/collections/orders/rows' \
  -H 'Authorization: Bearer $AMBA_PAT' \
  -H 'Idempotency-Key: order-2026-0042' \
  -H 'Content-Type: application/json' \
  -d '{ "sku": "WIDGET", "qty": 2 }'

Return shape (return=minimal)

By default the insert echoes the full row (RETURNING *). For agent-driven bulk migrations — rows with large JSONB columns (word timings, embeddings, chunk blobs) — that echo can exhaust an agent's token budget after a couple of inserts. Pass ?return=minimal (or the idiomatic header Prefer: return=minimal) to trim the response to the row id only. The row is still inserted in full; only the response is trimmed.

# Default — full row
curl -X POST '…/collections/chapters/rows' \
  -H 'Authorization: Bearer $AMBA_PAT' -H 'Content-Type: application/json' \
  -d '{ "title": "Chapter 1", "timings": { /* 100KB of word timings */ } }'
# → 201 { "data": { "id": "…", "title": "Chapter 1", "timings": { …all 100KB echoed… }, … } }
 
# Minimal — id only
curl -X POST '…/collections/chapters/rows?return=minimal' \
  -H 'Authorization: Bearer $AMBA_PAT' -H 'Content-Type: application/json' \
  -d '{ "title": "Chapter 1", "timings": { /* 100KB of word timings */ } }'
# → 201 { "data": { "id": "…" } }

The same option applies to the batch endpoint below, where it matters most — a 500-row batch echoes rows: [{ "id": "…" }, …] instead of megabytes of inserted rows.

A required column left unset returns 400 NOT_NULL_VIOLATION — most often user_id on a per-user collection; create the collection with "shared": true (or PATCH "relax_user_id": true) if the rows are global/shared. 400 INVALID_COLUMN for an unknown column; 404 COLLECTION_NOT_FOUND if the collection doesn't exist. A row that collides with a unique index returns 409 CONFLICT (not an opaque 500) — pass on_conflict / conflict_target query params to turn the insert into an upsert instead.

Upsert (on_conflict / conflict_target)

The single-row insert accepts the same upsert query params as the client route: ?on_conflict=error|ignore|update (default error) plus ?conflict_target=col1,col2 naming the unique-index columns. ignore returns the existing row, update merges the supplied columns; both require conflict_target, and a target not covered by a unique index (or absent from the row) returns 400 INVALID_CONFLICT_TARGET. See the client collections reference for the full status-code matrix (admin inserts aren't user-scoped, so the "owned by another user" 409 case doesn't apply).

POST /admin/projects/:projectId/collections/:name/rows:batch

Bulk-insert up to 500 rows in a single atomic statement — the canonical path for seeding and migrations (question banks, content libraries, reference data). Far faster than calling the single-row insert in a loop.

FieldTypeRequiredDescription
rowsarrayyes1–500 column → value maps. Rows may be sparse — a column absent from a row is inserted NULL.
on_conflictstringno"error" (default) aborts the whole batch on any conflict; "skip" ignores conflicting rows.
curl -X POST 'https://api.amba.dev/v1/admin/projects/$PROJECT_ID/collections/questions/rows:batch' \
  -H 'Authorization: Bearer $AMBA_PAT' \
  -H 'Content-Type: application/json' \
  -d '{
    "rows": [
      { "prompt": "Capital of France?", "answer": "Paris" },
      { "prompt": "Capital of Japan?", "answer": "Tokyo" }
    ],
    "on_conflict": "skip"
  }'

Returns 201 { "data": { "inserted": <n>, "rows": [...] } }; with "on_conflict": "skip" the response also carries "skipped": <n>. Pass ?return=minimal (or Prefer: return=minimal) to trim rows to [{ "id": "…" }, …] — essential when seeding rows with large JSONB columns so the response doesn't exhaust an agent's token budget. The rows are still inserted in full. Errors: 400 BATCH_TOO_LARGE (more than 500 rows — split into multiple calls), 400 NOT_NULL_VIOLATION / INVALID_COLUMN, 404 COLLECTION_NOT_FOUND, 409 CONFLICT (when on_conflict is "error").

GET /admin/projects/:projectId/collections/:name/rows

Find rows with the full query DSL — pass the [FindQuery] as a JSON body or via ?query=<json> (and the friendly ?where= / ?limit= / ?order= shortcuts). Unlike the client route, admin reads are not user-scoped, so you see every user's rows.

Behavior change. includeDeleted now defaults to false on the admin row reads — soft-deleted rows are excluded by default. (It previously defaulted to true.) Pass "includeDeleted": true in the body (or ?includeDeleted=true) to include soft-deleted rows.

# Default — excludes soft-deleted.
curl 'https://api.amba.dev/v1/admin/projects/$PROJECT_ID/collections/notes/rows?limit=50' \
  -H 'Authorization: Bearer $AMBA_PAT'
 
# Include soft-deleted rows.
curl 'https://api.amba.dev/v1/admin/projects/$PROJECT_ID/collections/notes/rows?includeDeleted=true' \
  -H 'Authorization: Bearer $AMBA_PAT'

Returns 200 { "data": [...], "next_cursor": ... }.

where syntax

where is a column-keyed JSON object, not the Node SDK's { column, op, value } filter shape. On GET routes, URL-encode it in the query string (?where=<json>), or put it inside the full ?query=<FindQuery> object. Bulk write routes take the same object directly in the JSON body.

{ "status": "queued" }
{ "status": { "in": ["queued", "running"] } }
{ "status": { "notIn": ["done", "failed"] } }
{ "attempt": { "gte": 3 } }
{ "deleted_at": { "isNull": true } }
{ "name": { "ilike": "%dream%" } }
{ "and": [{ "status": "queued" }, { "attempt": { "lt": 3 } }] }

Supported field operators: eq, ne, gt, gte, lt, lte, in, notIn, like, ilike, isNull, isNotNull, contains, containedBy, and overlaps. Operators are not $-prefixed. The null checks must be literal true ({ "deleted_at": { "isNull": true } }).

If you are translating from the Node SDK filter shape, map not_innotIn, is_nullisNull, is_not_nullisNotNull, and contained_bycontainedBy.

GET /admin/projects/:projectId/collections/:name/rows/count

Exact row count. Honors where + includeDeleted only (pagination / projection params are rejected). Same default as the find route — includeDeleted defaults to false, so soft-deleted rows are not counted unless you pass ?includeDeleted=true.

curl 'https://api.amba.dev/v1/admin/projects/$PROJECT_ID/collections/notes/rows/count?where={"pinned":true}' \
  -H 'Authorization: Bearer $AMBA_PAT'

Returns 200 { "data": { "count": <n> } }.

GET /admin/projects/:projectId/collections/:name/rows/:id

Fetch one row by id.

curl 'https://api.amba.dev/v1/admin/projects/$PROJECT_ID/collections/notes/rows/$ROW_ID' \
  -H 'Authorization: Bearer $AMBA_PAT'

Returns 200 { "data": { ...row } }, 404 NOT_FOUND if no row exists, or 400 INVALID_UUID for a malformed id.

POST /admin/projects/:projectId/collections/:name/rows/aggregate

Group-by aggregation across all rows (no user-scope). Run a count / sum / avg / min / max server-side instead of paging through rows.

FieldTypeRequiredDescription
selectarrayyesOne or more { fn, column?, as? }. fncount/sum/avg/min/max.
group_bystring | string[]noColumn(s) to group by. Omit for a whole-collection aggregate.
whereobjectnoFilter the ROWS before aggregating.
havingarrayno{ fn, column?, op, value } conditions on the GROUPED result. opeq/ne/gt/gte/lt/lte.
order_bystring | arraynoOrder groups by a group_by column or select alias — "revenue" or { by, direction }.
limitnumbernoCap the number of returned groups (max 10000).
includeDeletedbooleannoInclude soft-deleted rows (default false; include_deleted also accepted).

count with no column is COUNT(*); pass a column for COUNT(col). sum/avg/min/max require a column. as sets the output alias. having rebuilds its aggregate from fn/column, so it can filter on an aggregate the select doesn't return. Like the row reads above, soft-deleted rows are excluded by default — pass "includeDeleted": true to include them.

curl -X POST 'https://api.amba.dev/v1/admin/projects/$PROJECT_ID/collections/orders/rows/aggregate' \
  -H 'Authorization: Bearer $AMBA_PAT' \
  -H 'Content-Type: application/json' \
  -d '{
    "select": [{ "fn": "count", "as": "total" }, { "fn": "sum", "column": "amount", "as": "revenue" }],
    "group_by": "status",
    "having": [{ "fn": "count", "op": "gte", "value": 3 }],
    "order_by": [{ "by": "revenue", "direction": "desc" }],
    "limit": 5
  }'

Returns one row per group, each merging the group columns with the aggregate aliases:

{
  "data": [
    { "status": "paid", "total": 12, "revenue": 4200 },
    { "status": "pending", "total": 3, "revenue": 900 }
  ]
}

POST /admin/projects/:projectId/collections/transaction

Run multiple collection writes atomically inside one project tenant database. This is the admin equivalent of POST /v1/client/collections/transaction: all operations commit or all roll back. Admin transactions bypass user row scoping and can read/write any row in the project.

curl -X POST 'https://api.amba.dev/v1/admin/projects/$PROJECT_ID/collections/transaction' \
  -H 'Authorization: Bearer $AMBA_PAT' \
  -H 'Content-Type: application/json' \
  -d '{
    "operations": [
      {
        "collection": "sessions",
        "op": "update",
        "id": "00000000-0000-0000-0000-000000000000",
        "set": { "status": "running" },
        "expected": { "status": "queued" }
      },
      {
        "collection": "webhook_outbox",
        "op": "insert",
        "row": { "kind": "session_started" }
      }
    ]
  }'

Each operation has collection plus op: "insert" | "update" | "delete". Inserts take row and optional on_conflict / conflict_target. Updates take id, non-empty set, optional expected, optional where, and optional objects: "merge" | "replace" for JSONB object update behavior. Deletes take id, optional expected, optional where, and optional hard: true for an admin hard delete.

expected is a compare-and-set guard: the write applies only if the current row still matches the supplied column values. A mismatch returns 409 PRECONDITION_FAILED; a missing row returns 404 NOT_FOUND. On transaction failure, the response includes details.opIndex naming the failing operation. Transactions are project-local; they do not span multiple Amba projects.

INSERT vs PATCH body shapes

The write verbs deliberately take different body shapes — this is a contract, not an accident:

VerbBody shapeWhy
POST …/rows (insert)flat { "col": value, … }An insert body is only column values — nothing to disambiguate.
PATCH …/rows/:id (single row){ "set": { … } } — flat also worksset is the canonical envelope (symmetry with bulk update); a flat body is accepted as an alias since it's unambiguous.
PATCH …/rows (bulk){ "where": { … }, "set": { … } }set and where are siblings — the set wrapper is what disambiguates the update payload from the filter.

Side by side:

# INSERT — flat column → value map
curl -X POST 'https://api.amba.dev/v1/admin/projects/$PROJECT_ID/collections/notes/rows' \
  -H 'Authorization: Bearer $AMBA_PAT' \
  -H 'Content-Type: application/json' \
  -d '{ "title": "Welcome", "pinned": true }'
 
# UPDATE — columns inside the `set` envelope
curl -X PATCH 'https://api.amba.dev/v1/admin/projects/$PROJECT_ID/collections/notes/rows/$ROW_ID' \
  -H 'Authorization: Bearer $AMBA_PAT' \
  -H 'Content-Type: application/json' \
  -d '{ "set": { "pinned": false } }'

A PATCH body with no usable set (an empty body, or a flat body on the bulk route, where the wrapper is required) returns 400 INVALID_BODY with a pointed hint naming the top-level keys you actually sent:

{
  "error": {
    "code": "INVALID_BODY",
    "message": "PATCH expects the fields you want to update inside a \"set\" object, e.g. {\"set\": {…}}. Received top-level keys: [title, pinned]."
  }
}

PATCH /admin/projects/:projectId/collections/:name/rows/:id

Update one row by id. The canonical body is { "set": { ...columns } }. A flat { "col": value } body (the same shape INSERT takes) is also accepted on this single-row route as an alias — there's no where here, so the shape is unambiguous.

# Canonical
curl -X PATCH 'https://api.amba.dev/v1/admin/projects/$PROJECT_ID/collections/notes/rows/$ROW_ID' \
  -H 'Authorization: Bearer $AMBA_PAT' \
  -H 'Content-Type: application/json' \
  -d '{ "set": { "pinned": false } }'
 
# Flat alias — equivalent
curl -X PATCH 'https://api.amba.dev/v1/admin/projects/$PROJECT_ID/collections/notes/rows/$ROW_ID' \
  -H 'Authorization: Bearer $AMBA_PAT' \
  -H 'Content-Type: application/json' \
  -d '{ "pinned": false }'

Two edge cases of the flat alias: a column literally named set holding an object resolves to the wrapper interpretation — use the explicit { "set": … } wrapper if you have one. And expected (compare-and-set, below) is a top-level control key — it's stripped on the flat path, while inside a { "set": { … } } envelope a real column named expected is written normally.

Compare-and-set (expected)

Optional optimistic concurrency, same contract as the client PATCH: include a top-level expected map of column → value and the update applies only if the row's current values match — otherwise 409 PRECONDITION_FAILED (vs 404 NOT_FOUND when the row doesn't exist at all). Null-safe: "expected": { "col": null } matches a NULL column. Admin CAS is not user-scoped — it can target any owner's row.

{ "set": { "status": "shipped" }, "expected": { "status": "packing" } }

JSON object columns merge (merge-patch)

A set value that is a JSON object targeting a jsonb column merges into the stored object (shallow, top-level key union, atomic in one statement) instead of replacing it — same contract as the client PATCH. Arrays, scalars and null replace the column value, as does any patch whose stored value isn't a JSON object. Opt out per request with ?objects=replace to overwrite the whole stored object.

PATCH /admin/projects/:projectId/collections/:name/rows

Bulk update rows matching a filter. Body is { "where": {...}, "set": {...} }. Unlike the single-row route, the set wrapper is required here — set and where are siblings, and the wrapper is what tells the update payload apart from the filter; a flat body returns 400 INVALID_BODY with the top-level-keys hint shown above. where is required and must produce at least one predicate (400 WHERE_REQUIRED otherwise — an empty {} doesn't count) so you can't accidentally rewrite the whole collection; to genuinely touch every row, use an always-true predicate such as { "id": { "isNotNull": true } }. JSON object values targeting jsonb columns merge exactly like the single-row PATCH above (?objects=replace opts out).

curl -X PATCH 'https://api.amba.dev/v1/admin/projects/$PROJECT_ID/collections/notes/rows' \
  -H 'Authorization: Bearer $AMBA_PAT' \
  -H 'Content-Type: application/json' \
  -d '{ "where": { "pinned": true }, "set": { "pinned": false } }'

DELETE /admin/projects/:projectId/collections/:name/rows/:id

Soft-delete one row by id. Pass ?hard=true only when you intentionally need to remove the row from the table.

curl -X DELETE 'https://api.amba.dev/v1/admin/projects/$PROJECT_ID/collections/notes/rows/$ROW_ID' \
  -H 'Authorization: Bearer $AMBA_PAT'

Returns 200 { "data": { "id": "...", "deleted": true, "mode": "soft" } }. If a soft-delete targets an already soft-deleted row, the route returns deleted: false with already_deleted: true. Missing rows return 404 NOT_FOUND.

DELETE /admin/projects/:projectId/collections/:name/rows

Bulk delete rows matching where. You must pass ?confirm=<count> with the exact number of rows that should be deleted; preview that value with the rows/count endpoint first. The default is a soft delete; ?hard=true permanently deletes matching rows.

curl -X DELETE 'https://api.amba.dev/v1/admin/projects/$PROJECT_ID/collections/notes/rows?confirm=3' \
  -H 'Authorization: Bearer $AMBA_PAT' \
  -H 'Content-Type: application/json' \
  -d '{ "where": { "status": "archived" } }'

MCP tools

amba_collections_create, amba_collections_list, amba_collections_get, amba_collections_alter, and amba_collections_delete wrap the schema routes for agentic use — pass your pat and project_id. The destructive operations set their confirm guard automatically. For rows, amba_admin_insert_row (one row), amba_admin_insert_rows (bulk, up to 500 — the seeding/migration path), amba_admin_list_rows, and amba_admin_aggregate_rows (group-by count/sum/avg/min/max) wrap the row routes. amba_admin_insert_row takes on_conflict + conflict_target for upserts, plus as_system to write a shared null-owner row. amba_collections_create takes shared (and read_policy / write_policy), and amba_collections_alter takes relax_user_id, for shared/global collections. amba_collections_reset_data empties a collection's rows while keeping its schema (the reset path above).

On this page