Skip to content
littlebit labs
Products Pricing Grants Company Docs
Products Pricing Grants Company Docs Experience Now
Library Vol. I — Essential Vol. II — Advanced Vol. III — Identity & the Gateway
Experience Now →
BOOK OF MINIMALVOL. I A house sparrow, engraved in black and white Essential Minimal
1Why Minimal
The shape of Minimal Two ways to reach a table Who this book is for How this book is organized What you need before Chapter 2 Exercises
2A Tour of Minimal
Where identity comes from A credential to call with Reading the organization What the project holds Reading rows Writing a row Reading it back Updating a row What just happened Paging, now with twelve Exercises
3Organizations, Projects, and Spaces
Three levels, one shape Naming a level: ids and abbreviations How a credential resolves to an org, a project, and a space The organization: reading and updating Projects: listing and reading Projects: updating Spaces: creating, listing, and updating Features Reading history What each level puts out of reach Exercises
4Credentials and Roles
Minting an access token What key_type controls Presenting the token Finding a token later No credential, or a bad one Roles: project and organization A narrowly scoped token, worked The request's path Exercises
5Auto API
Addressing a table How access is decided Reading rows Writing rows Output formats The same four operations through MCP Exercises
6Table Security, the Essentials
Permission templates Table locks Visibility: hiding a table from callers Exercises
7Talking to Minimal as an Agent
What MCP is Discovering the catalog Tools, not routes The session you cannot skip Reading and writing Acme's data over MCP When the client does not cooperate What a refusal looks like Exercises
8Errors and Refusals
401 and 403 — who you are, and what you may do 404 — nothing here to act on 409 — someone else already holds this 412 and 424 — a dependency, not necessarily bad input How MCP surfaces a refusal Telling refusals apart Exercises
AQuick Reference
The Acme database Auto API filter operators HTTP status codes MCP tools

Book of Minimal, Vol. I

Essential Minimal

Standing up an organization, reading and writing through the Auto API, authoring your first Custom API definitions, and calling both surfaces over MCP.

9 CHAPTERS · 20,871 WORDS · 8 DIAGRAMS

1Why Minimal

You have a database. You need an API in front of it: something a web client can call over HTTP, something an agent can call as a tool, something that checks who is asking before it answers. You need that API to know about more than one customer, so that acme's rows never show up in another company's response. You need most of the routes to be ordinary CRUD — list the employees, insert a leave request, update a salary — and a few of them to run real logic: join two tables, mask a column, call out to another system before answering.

None of that is hard, exactly. It is just a tax. Every team that puts a database behind an API pays it: write the routing, write the auth middleware, write the tenant check that runs before every query, decide who may read which table and who may write to it, write the same list/get/insert/update/delete handler for the eleventh table this month. None of that work is specific to what the product does — a payroll table and an inventory table need exactly the same plumbing around them, and most teams write it twice a year for the rest of their careers.

Minimal is that tax removed. Point it at a database you already have, and it answers HTTP requests — and, if you want, Model Context Protocol tool calls — for every table in that database, with authentication, per-tenant isolation and per-role permission already wired in. A caller identifies itself once, as belonging to one organization and holding some set of roles, and every request after that is checked against those roles before a row moves — how a caller proves that identity, and why Minimal trusts what it is told, is Chapter 2's subject. What you write by hand is the logic that is actually yours: the one endpoint that computes a payroll total, not the fifty that just move rows.

The shape of Minimal

Everything in Minimal sits under an organization — one tenant, one customer, one company. An organization holds one or more projects, and a project is where a database connection lives: register a project against your Postgres, MySQL, MariaDB or ClickHouse instance, and every table in it becomes reachable. Beneath a project sit spaces — dev, staging, prod, or whatever split you want — which is where you write and promote your own authored logic. A table, finally, is the unit of data: the thing a caller reads, filters, inserts into and deletes from, addressed by the database it lives in and the project that registered that database.

graph TD
    O["Organization: acme"] --> P["Project: people"]
    P --> S1["Space: dev"]
    P --> S2["Space: staging"]
    P --> S3["Space: prod"]
    P --> D["Database: acme (pg)"]
    D --> T1["Table: employee"]
    D --> T2["Table: salary"]
    D --> T3["Table: leave_request"]
    S1 -. authored logic .-> D

An organization owns projects; a project owns both the spaces that hold your authored logic and the databases those spaces can reach into.

Acme Corporation, the company this book follows, is one organization (acme) running one product: an HR system, the project people. It has three spaces and one Postgres database registered under the name acme, holding an employee table, a salary table, a leave_request table, and more. Every example from here on works against that database.

"Table" is a slightly loose word here, and deliberately so. A database view and a materialized view work through Minimal exactly as a table does — same read route, same permission model, same query language — because nothing in a request says which kind of object it is. If your product reaches for a view to pre-join two tables, Minimal does not need to know.

Two ways to reach a table

Once a table exists inside a registered database, Minimal gives you two different doors into it, and the choice between them is the first design decision you make for any given piece of data.

The Auto API is generic CRUD, with no authoring step at all. The table is named directly in the URL, and the four HTTP verbs do what you expect: GET reads, POST inserts, PUT updates, DELETE removes. Filtering, column selection, ordering, grouping and paging are all query parameters, so GET .../employee?department_id=gt.6&oy=full_name.as&ps=20&pg=0 reads the first twenty employees in departments numbered above six, sorted by name — no code written, anywhere, by anyone. This is the door you use for the routes that are just routes: listing, filtering, the CRUD your admin screen needs.

http
GET /minimal/api/rest/auto/v1/acme/people/pg/acme/employee?fc=id,full_name,department_id&oy=id.as&ps=5&pg=0
LB-Access-Token: <your-access-token>
json
[
  { "id": 1, "full_name": "Rafael Delacroix", "department_id": 11 },
  { "id": 2, "full_name": "Rafael Müller", "department_id": 7 },
  { "id": 3, "full_name": "Rafael Novak", "department_id": 8 },
  { "id": 4, "full_name": "Kwame Haddad", "department_id": 6 },
  { "id": 5, "full_name": "Rafael Haddad", "department_id": 6 }
]

fc is column selection — list the ones you want, and only those come back; leave it off and every column arrives.

The Custom API is the other door: authored logic, for the cases generic CRUD cannot express. You write a definition — one document describing an HTTP method, a path, a database to talk to, and an ordered list of steps — and Minimal serves it as a real endpoint from that point on. A step can run SQL; a later step can run a small script against the SQL step's rows before anything goes back to the caller, which is how you compute a total, redact a column, or shape a response the raw table never would. You write this once, per endpoint that needs it, and it lives in a space so you can prove it in dev before it reaches prod. Writing your first one end to end is Volume 2, Chapter 1's subject; this volume only teaches you to recognize when reaching for one is the right call.

Every table in your database is reachable both ways at once. A caller with the right role can GET an employee through the Auto API while another endpoint, written as a Custom API definition against the same table, returns a computed payroll summary. Nothing about picking one forecloses the other.

Who this book is for

Two different people read this book for two different reasons, and most of the chapters serve both without saying so twice.

If you are wiring a backend to a database — a web app, a mobile backend, an internal tool — you read this book for the REST surface. It covers how organizations, projects, spaces and tables fit together, how the Auto API's query language works, when to reach for a Custom API definition instead, and how permissions decide who can do what to which table.

If you are building an agent, or any tool that lets an AI system act on a database, you read it for the Model Context Protocol listener, reached as tools an agent calls instead of routes a browser calls. A tool that reads rows and a GET that reads rows check the same role, return the same kind of answer, and are refused for the same reasons. The one thing MCP adds is a session: before an agent calls anything else, it calls start_ai_session, and every tool call after that is recorded under the session id that call returns — a persistent, inspectable record of what the agent actually did, not just what it was capable of doing.

http
POST /minimal/mcp/rpc/v1
LB-Access-Token: <your-access-token>
Content-Type: application/json
Accept: application/json, text/event-stream
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: start_ai_session
json
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "start_ai_session",
    "arguments": { "name": "Acme HR assistant", "context": "Answering employee questions" }
  }
}

(Chapter 7 shows the complete shape of a real request, including a _meta block a conforming client attaches to every call; this preview keeps only what matters for the point being made here.)

The response is a JSON-RPC object carrying your id, with the session-creation API's own answer serialized as text inside result.content:

json
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "content": [
      {
        "type": "text",
        "text": "{\"name\":\"Acme HR assistant\",\"org_id\":\"acme\",\"project_id\":\"people\",\"session_id\":\"01M20681V68Y5421QC5N23F9DB\",\"user_id\":\"mcp:acme-agent\"}"
      }
    ]
  }
}

Everything an agent can reach over MCP, a backend developer can reach over REST — MCP invents no capability of its own, it is a second way to call the API you already have. That is why this book teaches the REST surface first: understand the shape of an organization, a table and a permission once, and the MCP chapters are mostly about the session wrapper around a tool call you already recognize.

How this book is organized

This is Volume 1, Essential Minimal. It covers the path every deployment walks: standing up an organization, a project and a space; reading and writing through the Auto API; recognizing when a Custom API definition is the right call instead; and calling both surfaces over MCP as an agent would. By the end of it you can build a real application against a real database from either side — HTTP or MCP.

Volume 2, Advanced Minimal, is what you reach for once that application has users you don't fully trust and a shape you want to defend rather than just build. It covers permission templates and table-level locks in depth, row-level security for tables where a caller should see only their own rows, the schema-changing DDL surface, the full lifecycle of access tokens and credentials, authored modules as a unit you version and reuse across definitions, and the audit trail that records what every caller actually did. None of it is exotic — all of it is the difference between a demo and a deployment. The split exists because you do not need any of it to write your first working endpoint, and you will want all of it before you run one in production.

What you need before Chapter 2

Chapter 2 starts writing requests, and it assumes three things already exist: an organization, a project registered against a database, and a credential — an access token — that identifies you as a caller holding some set of roles in that organization. This book builds all three once, for Acme, and then uses them throughout: the organization acme, the project people, and a token that carries the roles the examples need. If you are following along against your own deployment rather than reading, create the equivalent of those three before you turn the page; nothing in Chapter 2 shows you how to get an identity, only what to do once you have one.

Exercises

  1. Acme's people project has one Postgres database, acme, holding an employee table, a salary table, and six more (Appendix A lists all eight, plus the two views and the materialized view built on top of them). Sketch your own product as the same four-level hierarchy — organization, project, space, table — using your own names in place of Acme's.

  2. Decide, for each of the following, whether it belongs behind the Auto API or a Custom API definition, and say why in one sentence: listing every open leave request; computing each department's average tenure in years; inserting a new employee row; redacting every employee's phone number before a report leaves the building.

  3. Using the shape of the Auto API URL shown in this chapter (?department_id=gt.6&oy=full_name.as&ps=20&pg=0), write the query string for a different request: the first ten employees in departments numbered 3 or lower, sorted by name in reverse. You cannot run it until Chapter 2 gives you a credential — write down what you believe the correct query string is, and check it against Chapter 5.

2A Tour of Minimal

Acme Corporation runs one product on Minimal: an internal HR system called People. Acme is the organization; people is the project beneath it; three spaces — dev, staging, and prod — sit under that. A database named acme, holding the ordinary tables of an HR system, is registered against the project. Every request in this chapter runs against dev.

Nothing here is hypothetical. Every call below is one you can make yourself, against your own deployment, once you swap Acme's names for the ones your organization actually uses. Start with the shape of things.

graph TD
    Org["organization: acme"] --> Proj["project: people"]
    Proj --> Dev["space: dev"]
    Proj --> Staging["space: staging"]
    Proj --> Prod["space: prod"]
    Proj --> DB["database: acme (postgres)"]
    DB --> Employee["table: employee"]
    DB --> Department["table: department"]
    DB --> Salary["table: salary"]
    DB --> Rest["8 more tables"]

One organization, one project, three spaces, and a registered database holding the tables the rest of this chapter reads and writes.

The three spaces are not three copies of the same data — they are three contexts a request can be made in. dev is where a change is written and tried first; staging is where it is proven before it reaches prod; prod is what Acme's own applications call. This chapter stays in dev throughout, which is the space every request below names.

Where identity comes from

A request carrying these three headers is claiming an identity:

http
GET /minimal/system/api/v1/org HTTP/1.1
Host: api.acme.example
X-Org-Id: acme
X-User-Id: acme-admin
X-User-Roles: hr-admin

An organization route reads only these three — X-Project-Id and X-Space-Id name a project and a space, and neither applies above the project level, so both go unread here. Minimal answers this one — with the same organization record the next section reads — without checking that whoever sent it is really acme-admin, or that anyone actually granted them hr-admin. Every one of these headers is a plain claim. Minimal trusts them as given.

That is deliberate, not an oversight. Minimal is meant to run behind a boundary that has already done real authentication — a private network, an internal sub-domain, or a gateway that verifies a real user or service and only then sets these headers itself before forwarding the request. A caller that reaches Minimal at all has, by construction, already cleared that boundary. Minimal's job starts one step later: given an identity and a set of roles it can take as true, decide what that identity may do to which table.

This matters for where you put Minimal, not for how you call it. Running it on your own machine or on a network only you can reach — which is what every example in this book does — makes sending the headers directly exactly as safe as running any other admin tool locally. If you expose Minimal to a network you do not fully trust, the boundary above has to exist and has to be the only path in: something in front of Minimal that authenticates a real caller and sets these headers itself, never forwarding ones a caller supplied on its own.

A credential to call with

Every request to Minimal carries an identity: which organization, which project, which space, which user, and which roles that user holds. You can send those as five separate headers on every call, or you can mint a credential once and send that instead. An access token is the credential that does this — a single string that stands in for the five headers, carries its own fixed set of roles, and can expire.

Minting one still needs the five headers, because you have not got a token yet:

http
POST /minimal/system/api/v1/access/token HTTP/1.1
Host: api.acme.example
X-Org-Id: acme
X-Project-Id: people
X-Space-Id: dev
X-User-Id: acme-admin
X-User-Roles: hr-admin
Content-Type: application/json
json
{
  "name": "acme-admin-cli",
  "description": "Command-line client for the People API walkthrough",
  "roles": "hr-admin",
  "key_type": "crud",
  "token_type": "user",
  "expires_in_days": 1
}

roles has to be a subset of whatever X-User-Roles sent — a token can never carry more than its minter holds. key_type is four characters in create/read/update/delete order; crud is every verb, -r-- would be read-only. The response carries the token string exactly once:

http
HTTP/1.1 201 Created
Content-Type: application/json
json
{
  "token_id": "01M265XDWMX41N953583HCKQWA",
  "access_token": "<your-access-token>",
  "token_type": "us",
  "name": "acme-admin-cli",
  "description": "Command-line client for the People API walkthrough",
  "tags": "",
  "org_id": "acme",
  "project_id": "people",
  "space_id": "dev",
  "owner_id": "acme-admin",
  "roles": "hr-admin",
  "key_type": "crud",
  "status": "active",
  "expires_at": "2026-09-11T17:30:12Z",
  "created_at": "2026-09-10T17:30:12Z"
}

Note token_type comes back as the two-letter code us, not the word you sent. The token string does not vanish after this response. A caller can read their own access_token back in full later, through the token detail read or through their own listing — both return it exactly as minted. The one place it is left out is the listing that spans every owner in the project, which lets an administrator confirm a credential exists without being able to spend it. Every call from here on sends the token as a single header, LB-Access-Token, in place of the five you just used.

Reading the organization

With a token in hand, read the organization record:

http
GET /minimal/system/api/v1/org HTTP/1.1
Host: api.acme.example
LB-Access-Token: <your-access-token>
json
{
  "org_id": "acme",
  "org_name": "Acme Corporation",
  "abbreviation": "acme",
  "admin_email": "people-ops@acme.example",
  "admin_name": "Margaret Okonjo",
  "address": "1 Foundry Road, Suite 400",
  "state": "CA",
  "country": "USA",
  "pin_code": "94103",
  "status": "active",
  "touched_by": "mcp:acme-agent",
  "creator_type": "user",
  "org_read_roles": "hr-admin,hr-analyst,payroll-clerk",
  "org_write_roles": "hr-admin",
  "org_delete_roles": "hr-admin",
  "project_create_roles": "hr-admin",
  "database_create_roles": "hr-admin",
  "database_drop_roles": "hr-admin",
  "created": "2026-09-05T17:17:32.832849Z",
  "updated_at": "2026-09-10T14:00:07.454321Z"
}

Everything is there in one object — no masking, nothing dropped. Notice org_read_roles and org_write_roles are different lists: hr-analyst and payroll-clerk can read this record, but only hr-admin can change it. That distinction matters later in this chapter.

There is no paging and no format parameter on this route — one organization is always one object, returned as JSON. And the check above is the only gate on it: a suspended organization can still be read this way, deliberately, so that whoever holds the right role can see why it was suspended and what it would take to reactivate it.

What the project holds

The organization record does not say what tables live under it. For that, ask the project's own catalog for one of its registered databases:

http
GET /minimal/rest/meta/v1/table/list?type=pg&schema=acme HTTP/1.1
Host: api.acme.example
LB-Access-Token: <your-access-token>
json
[
  "employee",
  "department",
  "salary",
  "hike",
  "leave_request",
  "holiday",
  "attendance",
  "audit_note",
  "v_employee_directory",
  "v_leave_balance",
  "mv_headcount_by_department"
]

type is a two-letter code for the kind of database — pg here. Minimal serves four: pg (PostgreSQL), ch (ClickHouse), ms (MySQL), and ma (MariaDB). schema is the name the database was registered under on this project, which happens to also be acme. The list answers from Minimal's own catalog of what has been indexed, not from a live query against the database itself, so a table that has never been indexed will not appear here even if it exists. employee has been indexed. That is the one this chapter uses next.

This two-letter code is the vocabulary for addressing a table — Auto API and this Meta route both use it. A separate surface, the one that registers a database against a project and indexes its tables in the first place, names the same four databases by their full word instead — postgres, clickhouse, mysql, mariadb — under a differently named field, db_type rather than type. The two never mix on the same request; which one a given route wants is part of that route's own contract, not something to infer from the field name alone.

This route only names tables. If you need the columns behind one of them — names, types, nullability — a neighboring route inspects the database live: GET /minimal/rest/meta/v1/table/detail?type=pg&schema=acme&table=employee returns one object per column, current as of the moment you call it rather than as of the last time the table was indexed.

Reading rows

Auto API addresses a table directly in the URL path: the organization's abbreviation, the project's abbreviation, the database-type code, the registered database name, and the table — five segments, in that order. For employee that is /acme/people/pg/acme/employee.

Page size and page number are both mandatory. There is no default row order, so a filtered read that needs to come back the same way twice needs an explicit oy. Read the first five people in the Data department, ordered by name:

http
GET /minimal/api/rest/auto/v1/acme/people/pg/acme/employee?ps=5&pg=0&department_id=eq.3&oy=full_name.as HTTP/1.1
Host: api.acme.example
LB-Access-Token: <your-access-token>
json
[
  {
    "id": 56,
    "full_name": "Chidi Ahmed",
    "email": "chidi.ahmed56@acme.example",
    "department_id": 3,
    "manager_id": 37,
    "title": "Senior Analyst",
    "hired_on": "2023-08-16T00:00:00Z",
    "left_on": null,
    "phone": "+44 20 7475 8864",
    "is_active": true
  },
  {
    "id": 18,
    "full_name": "Chidi Silva",
    "email": "chidi.silva18@acme.example",
    "department_id": 3,
    "manager_id": 15,
    "title": "Specialist",
    "hired_on": "2024-12-09T00:00:00Z",
    "left_on": null,
    "phone": "+44 20 7395 5296",
    "is_active": true
  },
  {
    "id": 30,
    "full_name": "Diego O'Brien",
    "email": "diego.obrien30@acme.example",
    "department_id": 3,
    "manager_id": 21,
    "title": "Staff Engineer",
    "hired_on": "2024-06-10T00:00:00Z",
    "left_on": null,
    "phone": "+44 20 7513 9122",
    "is_active": true
  },
  {
    "id": 74,
    "full_name": "Fatima Silva",
    "email": "fatima.silva74@acme.example",
    "department_id": 3,
    "manager_id": 50,
    "title": "Specialist",
    "hired_on": "2025-08-04T00:00:00Z",
    "left_on": null,
    "phone": "+44 20 7985 7736",
    "is_active": true
  },
  {
    "id": 22,
    "full_name": "Ingrid Bergström",
    "email": "ingrid.bergstrom22@acme.example",
    "department_id": 3,
    "manager_id": 19,
    "title": "Analyst",
    "hired_on": "2023-01-10T00:00:00Z",
    "left_on": null,
    "phone": "+44 20 7920 9559",
    "is_active": true
  }
]

department_id=eq.3 is a column filter, written operator.value. eq is one of nineteen operators Auto API implements; others compare (gt, lt), match a list (in), match a pattern (li for contains, mt for a regular expression), or test for null (is). Any query parameter that is not one of the reserved keywords — ps, pg, fc, oy, gy, hv, df, uf, format, or — is read as a filter on the column of that name, which is also why a misspelled column comes back as a 417, not a silently ignored parameter.

Send more than one filter and they combine with AND; there is no way to write OR across two different columns except the dedicated or group.

Page size has a ceiling — 100 rows unless the deployment raises it — and a request for more is quietly capped rather than refused, so a page shorter than what you asked for does not by itself mean you have reached the end of the table. pg=1 on the same URL returns the next five rows in the same order; the page size and the ordering together are what make that tiling predictable, and without oy two calls for the same page are not guaranteed to agree.

Every example so far has come back as JSON, which is the default. Add &format=csv, &format=xml, &format=yaml, or &format=bson to the same request and the rows come back encoded differently; nothing about the filters, the ordering, or the paging changes underneath it.

Writing a row

The insert route takes the same path, a POST, and a body that is always an array — even for one row. Every key has to be a real column on the table; a column the database generates for you, such as employee's id, still has to be present in the body, just set to null:

http
POST /minimal/api/rest/auto/v1/acme/people/pg/acme/employee HTTP/1.1
Host: api.acme.example
LB-Access-Token: <your-access-token>
Content-Type: application/json
json
[
  {
    "id": null,
    "full_name": "Priya Chandrasekhar",
    "email": "priya.chandrasekhar@acme.example",
    "department_id": 3,
    "manager_id": 21,
    "title": "Data Analyst",
    "hired_on": "2026-09-10",
    "left_on": null,
    "phone": "+44 20 7000 1234",
    "is_active": true
  }
]
http
HTTP/1.1 201 Created
Content-Type: application/json
json
{"rows_affected": 1}

That is all a successful insert tells you — a count, not the row. Every write here is atomic: a multi-row insert lands in a single transaction — send ten rows and either all ten insert or none do — and a PUT or DELETE matching many rows runs as one statement, atomic the same way. ClickHouse is the one exception: it has no transaction concept at all, so none of the three verbs gets this guarantee there.

id is the only column in this table sent as null, because it is the only one the database assigns for you. Every other mandatory column — full_name, email, department_id, title, hired_on, is_active — has to be present on every row of the array, or the whole insert is refused with a message naming which ones were missing. null is not a way to skip a mandatory column in general; it only works where the database itself has a default to fall back on, id's sequence being one.

Reading it back

The insert did not hand back the row it created, so read it back the same way you read anything else — a filter that will only match the row you just wrote:

http
GET /minimal/api/rest/auto/v1/acme/people/pg/acme/employee?ps=5&pg=0&email=eq.priya.chandrasekhar%40acme.example HTTP/1.1
Host: api.acme.example
LB-Access-Token: <your-access-token>
json
[
  {
    "id": 121,
    "full_name": "Priya Chandrasekhar",
    "email": "priya.chandrasekhar@acme.example",
    "department_id": 3,
    "manager_id": 21,
    "title": "Data Analyst",
    "hired_on": "2026-09-10T00:00:00Z",
    "left_on": null,
    "phone": "+44 20 7000 1234",
    "is_active": true
  }
]

The row exists, and the database assigned it id 121 — the value you sent as null, and one past the 120 employees already in the table before this chapter started. Keep that id; the rest of this chapter, and its exercises, reuse it.

Updating a row

Change is a PUT on the same path, with no body: the new values and the row filter both live in the query string, as set=column.value and column=operator.value. Give Priya a new title:

http
PUT /minimal/api/rest/auto/v1/acme/people/pg/acme/employee?set=title.Senior%20Data%20Analyst&id=eq.121 HTTP/1.1
Host: api.acme.example
LB-Access-Token: <your-access-token>
http
HTTP/1.1 200 OK
Content-Type: application/json
json
{"rows_affected": 1}

At least one filter is required — an update sent with none is refused outright rather than applied to every row in the table. Reading the row back the same way as before now shows "title": "Senior Data Analyst".

What just happened

Every call in this chapter succeeded because the token carried hr-admin. That is not the same role check twice. Reading and writing the organization record is decided by that record's own org_read_roles and org_write_roles, which you saw above. Reading and writing a table through Auto API is decided by something else entirely: a permission assigned to that specific table, independent of anything the organization or the project record says, and checked separately for each of three channels: the direct channel (an ordinary caller like the one in this chapter), the agent channel, and the machine-to-machine channel. A role that can read or write the organization freely can still be refused on a table, and the reverse holds too.

Send the same two calls again, this time as hr-analyst instead of hr-admin — using the five headers directly is enough to show it, without minting anything new. The read still works:

http
GET /minimal/api/rest/auto/v1/acme/people/pg/acme/employee?ps=5&pg=0 HTTP/1.1
Host: api.acme.example
X-Org-Id: acme
X-Project-Id: people
X-Space-Id: dev
X-User-Id: acme-analyst
X-User-Roles: hr-analyst
http
HTTP/1.1 200 OK

The insert does not:

http
POST /minimal/api/rest/auto/v1/acme/people/pg/acme/employee HTTP/1.1
Host: api.acme.example
X-Org-Id: acme
X-Project-Id: people
X-Space-Id: dev
X-User-Id: acme-analyst
X-User-Roles: hr-analyst
Content-Type: application/json
http
HTTP/1.1 403 Forbidden
Content-Type: application/json
json
{
  "code": 403,
  "status": "Forbidden",
  "message": "insufficient permissions for this operation on this table, got [hr-analyst]"
}

The message names exactly which roles were sent and refuses the specific operation, not the whole table — the same token can still read employee a moment later. Two roles, one table, two different answers:

Role Read employee Create in employee
hr-admin 200 201
hr-analyst 200 403

Everything in this chapter ran on the direct channel. The agent channel and the machine-to-machine channel — a scheduled job, say — can carry an entirely different set of roles on the same table; nothing about the shape of the check changes, only which of the three role lists is consulted.

Landing on one of the three is never the caller's own choice. Presenting an access token, the channel is whichever one the token's own token_type names — Chapter 4 covers minting one, with the full mapping. Presenting the five headers directly instead, Minimal reads a prefix off X-User-Id: a value beginning mcp: puts the request on the agent channel, one beginning m2m: puts it on machine-to-machine, and anything else — no prefix included — falls back to direct, the channel every call in this chapter has used. Minimal does no more than compare that prefix string; it does not know or check what "agent" or "machine-to-machine" actually means. Deciding which real caller is a person, which is an agent acting on someone's behalf, and which is one service calling another — and writing mcp: or m2m: onto the identity it hands Minimal accordingly — is the job of the boundary this chapter's "Where identity comes from" section described, never Minimal's own. A caller who reaches Minimal directly, bypassing that boundary, could type mcp: in front of its own user id and land on the agent channel the same way; nothing here authenticates the prefix, any more than the four headers beside it are authenticated. Volume 2, Chapter 1 covers the full mechanics for the Custom API, Chapter 9 covers configuring which prefix strings a deployment recognizes at all, and Chapter 10 walks through building that boundary against a real identity provider.

Paging, now with twelve

The Data department had eleven employees at the start of this chapter and has twelve now. Ask for the second page of the same filtered, ordered read from earlier — pg=1 instead of pg=0, everything else unchanged:

http
GET /minimal/api/rest/auto/v1/acme/people/pg/acme/employee?ps=5&pg=1&department_id=eq.3&oy=full_name.as HTTP/1.1
Host: api.acme.example
LB-Access-Token: <your-access-token>
json
[
  {
    "id": 105,
    "full_name": "Ingrid Haddad",
    "email": "ingrid.haddad105@acme.example",
    "department_id": 3,
    "manager_id": 23,
    "title": "Director",
    "hired_on": "2022-06-28T00:00:00Z",
    "left_on": null,
    "phone": "+44 20 7370 1758",
    "is_active": true
  },
  {
    "id": 111,
    "full_name": "Kwame Müller",
    "email": "kwame.muller111@acme.example",
    "department_id": 3,
    "manager_id": 92,
    "title": "Analyst",
    "hired_on": "2021-01-12T00:00:00Z",
    "left_on": null,
    "phone": "+44 20 7624 0036",
    "is_active": true
  },
  {
    "id": 49,
    "full_name": "Lars Fernandes",
    "email": "lars.fernandes49@acme.example",
    "department_id": 3,
    "manager_id": 39,
    "title": "Analyst",
    "hired_on": "2021-06-29T00:00:00Z",
    "left_on": null,
    "phone": "+44 20 7537 3156",
    "is_active": true
  },
  {
    "id": 88,
    "full_name": "Margaret O'Brien",
    "email": "margaret.obrien88@acme.example",
    "department_id": 3,
    "manager_id": 53,
    "title": "Engineer",
    "hired_on": "2025-08-30T00:00:00Z",
    "left_on": null,
    "phone": "+44 20 7172 9614",
    "is_active": true
  },
  {
    "id": 121,
    "full_name": "Priya Chandrasekhar",
    "email": "priya.chandrasekhar@acme.example",
    "department_id": 3,
    "manager_id": 21,
    "title": "Data Analyst",
    "hired_on": "2026-09-10T00:00:00Z",
    "left_on": null,
    "phone": "+44 20 7000 1234",
    "is_active": true
  }
]

Priya, the row this chapter just wrote, sorts onto the end of this page because full_name puts her there alphabetically — paging and ordering are computed fresh on every call, over whatever rows currently exist, not over some snapshot taken when the first page was read.

Exercises

  1. The department table has twelve rows, each with an id and a name. Read all of them — ps=20&pg=0 against /acme/people/pg/acme/department is enough to get every one in a single page — then pick a department other than the one this chapter used, from among the eleven that actually have employees (one department is deliberately empty), and read employee filtered to it, ordered by hired_on ascending instead of full_name. Who was the first person hired into that department, and who is the most recent?

  2. Update a different column on the row this chapter inserted — its phone, say — the same way this chapter just updated title, then read the row back to confirm the new value landed.

  3. Mint an access token for hr-analyst — roles=hr-analyst, key_type=crud, same organization, project and space as before — and use it, instead of the five headers, to repeat the refused insert from this chapter. key_type=crud grants the token every verb, so nothing at the token's own level stands between it and a POST. Confirm the insert is refused anyway, for the same reason as before, and read the message it carries to see which of this chapter's two separate role checks — the organization's, or the table's — is the one actually doing the refusing.

3Organizations, Projects, and Spaces

Every request you send to Minimal names a place before it names an action. Here is a complete one:

http
GET /minimal/system/api/v1/org HTTP/1.1
Host: api.acme.example
X-Org-Id: acme
X-User-Id: acme-analyst
X-User-Roles: hr-analyst
json
{
  "org_id": "acme",
  "org_name": "Acme Corporation",
  "abbreviation": "acme",
  "admin_email": "people-ops@acme.example",
  "admin_name": "Margaret Okonjo",
  "address": "1 Foundry Road, Suite 400",
  "state": "CA",
  "country": "USA",
  "pin_code": "94103",
  "status": "active",
  "touched_by": "mcp:acme-agent",
  "creator_type": "user",
  "org_read_roles": "hr-admin,hr-analyst,payroll-clerk",
  "org_write_roles": "hr-admin",
  "org_delete_roles": "hr-admin",
  "project_create_roles": "hr-admin",
  "database_create_roles": "hr-admin",
  "database_drop_roles": "hr-admin",
  "created": "2026-09-05T17:17:32.832849Z",
  "updated_at": "2026-09-10T14:00:07.454321Z"
}

X-Org-Id names the organization. hr-analyst is one of the roles listed in that organization's own org_read_roles, so the read succeeds — even though hr-analyst sits nowhere in org_write_roles and cannot change anything below. Nothing else in the request says who is asking beyond that: the organization decides, one role list per verb, who may read it and who may write it, and the two lists do not have to agree. Nothing in this record is masked or trimmed for the reader's roles, either — every field comes back to any caller who clears the read check at all.

That one request already shows the whole shape of Minimal's identity hierarchy: a place, addressed by an id, guarded by a role list read from that place's own stored record. Organizations, projects, and spaces are the same shape, three times, nested.

Three levels, one shape

An organization is the billing and identity boundary — one organization per tenant. It holds an administrator's contact details, a status (active or suspended), and four role lists that govern the organization surface itself: who may read it, write it, delete it, and create a project beneath it. It also holds two role lists that govern who may create or drop a whole database on the organization's behalf, a provisioning-level power this chapter does not cover.

A project is where a database connection is registered and where most of a caller's day-to-day work happens. Acme runs one, people, an HR product; an organization can hold as many as its project_create_roles allow. A project owns three role lists of its own — read, write, delete — and it owns the database registrations themselves: host, port, credentials, database type. A table exists to the rest of the API only once it has been indexed under a project.

A space is an environment inside a project — dev, staging, prod are Acme's three. The same Custom API definition can exist independently in each space, at its own version, which is how a change is written and tried in dev, proven in staging, before it reaches prod. A space is not a copy of the project; it is a narrower scope inside it. Permission templates and table permissions live at the project level and are shared by every space in it; only a smaller set of objects — Custom API definitions and modules chief among them — live inside one space and are invisible from the others.

graph TD
    subgraph ORG["Organization acme — org-wide"]
        OROLES["org_read/write/delete_roles<br/>project_create_roles"]
        ODDL["database_create_roles<br/>database_drop_roles"]
    end
    subgraph PROJ["Project people — project-scoped"]
        PROLES["project_read/write/delete_roles"]
        PDB["registered databases"]
        TEMPL["permission templates"]
        LOCKS["table permissions and locks"]
    end
    subgraph DEV["Space dev — space-scoped"]
        DDEF["Custom API definitions"]
        DMOD["modules"]
    end
    subgraph STG["Space staging — space-scoped"]
        SDEF["Custom API definitions"]
        SMOD["modules"]
    end
    subgraph PRD["Space prod — space-scoped"]
        PDEF["Custom API definitions"]
        PMOD["modules"]
    end
    ORG --> PROJ
    PROJ --> DEV
    PROJ --> STG
    PROJ --> PRD

What lives at each level: organization-wide data applies to every project underneath it; project-scoped data is shared by every space inside that project; space-scoped data exists independently in each space and is invisible from the others.

Naming a level: ids and abbreviations

Every organization, project, and space has two identities, for two different purposes.

The id — org_id, project_id, space_id — is what every request header and query parameter uses to address one specific row: X-Org-Id: acme. Left unspecified at creation, the server generates a 26-character ULID; sent explicitly, the caller's own choice is stored instead. Acme chose plain strings for all four of its ids — acme, people, and dev / staging / prod — which is why they read like names rather than ULIDs. Either way, an id never changes and is never reused. When sent as a header it is capped at 64 characters — room to spare over a generated ULID's 26.

The abbreviation is a separate, always human-chosen code — even when, as with Acme, it happens to match the id. Organizations and projects each have one; spaces do not. It is unique within its scope — deployment-wide for an organization, organization-wide for a project — and fixed for the life of the row: no route lets you change it once set.

The abbreviation's job is narrower than the id's: it addresses the organization and project in the path of an Auto API or Custom API call, lower-cased. /acme/people/pg/acme/employee reads the employee table through the people project of the acme organization, over a database registered under the schema name acme on that project. A space has no abbreviation because a space is never named in a path — it is always named in a header.

How a credential resolves to an org, a project, and a space

Every route, organization and project routes included, reads its identity the same way: an organization route needs X-Org-Id, X-User-Id, X-User-Roles; a project route adds X-Project-Id to that set — and a space route or most of the surface beneath a project adds X-Space-Id as well. Rather than sending those headers explicitly, present LB-Access-Token instead, and the server fills in the organization, project, space, user, and roles from what that token was minted with; this shortcut works on every route, not only space-level ones.

http
GET /minimal/system/api/v1/space HTTP/1.1
Host: api.acme.example
LB-Access-Token: <your-access-token>

is equivalent to sending the identity headers by hand, because minting an access token takes X-Org-Id, X-Project-Id, and X-Space-Id at creation time, and the token carries that exact combination, plus a fixed set of roles, for as long as it is valid. None of the identity fields on a token can be widened or moved afterward — if a caller needs a different space, the answer is a new token, not an edit to the old one.

An MCP session resolves the same way, one level more strictly. LB-Access-Token is required on every tools/call, not only the first — the organization, project, space, user, and roles all come from whatever token is presented with that call. Every tool but two, start_ai_session and help, additionally requires an X-Session-Id naming the conversation the call is recorded under, sent alongside the token rather than in place of it. No tool takes the organization, project, or space as an argument, and no argument can override what the token resolves to:

json
{"name": "get_org_details", "arguments": {}}

answers with the caller's own organization record — the one its token resolves to — because there is nothing else the call could mean. (Chapter 7 shows the complete request, headers included.)

The organization: reading and updating

Reading an organization needs a role in its org_read_roles; updating it needs a role in org_write_roles. Acme keeps the two lists different on purpose, so a caller who can see the record is not automatically a caller who can change it.

An update sends only the fields you mean to change:

http
PUT /minimal/system/api/v1/org HTTP/1.1
Host: api.acme.example
X-Org-Id: acme
X-User-Id: acme-admin
X-User-Roles: hr-admin
Content-Type: application/json

{
  "org_id": "acme",
  "admin_email": "ops@acme.example",
  "org_write_roles": "hr-admin,ops-lead"
}
http
HTTP/1.1 200 OK
Content-Type: application/json

(the body is empty — read the organization back if you want to confirm the new values)

Everything you leave out of the body is left exactly as stored, but the body must change something: sending only org_id is refused with 400, reported as no fields to update, rather than accepted as a no-op. Three fields are refused outright rather than quietly dropped if you send them here: abbreviation (fixed for life, because it addresses the organization in a path), status (changed only by a separate lifecycle call), and database_create_roles/database_drop_roles (granted only by a separate provisioning call). Each answers 400 and names where the field actually belongs — a silently ignored field would look, from the response, exactly like one that took effect.

The lifecycle route itself, PATCH /minimal/system/api/v1/org/status (Chapter 2 shows it suspending and reactivating Acme), is narrower still: the body may carry status and nothing else — sending any other field alongside it, DDL role grants included, is refused with 400 rather than applied together with the status change. And status accepts only active, suspended, inactive, or defunct — deleted is not a settable value here, because deleting an organization is its own route (DELETE v1/org) that stamps that status internally as an audit marker, not a state anything else is meant to observe on a live row.

Role lists behave differently on update than they do at creation. Creating an organization merges the roles you name with your own caller roles, so a creator can never lock themselves out of what they just made. Updating one replaces the list outright — sending org_write_roles without your own role in it removes your own access to this route immediately, and the API treats that as your decision to make, not a mistake to guard against.

Projects: listing and reading

A project is addressed by X-Project-Id alongside the organization it sits under, and read access is gated the same way an organization's is — a role in the project's own project_read_roles.

http
GET /minimal/system/api/v1/project HTTP/1.1
Host: api.acme.example
X-Org-Id: acme
X-Project-Id: people
X-User-Id: acme-payroll
X-User-Roles: payroll-clerk
json
{
  "org_id": "acme",
  "project_id": "people",
  "name": "People",
  "abbreviation": "people",
  "description": "Acme people directory, updated over MCP",
  "project_read_roles": "hr-admin,hr-analyst,payroll-clerk",
  "project_write_roles": "hr-admin,payroll-clerk",
  "project_delete_roles": "hr-admin",
  "embedding_enabled": 0,
  "db_info": [
    {
      "db_name": "acme",
      "db_type": "postgres",
      "username": "minimalist",
      "password": "********",
      "host_details": [{"host": "localhost", "port": "5433"}]
    }
  ]
}

The full record also carries a handful of other embedding-configuration fields beyond the flag shown, the project's schema-DDL role lists (schema_read_roles, schema_write_roles), and its audit timestamps, left out above for space. Every password inside db_info, though, always comes back as a fixed mask — reading a project is never a way to recover a connection's real credentials. A caller who genuinely needs the decrypted password back, to resend a complete database list on an update, needs project_write_roles, not merely project_read_roles, and reaches it through a separate, otherwise identical read.

To see every project in an organization at once, rather than one at a time:

http
GET /minimal/system/api/v1/project/all HTTP/1.1
Host: api.acme.example
X-Org-Id: acme
X-User-Id: acme-payroll
X-User-Roles: payroll-clerk

answers with a JSON array, one entry per project the caller's roles can read, each shaped exactly like the single read above. A project the caller has no read role for is simply missing from the array — it is never listed in a locked-out or redacted form, and the caller has no way to tell from the response that anything was withheld. That makes two different empty answers mean two different things: an organization with no projects at all answers 200 with []; an organization that holds projects, none of which this caller can read, answers 404.

Projects: updating

Updating a project takes the same headers as reading one and needs a role in project_write_roles rather than project_read_roles. org_id and project_id in the body are ignored either way — whatever the headers name is what gets updated, regardless of what the body claims.

http
PUT /minimal/system/api/v1/project HTTP/1.1
Host: api.acme.example
X-Org-Id: acme
X-Project-Id: people
X-User-Id: acme-admin
X-User-Roles: hr-admin
Content-Type: application/json

{
  "description": "Acme people directory and payroll, updated over MCP and REST",
  "project_write_roles": "hr-admin,payroll-clerk,ops-lead"
}
http
HTTP/1.1 200 OK
Content-Type: application/json

{"org_id": "acme", "project_id": "people"}

Everything left out of the body is left exactly as stored, the same differential-write rule the organization follows. abbreviation is fixed for the project's life, for the same reason it is fixed for an organization — it addresses the project in a path. And schema_write_roles / schema_read_roles, the project's own database-DDL role lists, are refused here the same way an organization's database_create_roles/database_drop_roles are: 400, naming the separate provisioning route that actually sets them (Advanced Minimal, Chapter 3).

database is the field worth pausing on. It replaces the project's whole list of registered database connections outright, not merging one new entry alongside what is already there — which is also why no MCP tool offers it: doing so would mean resending every already-registered database's real password along with the new one, and nothing on that surface holds those in the clear to resend. A REST caller with project_write_roles can, through the unmasked read from the previous section. And whether or not this particular patch touched database at all, a successful PUT re-indexes every database currently registered on the project, the same table-cataloging pass Chapter 2 first showed running at bootstrap — a patch that only changed description still re-triggers the whole project's index.

Spaces: creating, listing, and updating

A space is created inside a project, and — unlike an organization or a project — its create and update bodies are always a JSON array, even for a single space. Acme named its three spaces explicitly rather than letting the server generate ids for them:

http
POST /minimal/system/api/v1/space HTTP/1.1
Host: api.acme.example
X-Org-Id: acme
X-Project-Id: people
X-User-Id: acme-admin
X-User-Roles: hr-admin
Content-Type: application/json

[
  {
    "org_id": "acme",
    "project_id": "people",
    "space_id": "dev",
    "name": "Development",
    "description": "Where a definition is written and first run against the acme database",
    "email": "people-ops@acme.example"
  },
  {
    "org_id": "acme",
    "project_id": "people",
    "space_id": "staging",
    "name": "Staging",
    "description": "Where a definition is proven before it reaches prod",
    "email": "people-ops@acme.example"
  },
  {
    "org_id": "acme",
    "project_id": "people",
    "space_id": "prod",
    "name": "Production",
    "description": "What Acme's own applications call",
    "email": "people-ops@acme.example"
  }
]
http
HTTP/1.1 201 Created
Content-Type: application/json

[
  {"space_id": "dev", "project_id": "people", "name": "Development", "owner_id": "acme-admin"},
  {"space_id": "staging", "project_id": "people", "name": "Staging", "owner_id": "acme-admin"},
  {"space_id": "prod", "project_id": "people", "name": "Production", "owner_id": "acme-admin"}
]

Every object in that array is written on its own — there is no transaction across them, so if the third entry had failed, the first two would already be stored and the response would be a single error covering only the failure. owner_id is one field you cannot send at all: ownership is always taken from X-User-Id, and a body that tries to set it is refused with 400 before anything is written. name must be unique within the project, and space_id may be chosen yourself, as Acme did, or left for the server to generate.

Ownership is what makes the two listing routes different. The plain listing —

http
GET /minimal/system/api/v1/space HTTP/1.1
Host: api.acme.example
X-Org-Id: acme
X-Project-Id: people
X-User-Id: acme-admin
X-User-Roles: hr-admin

— returns only the spaces the calling user owns, and needs a role in project_read_roles. A second caller who holds a read role but created none of Acme's three spaces gets back an empty array from this same route, not an error and not the three spaces above. The project-wide view —

http
GET /minimal/system/api/v1/space/all HTTP/1.1
Host: api.acme.example
X-Org-Id: acme
X-Project-Id: people
X-User-Id: acme-admin
X-User-Roles: hr-admin

— returns every space in the project regardless of who owns it, but it needs project_write_roles, not project_read_roles: seeing across owners is a step up from reading your own workspace, not a variant of the same privilege.

Updating a space also takes an array, and it always writes all three editable fields at once — name, description, email — there is no partial update:

http
PUT /minimal/system/api/v1/space HTTP/1.1
Host: api.acme.example
X-Org-Id: acme
X-Project-Id: people
X-User-Id: acme-admin
X-User-Roles: hr-admin
Content-Type: application/json

[
  {
    "org_id": "acme",
    "project_id": "people",
    "space_id": "dev",
    "name": "Development",
    "description": "Where a definition is written and first run, and for iteration afterward",
    "email": "people-ops@acme.example"
  }
]
http
HTTP/1.1 200 OK
Content-Type: application/json

{"rows_affected": 1}

rows_affected counts the objects the request carried, not the rows that actually changed — updating a space_id that does not exist still answers 200 with a positive count, so read the space back if you need to confirm the write actually landed. owner_id is refused here too, for the same reason it is refused at creation: a space's owner never moves once set. Unlike the plain listing, this route needs no ownership check to write — any caller holding the project's write role can update any space in the project, including one somebody else created.

Features

A feature is a smaller, project-scoped resource that shares almost all of a space's plumbing — ownership, the own-vs-all listing split, history — but names something a project offers rather than an environment it runs in. Acme uses features to track what its people portal actually exposes: directory, leave, payroll.

Creating and updating both take a JSON array, the same convention spaces use, even for a single feature:

http
POST /minimal/system/api/v1/feature HTTP/1.1
Host: api.acme.example
X-Org-Id: acme
X-Project-Id: people
X-User-Id: acme-admin
X-User-Roles: hr-admin
Content-Type: application/json

[
  {
    "org_id": "acme",
    "project_id": "people",
    "feature_id": "directory",
    "name": "Directory",
    "description": "Who works here, in which department, reporting to whom",
    "email": "people-ops@acme.example"
  }
]
http
HTTP/1.1 201 Created
Content-Type: application/json

[{"feature_id": "directory", "name": "Directory", "owner_id": "acme-admin", "project_id": "people"}]

The create response is trimmed to just those four fields — reading a feature back in full is a separate call. owner_id cannot be sent in the body at all: ownership always comes from X-User-Id, and a body that tries to set it is refused with 400, the identical rule Spaces enforces. feature_id may be chosen yourself, as directory is here, or left for the server to generate. Updating is a PUT to the same route, also array-bodied, and answers {"rows_affected": N} rather than echoing the row back — there is no route to delete a feature at all, only to retire one by updating it into disuse.

The read side splits the same way a space's does. The plain listing —

http
GET /minimal/system/api/v1/feature?ps=20&pg=0 HTTP/1.1
Host: api.acme.example
X-Org-Id: acme
X-Project-Id: people
X-User-Id: acme-admin
X-User-Roles: hr-admin

— returns only the features the calling user owns, and needs project_read_roles. The project-wide view, GET v1/feature/all, returns every feature regardless of owner, and needs project_write_roles instead — deliberately the write role, the same reasoning Spaces' project- wide listing uses: seeing across owners is a step up from reading your own workspace.

A feature's history follows the same pattern project and space history do — "Reading history," next — with feature_id as its own required query parameter:

http
GET /minimal/system/api/v1/feature/history?feature_id=payroll&ps=10&pg=0&format=csv HTTP/1.1
Host: api.acme.example
X-Org-Id: acme
X-Project-Id: people
X-User-Id: acme-admin
X-User-Roles: hr-admin
http
HTTP/1.1 200 OK
Content-Type: text/csv

name,op_ts,feature_id,owner_id,description,email,org_id,touched_by,touched_at,op_kind,project_id
Payroll,2026-09-05T17:17:34Z,payroll,acme-admin,"Gross-to-net, salary bands, hikes and the payroll register",payroll@acme.example,acme,acme-admin,2026-09-05T17:17:34Z,UPDATE,people

Reading history

Both a project and a space keep an archived history of every write against them, newest first, on a route named .../history alongside the resource itself. A project's history takes ps/pg like any other list — omit either and the answer is 406, not an unpaged dump:

http
GET /minimal/system/api/v1/project/history HTTP/1.1
Host: api.acme.example
X-Org-Id: acme
X-Project-Id: people
X-User-Id: acme-admin
X-User-Roles: hr-admin
http
HTTP/1.1 406 Not Acceptable
Content-Type: application/json

{"code":406,"status":"Not Acceptable","message":"GET request must use PAGINATION ","details":"pagination is done with 'ps' (page size) and 'pg' (page number) keywords e.g. ps=10&pg=1 (will get 10 rows for second page); page number starts with zero"}

A space's history additionally requires space_id as its own query parameter, sent alongside the usual headers rather than in place of them, and checked before pagination is — omit it and the answer is 400 {"code":400,"status":"Bad Request","message":"space_id is required"}, even when ps/pg are both present.

Every history route also accepts the format parameter Auto API's row reads do — csv, xml, yaml, or bson, alongside the JSON default:

http
GET /minimal/system/api/v1/project/history?ps=10&pg=0&format=yaml HTTP/1.1
Host: api.acme.example
X-Org-Id: acme
X-Project-Id: people
X-User-Id: acme-admin
X-User-Roles: hr-admin

answers 200 with the same rows YAML-encoded instead of JSON.

What each level puts out of reach

The diagram earlier draws the line concretely, and the routes above are what enforce it.

Organization-wide data — the org's own role lists and its database-DDL role lists — governs or exists across every project the organization holds.

Project-scoped data — a project's own role lists, its database registrations, its permission templates, its table locks — is shared by every space beneath that project. Because a permission template is assigned per table at the project level, a definition running in dev and a definition running in staging are checked against the very same template whenever they touch the same table.

Space-scoped data is the narrowest of the three: a Custom API definition and a module belong to the one space they were written in, which is exactly what lets the same path and method exist independently, at independent versions, in dev and in staging at once — and what makes a definition written and tried in dev, then proven in staging, something you promote into prod only once staging has confirmed it.

Exercises

  1. Read your organization's record once with a role that appears in its org_read_roles, and once with a role that appears in neither org_read_roles nor org_write_roles. You should get a 200 and a 403. Now send the same read against an organization id that does not exist at all. Explain, in one sentence, what a caller can and cannot tell apart between the 403 and the 404.

  2. Create three spaces — dev, staging, prod — under one project, in a single call. List them with the plain space route and with the project-wide route, both as the caller who created them. Then repeat both listings as a second caller who holds the project's write role but created none of the three spaces. Explain why the two routes disagree for the second caller but agree for the first.

  3. Update your organization's org_write_roles to a value that does not include any role your own caller currently holds. Attempt a second update immediately afterward, using that same caller. Explain what happens, and why creating an organization protects a creator from this outcome while updating one deliberately does not. (Try this against a disposable test organization, not one you rely on — updating a role list this way is not reversible from inside the API itself.)

4Credentials and Roles

Every request you've made so far has carried five headers: X-Org-Id, X-Project-Id, X-Space-Id, X-User-Id, and X-User-Roles. Minimal reads all five on every call and uses them to decide two separate questions: who is asking, and what are they allowed to do. Typing all five by hand on every request works, but it means passing a project's and a space's raw identity around wherever a client runs. An access token collapses the five headers into one.

An access token is a single string. Present it — as a header or as a query parameter — and Minimal resolves it back into the same five values it would have read from the headers, then continues exactly as if you had sent them. Nothing about what happens after that resolution changes. A token is an alias for an identity, never a bigger grant than the identity already had.

Minting an access token

You mint a token by calling the token route with your identity headers, plus a body describing the token you want:

http
POST /minimal/system/api/v1/access/token HTTP/1.1
Host: api.acme.example
X-Org-Id: acme
X-Project-Id: people
X-Space-Id: dev
X-User-Id: hr-admin@acme.example
X-User-Roles: hr-admin
Content-Type: application/json

{
  "name": "reporting-readonly",
  "description": "Read-only token for the monthly headcount report",
  "roles": "hr-admin",
  "key_type": "-r--",
  "token_type": "user"
}
http
HTTP/1.1 201 Created
Content-Type: application/json

{
  "access_token": "mus:<70 URL-safe characters>",
  "created_at": "2026-09-10T17:29:04Z",
  "description": "Read-only token for the monthly headcount report",
  "expires_at": null,
  "key_type": "-r--",
  "name": "reporting-readonly",
  "org_id": "acme",
  "owner_id": "hr-admin@acme.example",
  "project_id": "people",
  "roles": "hr-admin",
  "space_id": "dev",
  "status": "active",
  "tags": "",
  "token_id": "01HZY2Q9M4K7T5V8W1X3Y6Z0A2",
  "token_type": "us"
}

access_token is the credential itself. You can read it back later, in full, through the token's own detail read or through your own listing of your tokens. The one place it is left out is the listing that spans every owner in the project — seeing that somebody else holds a token and being able to spend it are separate grants.

token_id is how you address this token later — to read it back, to amend its description, to delete it. owner_id, org_id, project_id and space_id come from your identity headers, not from the body; you cannot mint a token bound to a project you weren't already calling as, and you cannot mint one owned by somebody else.

roles has to be a non-empty subset of the X-User-Roles you sent — a caller holding hr-admin can mint a token carrying hr-admin, but never a role it does not hold itself, and never an empty list: minting the token above with roles left out, or set to "", is refused with 400. No role column is consulted to decide whether you may mint at all — the roles-subset rule is the only bound. Acme's hr-analyst role holds no place in project_write_roles and cannot write a single row through Auto API, but it can still mint itself a token carrying hr-analyst, because minting checks only that the token asks for nothing beyond what the caller already holds, not whether the caller can do anything else:

http
POST /minimal/system/api/v1/access/token HTTP/1.1
Host: api.acme.example
X-Org-Id: acme
X-Project-Id: people
X-Space-Id: dev
X-User-Id: acme-analyst
X-User-Roles: hr-analyst
Content-Type: application/json

{
  "name": "analyst-own-token",
  "description": "hr-analyst minting for itself",
  "roles": "hr-analyst",
  "key_type": "-r--",
  "token_type": "user"
}
http
HTTP/1.1 201 Created
Content-Type: application/json

{"token_id": "01HZY2R1N5L8U6W2Y4Z7A9B1C3", "access_token": "mus:<70 URL-safe characters>", "roles": "hr-analyst", "key_type": "-r--", "status": "active"}

What key_type controls

key_type is four characters, always in the same order — create, read, update, delete:

position letter grants
1 c POST
2 r GET
3 u PUT and PATCH
4 d DELETE

A dash in a position means the token cannot do that. -r-- is read-only. crud can do everything the identity behind it is otherwise allowed to do. ---- is refused outright when you try to mint it — a token that grants nothing is not a token worth having.

This is fixed for the token's life. roles, key_type, token_type and the expiry cannot be changed after minting — an amend call that sends any of them back is refused with 400, naming the field. What you can change later is the descriptive material: name, description, tags, and whether the token is suspended.

Amending is a PATCH to the same route, naming the token by token_id:

http
PATCH /minimal/system/api/v1/access/token HTTP/1.1
Host: api.acme.example
LB-Access-Token: <your-access-token>
Content-Type: application/json

{
  "token_id": "01HZY2Q9M4K7T5V8W1X3Y6Z0A2",
  "roles": "hr-admin,payroll-clerk"
}
http
HTTP/1.1 400 Bad Request
Content-Type: application/json

{"code": 400, "message": "field cannot be amended after minting", "data": "roles"}

If a token needs different roles or different verbs, you mint a new one and delete the old. Suspending a token is the fast way to cut off a credential without hunting down its id and deleting it outright: set status to suspended through the same route, and the token authenticates nothing until somebody sets it back to active. A suspended token still counts against however many tokens its owner is allowed to hold, so suspension is a pause, not a way to make room for a new one — deletion is permanent, and a deleted token cannot be brought back.

token_type says what kind of caller is presenting the token, and decides which of three channels the request is treated as arriving on:

token_type channel
user, reviewer, anyone direct
mcp agent
m2m, cron, daemon machine-to-machine

A table's permission template answers the "who may read or write this table" question separately for each of the three channels, so the same table can be open to a person and closed to every agent — Acme's salary table is set up exactly that way. How a template decides that is Chapter 6's subject; token_type is simply what tells Minimal which of the three answers to consult. A token of type anyone may only ever carry key_type -r-- — a token meant for anonymous-style reads cannot also be given write access.

Presenting the token

Send the token as LB-Access-Token, or as a ?lb-access-token= query parameter — the two are equivalent, and either replaces all five identity headers:

http
GET /minimal/api/rest/auto/v1/acme/people/pg/acme/employee?ps=3&pg=0&oy=id.as HTTP/1.1
Host: api.acme.example
LB-Access-Token: mus:<the token from the response above>
http
HTTP/1.1 200 OK
Content-Type: application/json

[
  {"id": 1, "full_name": "Rafael Delacroix", "email": "rafael.delacroix1@acme.example", "department_id": 11, "manager_id": null, "title": "Chief Executive", "hired_on": "2021-01-04T00:00:00Z", "left_on": null, "phone": null, "is_active": true},
  {"id": 2, "full_name": "Rafael Müller", "email": "rafael.muller2@acme.example", "department_id": 7, "manager_id": 1, "title": "Manager", "hired_on": "2021-05-20T00:00:00Z", "left_on": null, "phone": "+44 20 7695 8653", "is_active": true},
  {"id": 3, "full_name": "Rafael Novak", "email": "rafael.novak3@acme.example", "department_id": 8, "manager_id": 1, "title": "Director", "hired_on": "2022-04-05T00:00:00Z", "left_on": null, "phone": "+44 20 7811 4273", "is_active": true}
]

The response looks exactly like a call made with the five headers directly, because as far as the rest of the request is concerned, it was.

Finding a token later

The project-wide listing from "Minting an access token" — every token in the project, across every owner, access_token itself always omitted — takes five optional filters beyond the usual paging, so an administrator can narrow it instead of paging through everything by hand:

http
GET /minimal/system/api/v1/access/token/all?ps=20&pg=0&tag=reporting HTTP/1.1
Host: api.acme.example
X-Org-Id: acme
X-Project-Id: people
X-User-Id: acme-admin
X-User-Roles: hr-admin

tag=reporting matches a tag exactly; q=analyst searches free text across a token's name and description instead. expiring_in=45 lists tokens whose expires_at falls within the next 45 days — the reminder before something quietly stops working. unused_for=30 lists tokens whose last_used_at is older than 30 days, or that have never been used at all — the candidates for deletion nobody has touched to check first. status=active (or suspended) narrows to one lifecycle state. Any of the five can be combined with the others and with paging; none needs a role beyond the org_write_roles the listing itself already requires.

No credential, or a bad one

Send neither the five headers nor a token, and Minimal refuses before it looks at anything else:

http
HTTP/1.1 400 Bad Request
Content-Type: application/json

{
  "code": 400,
  "message": "missing required header",
  "data": ["X-Org-Id", "X-Project-Id", "X-Space-Id", "X-User-Id", "X-User-Roles"]
}

Send a token that doesn't resolve — malformed, unknown, or simply made up — and the answer is 401, not 400. The distinction matters: 400 means you told Minimal nothing about who you are; 401 means you told it something, and that something didn't check out.

http
HTTP/1.1 401 Unauthorized
Content-Type: application/json

{
  "code": 401,
  "message": "access token is not valid for this request",
  "data": "malformed access token"
}

A token that resolves fine, but whose key_type doesn't cover the method you used, is refused with the same 401 status and a different reason:

http
HTTP/1.1 401 Unauthorized
Content-Type: application/json

{
  "code": 401,
  "message": "access token is not valid for this request",
  "data": "this token's key_type does not permit this HTTP method"
}

Every one of these is decided before anything about roles or tables is consulted. An identity that resolves cleanly still has to clear the role check next, and that check answers with 403, not 401 — by then Minimal knows exactly who is asking. 401 means the credential itself is the problem; 403 means the credential is fine and the identity behind it isn't allowed to do this.

Roles: project and organization

X-User-Roles (or the roles a token carries) is a comma-separated list of plain names — hr-admin, payroll-clerk, whatever an organization has chosen to call them. Minimal never interprets these names; it only ever compares them, as a set, against a role column a route names. Almost every gated route works the same way: it names one column, and you need at least one role in common with it.

Most of what you do day to day is gated at the project level, through three columns — one per verb: project_read_roles, project_write_roles, and project_delete_roles. Defining or assigning a permission template, and rebuilding a project's schema index, both check the caller's roles against project_write_roles. Minting a token and listing your own tokens are the exception — neither consults a role column at all, so a caller holding no project role can still do both, bounded only by the roles-subset rule shown above. A project can, and Acme's does, set the three columns differently: Acme's payroll-clerk role holds a place in project_read_roles and project_write_roles but not in project_delete_roles, so a caller holding only that role can read and write freely and is refused every delete on that surface.

A smaller set of things is gated at the organization level instead, through org_read_roles and org_write_roles. Reading the organization's own record needs a role in org_read_roles; changing it needs one in org_write_roles, and the two need not be the same set — Acme's hr-analyst role can read the organization record and cannot update it. The same pair also gates a project-spanning admin surface that reaches past any one caller's own data: listing every access token in a project, across every owner and every space, needs org_write_roles, not an ordinary project role. That is deliberate — it is the same role that lets an administrator delete somebody else's token, so nobody can discover a token through this surface that they could not also revoke, even though the surface never returns the token string itself.

Where the organization-level model goes from here — how it interacts with auditing, and the full shape of who can see what — belongs to a later, more advanced chapter. Project roles and organization roles are two different checks, gating two different classes of route, and holding one says nothing about the other.

Ask for something your roles don't cover, and you get 403 naming the shortfall:

http
HTTP/1.1 403 Forbidden
Content-Type: application/json

{
  "code": 403,
  "status": "Forbidden",
  "message": "caller does not hold a role permitted to manage this project's permission templates"
}

A table's own permission template runs the identical check one layer down, against the roles the template names for the channel you're calling on:

http
HTTP/1.1 403 Forbidden
Content-Type: application/json

{
  "code": 403,
  "status": "Forbidden",
  "message": "insufficient permissions for this operation on this table, got [guest-role]"
}

The mechanism is the same top to bottom — a route names a role column, your roles are compared against it — whether that column belongs to a project, an organization, or one table's template.

A narrowly scoped token, worked

Put the pieces together: mint a token that can only read, and confirm both halves of that claim — what it can do, and what it refuses.

bash
curl -X POST https://api.acme.example/minimal/system/api/v1/access/token \
  -H "X-Org-Id: acme" -H "X-Project-Id: people" -H "X-Space-Id: dev" \
  -H "X-User-Id: hr-admin@acme.example" -H "X-User-Roles: hr-admin" \
  -H "Content-Type: application/json" \
  -d '{"name":"readonly-demo","description":"read only","roles":"hr-admin","key_type":"-r--","token_type":"user"}'

Capture access_token from the response, then use it for a read — it works, exactly as in "Presenting the token" above. Now try a write with the same token:

bash
curl -X POST https://api.acme.example/minimal/api/rest/auto/v1/acme/people/pg/acme/employee \
  -H "LB-Access-Token: mus:<the readonly-demo token>" \
  -H "Content-Type: application/json" \
  -d '{"full_name":"New Hire","email":"new.hire@acme.example","department_id":7,"title":"Analyst","hired_on":"2026-09-10","is_active":true}'
http
HTTP/1.1 401 Unauthorized
Content-Type: application/json

{
  "code": 401,
  "message": "access token is not valid for this request",
  "data": "this token's key_type does not permit this HTTP method"
}

The token never reaches a role check at all here — key_type refuses POST before Minimal asks who hr-admin is or what it may touch. Mint a second token with key_type "crud" instead, and the same POST gets past this layer; whether it then succeeds depends on whether hr-admin is one of the roles the employee table's template lists for creates. Two separate gates, two separate failure modes, and a caller reading the status code can always tell which one stopped it.

The request's path

sequenceDiagram
    participant C as Caller
    participant M as Minimal

    C->>M: Request + LB-Access-Token (or the 5 identity headers)
    alt no credential at all
        M-->>C: 400 missing required header
    else token malformed or unresolvable
        M-->>C: 401 access token is not valid for this request
    end
    M->>M: Resolve credential -> org, project, space, user, roles
    alt key_type does not permit this HTTP method
        M-->>C: 401 access token is not valid for this request
    end
    M->>M: Compare caller's roles against the role column this route reads
    alt no role in common
        M-->>C: 403 insufficient permissions
    else role matches
        M-->>C: 200 (or 201/204) + result
    end

A request's path from credential to answer: resolve the identity, check the verb the credential allows, then check the roles that identity holds against the column the route consults — refusing at the first check that fails.

Exercises

  1. Mint two access tokens for a role you hold, identical except for key_type: one -r--, one crud. Use the -r-- token to read a table, then attempt a write with it and record the exact status and message you get back. Repeat the write with the crud token, sending every mandatory column the table needs, and confirm it gets past the verb check — then check whether it also gets past the role check, and explain, from the response alone, which of the two layers answered.

  2. Mint a token, then try to widen it: PATCH the token route and send a roles value that adds a role you did not include at mint time. Record the status code and the field the refusal names. Then mint a fresh token with the wider roles value instead, and explain in one sentence why the amend route and the mint route disagree about whether this is allowed.

  3. Using only the five identity headers (no token), make one call with a role you know is not in a table's permission template for the verb you're using, and one call with a role that is. Compare the two responses — status code, message, and whether data names the table or the role — and write down what a caller can and cannot learn about a permission template from the refusal alone.

5Auto API

Every table your project has cataloged can be read and written over HTTP without writing a single line of server code. This is the Auto API: one route per verb, the table named directly in the path, no definition, no handler, no business logic standing between the caller and the row. If a definition is a function you write, the Auto API is the table itself, addressed.

It reads and writes exactly one table per call. There are no joins, no subqueries, no aggregate functions. What you get back is filtered, ordered, paged and grouped, but it always comes from a single SELECT, INSERT, UPDATE or DELETE against one object. A view or a materialized view works the same way — the join, if there is one, was already built into the view when the database was set up.

Addressing a table

The path names five things, in order:

/minimal/api/rest/auto/v1/{org}/{project}/{type}/{schema}/{table}
Segment Meaning
org the organization's abbreviation, lower case
project the project's abbreviation, lower case
type the two-letter code for the kind of database
schema the name the database was registered under in this project
table the table, view or materialized view to act on

type is one of pg (PostgreSQL), ch (ClickHouse), ms (MySQL) or ma (MariaDB) on a deployment that serves them; or (Oracle), ss (SQL Server) and md (MongoDB) are recognized codes but not yet served. schema is the database's registered name, not a namespace inside it — the same value the database-management routes call the database name.

For an organization acme with a project people that registered its primary PostgreSQL database as acme, the employee table is:

/minimal/api/rest/auto/v1/acme/people/pg/acme/employee

A full read looks like this:

http
GET /minimal/api/rest/auto/v1/acme/people/pg/acme/employee?ps=3&pg=0&oy=id.as HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-Space-Id: dev
X-User-Id: acme-admin
X-User-Roles: hr-admin
json
[
  {
    "id": 1,
    "full_name": "Rafael Delacroix",
    "email": "rafael.delacroix1@acme.example",
    "department_id": 11,
    "manager_id": null,
    "title": "Chief Executive",
    "hired_on": "2021-01-04T00:00:00Z",
    "left_on": null,
    "phone": null,
    "is_active": true
  },
  {
    "id": 2,
    "full_name": "Rafael Müller",
    "email": "rafael.muller2@acme.example",
    "department_id": 7,
    "manager_id": 1,
    "title": "Manager",
    "hired_on": "2021-05-20T00:00:00Z",
    "left_on": null,
    "phone": "+44 20 7695 8653",
    "is_active": true
  },
  {
    "id": 3,
    "full_name": "Rafael Novak",
    "email": "rafael.novak3@acme.example",
    "department_id": 8,
    "manager_id": 1,
    "title": "Director",
    "hired_on": "2022-04-05T00:00:00Z",
    "left_on": null,
    "phone": "+44 20 7811 4273",
    "is_active": true
  }
]

The five identity headers (X-Org-Id, X-Project-Id, X-Space-Id, X-User-Id, X-User-Roles) are how every Auto API call authenticates; LB-Access-Token is the alternative to all five at once, sent either as a header or as ?lb-access-token=. X-Space-Id is required by this API surface even though nothing about a table read depends on the space. The org and project path segments must be the lower-cased abbreviations the identity headers resolve to — a mismatch is refused with 400.

How access is decided

The project role columns you may already know from provisioning a project play no part here. A table's access is decided per table, per operation and per caller channel — direct, agent, or machine-to-machine, the same three channels a token's token_type decides in Chapter 4 — by two things: the table's lock mask, and the permission template assigned to it.

flowchart TD
    A[Request for org/project/type/schema/table] --> B{Table cataloged<br/>and visible?}
    B -- no --> P[412 - table not found]
    B -- yes --> C{Lock mask allows<br/>this operation<br/>on this channel?}
    C -- no --> F[403 - blocked by lock]
    C -- yes --> D{Permission template's<br/>role list grants<br/>this operation to<br/>one of the caller's roles?}
    D -- no --> G[403 - insufficient permissions]
    D -- yes --> E[Query is built and run]

What decides a 403 on a table: cataloged and visible first, then the lock, then the caller's roles against the template — each gate can stop the request before the next is ever checked.

A 403 does not say which of the two refused you. Chapter 6 covers permission templates and table locks in full — how a template's role lists grant read, insert, update and delete per role and per channel, and how a lock overrides all of it regardless of role. One example, reading the salary table with a role its template grants nothing:

http
GET /minimal/api/rest/auto/v1/acme/people/pg/acme/salary?ps=5&pg=0 HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-Space-Id: dev
X-User-Id: acme-guest
X-User-Roles: guest
json
{
  "code": 403,
  "status": "Forbidden",
  "message": "insufficient permissions for this operation on this table, got [guest]"
}

A table can also be row-scoped, so that a caller only ever sees and writes their own rows, through a header named for that table's scope. Advanced Minimal, Chapter 4 covers it in full.

A table marked not visible answers exactly as a table that was never cataloged does — a 412 either way. That is deliberate: nothing in the status, message or body lets a caller tell a withheld table from an absent one.

Reading rows

One route serves the whole query surface: column selection, filtering, ordering, grouping, having, and four output formats besides JSON.

Paging is mandatory

ps (page size) and pg (page number, from zero) must both be sent on every read. Omit either and the request is refused, not defaulted:

http
GET /minimal/api/rest/auto/v1/acme/people/pg/acme/employee HTTP/1.1
LB-Access-Token: <your-access-token>
json
{
  "code": 406,
  "status": "Not Acceptable",
  "message": "GET request must use PAGINATION ",
  "details": "pagination is done with 'ps' (page size) and 'pg' (page number) keywords e.g. ps=10&pg=1 (will get 10 rows for second page); page number starts with zero"
}

The deployment caps page size (100 unless configured otherwise) and silently reduces a larger request. A page shorter than the one you asked for does not by itself mean you have reached the end — it can just mean the cap applied. Keep reading pages until one comes back [].

There is no default ordering. Without oy (below), row order is whatever the database returns, which can differ between two calls for the same page — including two calls for the same page number. Paging that needs to tile cleanly across calls needs an explicit order.

Column selection: fc and df

fc is a comma-separated list of columns to return, or * for all:

GET .../employee?ps=3&pg=0&fc=id,full_name,department_id

df returns the same shape but with duplicate rows collapsed — SELECT DISTINCT over the named columns. It cannot be combined with fc; sending both fails with 417. Once df is set, any ordering (see below) must name a column that df also selects:

GET .../employee?ps=20&pg=0&df=department_id
→ [{"department_id":1},{"department_id":2}, … one row per department that has an employee]

GET .../employee?ps=20&pg=0&df=department_id&oy=full_name.as
json
{
  "code": 417,
  "status": "Expectation Failed",
  "message": "unable to perform select",
  "details": "a valid SQL statement could not be formed reason: cannot order by \"full_name\": df returns distinct rows over [department_id], and \"full_name\" is not among them -- add it to df, or drop df"
}

The reason is mechanical: full_name was already collapsed away by the time an order could be applied to it, so there is nothing left to sort by.

Ordering

oy is a comma-separated list of column.direction pairs, as for ascending or ds for descending. A bare column name sorts ascending:

GET .../employee?ps=10&pg=0&oy=full_name.as
GET .../salary?ps=10&pg=0&oy=effective_on.ds
GET .../salary?ps=10&pg=0&oy=employee_id.as,effective_on.ds

~rand orders randomly instead of naming a column, and cannot be combined with df (there is nothing meaningful about a random order over rows that have already been collapsed).

Filters and their operators

Any query parameter that is not one of the reserved keywords above (ps, pg, fc, df, oy, gy, hv, uf, format, or) is read as a filter on the column of that name, written operator.value:

GET .../leave_request?ps=20&pg=0&status=eq.approved

A misspelled or nonexistent column is not ignored — it fails as an unknown column:

GET .../employee?ps=10&pg=0&no_such_column=eq.1
json
{
  "code": 417,
  "status": "Expectation Failed",
  "message": "unable to perform select",
  "details": "a valid SQL statement could not be formed reason: unknown column \"no_such_column\""
}

Nineteen operators are available. Any other word in the operator position is refused rather than ignored, so a typo fails loudly instead of silently matching nothing.

Operator Meaning Example
eq equals status=eq.approved
ne not equal status=ne.rejected
lt le gt ge less than, less-or-equal, greater than, greater-or-equal department_id=gt.6
bw between two values id=bw.1.25
li contains full_name=li.a
rli starts with full_name=rli.M
lli ends with email=lli.example
nli nrli nlli negations of the three above full_name=nli.a
il case-insensitive contains full_name=il.MARGARET
mt regular-expression match, always case-insensitive full_name=mt.^m
in nin in / not in a comma-separated list department_id=in.1,2,3
is nis IS / IS NOT a fixed word manager_id=is.NULL

Multiple filters combine with AND. Repeat a parameter, or use commas, to supply several values to in/nin; the between operator always takes exactly two: id=bw.0.10.

is and nis take a fixed word, not a value. is accepts NULL, NOT NULL, TRUE, NOT TRUE, FALSE, NOT FALSE, UNKNOWN and NOT UNKNOWN. nis already means IS NOT, so it takes only the four un-negated words: NULL, TRUE, FALSE, UNKNOWN. These two filters are equivalent —

manager_id=nis.null
manager_id=is.NOT NULL

— and the first spells it without a literal space in the query string. Sending nis.NOT NULL is refused, because nis is already the negation and does not take an already-negated word:

json
{
  "code": 417,
  "status": "Expectation Failed",
  "message": "unable to perform select",
  "details": "a valid SQL statement could not be formed reason: nis accepts only one of [FALSE, NULL, TRUE, UNKNOWN], got \"NOT NULL\""
}

mt is a regular-expression match, not a full-text search, and it is always case-insensitive. It needs no index.

GET .../employee?ps=20&pg=0&full_name=mt.^k
→ every employee whose name starts with K or k: Kwame Haddad, Kwame Rossi, Kwame Nakamura, …

Alternation works the same as anywhere else:

full_name=mt.smith|jones

il folds case only up to the first dot in the value you send, not the whole value, while comparing against a column that has itself been lower-cased. Write the whole pattern in lower case unless you mean for the part after the first dot to be matched exactly:

email=il.rafael.delacroix     → matches rafael.delacroix1@acme.example
email=il.rafael.DELACROIX     → matches nothing at all

This only bites on a value that itself contains a dot — an email address is the obvious case.

No filter value may carry a bare SQL statement keyword. SELECT, UNION, DROP and the rest of the usual list are refused with 400 when they appear as a whole word:

GET .../employee?ps=20&pg=0&full_name=eq.SELECT me
json
{
  "code": 400,
  "status": "Bad Request",
  "message": "the value for \"full_name\" carries the SQL keyword \"SELECT\", which is not permitted in a filter value"
}

A keyword inside a longer word is fine — full_name=li.selected is accepted, because selected is not the word SELECT.

A semicolon in a value has to be percent-encoded as %3B. A raw semicolon is not a valid separator in a query string, and the parameter carrying it would otherwise be silently discarded — which would answer a wider result set than you asked for, with nothing in the response to say so. Rather than risk that, the whole request is refused:

GET .../employee?ps=20&pg=0&full_name=eq.Rafael;Delacroix
json
{
  "code": 400,
  "status": "Bad Request",
  "message": "the query string is malformed and at least one parameter was discarded (invalid semicolon separator in query); if a value needs to carry a ';', percent-encode it as %3B"
}

Encoded, the same character is just data:

GET .../employee?ps=20&pg=0&full_name=li.%3B    →   [] (no name contains a semicolon)

A handful of names are reserved by the filter grammar itself and cannot be used as a filter parameter even on a table that genuinely has a column of that name: nt, or, ad, all, any, alongside the reserved keywords above. all and nt are not implemented operators either, so using them in the operator position fails the same way an unknown operator does:

GET .../employee?ps=20&pg=0&all=eq.5
json
{
  "code": 417,
  "status": "Expectation Failed",
  "message": "unable to perform select",
  "details": "a valid SQL statement could not be formed reason: \"all\" is a reserved query keyword and cannot be used as a column filter"
}

The nineteen operator names themselves are reserved the same way, whether or not the operator they name is one this API implements — a column genuinely called eq is just as unreachable as one called all:

GET .../employee?ps=20&pg=0&eq=x
json
{
  "code": 417,
  "status": "Expectation Failed",
  "message": "unable to perform select",
  "details": "a valid SQL statement could not be formed reason: \"eq\" is a reserved query keyword and cannot be used as a column filter"
}

Combining filters: or

Ordinary filters AND together. To OR a group of conditions, use or=(...):

GET .../employee?ps=20&pg=0&or=(department_id.eq.1,department_id.eq.2)&full_name=rli.M

The group is parenthesized and AND-ed with everything else on the request, so this reads: department 1 or 2, and the name starts with M. There is one group per request, groups cannot be nested, and the list operators in/nin cannot be used inside one — their own commas would be indistinguishable from the group's own separators. Every condition inside the group is checked exactly as a top-level filter is, is/nis included.

Grouping and having

gy groups by a comma-separated column list — pair it with an fc naming the same columns, or different databases disagree about what an ungrouped column in the result means. hv filters on the grouped result, in the same column.operator.value form as an ordinary filter, and only means anything alongside gy:

GET .../employee?ps=20&pg=0&fc=department_id&gy=department_id&hv=department_id.gt.10
→ [{"department_id":11}]

hv supplies one column and one value, so bw — the operator that needs two — is refused there by name, not silently truncated.

Views and materialized views

A view reads like any other table once cataloged, filters and ordering included. A materialized view is different: its columns are not known to the column checks, so anything that names a column explicitly — fc, a filter, oy — fails with an unknown-column 417, even for a column that is genuinely there and comes back in the row. Read it with fc=* and no ordering, and take the row order the database gives you:

GET .../mv_headcount_by_department?ps=20&pg=0&fc=*     → every column, every row, database order
GET .../mv_headcount_by_department?ps=20&pg=0&oy=headcount.ds
json
{
  "code": 417,
  "status": "Expectation Failed",
  "message": "unable to perform select",
  "details": "a valid SQL statement could not be formed reason: unknown column \"headcount\""
}

Reading unmerged ClickHouse rows

On ClickHouse, a table whose storage engine merges rows in the background — replacing, summing, collapsing, coalescing — reads in its fully merged form by default. uf=false skips that and reads every stored row instead, superseded ones included, which is what you want when reconciling raw history rather than current state. Sent against a non-merging table or a non-ClickHouse database it does nothing; sent on a write it is refused with 400 outright, because no write statement accepts a merge-skipping read.

Writing rows

Insert, update and delete are three separate routes, on the same path as the read.

Insert

http
POST /minimal/api/rest/auto/v1/acme/people/pg/acme/department HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-Space-Id: dev
X-User-Id: acme-admin
X-User-Roles: hr-admin
Content-Type: application/json

[
  {"id": null, "name": "Customer Success", "cost_centre": "CC-9400", "head_email": "head.cs@acme.example", "created_at": "2026-09-10T00:00:00Z"}
]
json
{"rows_affected": 1}

201 Created.

The body is always a JSON array, even for one row — an object at the top level is refused. One call can insert many rows at once, and on every database except ClickHouse they share a single transaction: if one row in the batch fails, none of them lands. PUT and DELETE need no equivalent note — each runs as one statement against a filter, already atomic the same way, on every database except ClickHouse, which has no transaction concept at all.

null for a generated key is how you let the database assign it. The column still has to be present in the body — leaving it out entirely is refused as a missing required field — and null is what asks for the default:

json
[
  {
    "id": null,
    "name": "Book Exercise A",
    "cost_centre": "CC-9101",
    "head_email": "a@acme.example",
    "created_at": "2026-09-10T00:00:00Z"
  },
  {
    "id": null,
    "name": "Book Exercise B",
    "cost_centre": "CC-9102",
    "head_email": "b@acme.example",
    "created_at": "2026-09-10T00:00:00Z"
  }
]
json
{"rows_affected": 2}

The same rule applies to any mandatory column with a database default.

An unknown column in the body is refused, and uf — the ClickHouse merge-skipping flag from the read route — is refused outright here rather than silently ignored.

Update

An update has no body. What to set and which rows to touch both travel as query parameters:

http
PUT /minimal/api/rest/auto/v1/acme/people/pg/acme/department?set=cost_centre.CC-9150&name=eq.Field%20Sales HTTP/1.1
LB-Access-Token: <your-access-token>
json
{"rows_affected": 1}

set is a comma-separated list of column.value pairs — everything after the first dot is the value, so a value containing dots is safe. A value beginning with ~ is sent to the database as an expression rather than a literal — set=created_at.~NOW() — so treat that form as privileged. Every other query parameter that is a column name is a row filter, in the same operator.value form the read route uses.

An update with no filter is refused, so a whole table cannot be rewritten by accident:

PUT .../department?set=cost_centre.CC-0000
json
{
  "code": 424,
  "status": "Failed Dependency",
  "message": "unable to perform update",
  "details": "blanket updated detected, rejecting request"
}

The paging and column-selection keywords (ps, pg, fc, df, oy, gy, hv, uf) are not merely ignored on this route — sending any of them fails the request with 400.

Delete

Also no body; the filter travels in the query string, and is mandatory for the same reason an update's is:

http
DELETE /minimal/api/rest/auto/v1/acme/people/pg/acme/department?cost_centre=in.CC-9101,CC-9102 HTTP/1.1
LB-Access-Token: <your-access-token>
json
{"rows_affected": 2}

Unlike update, the read route's other reserved keywords do not fail a delete request — they are silently accepted and ignored, so sending ps and pg here will not stop the delete from running against every matching row. There is no recovery route: a row removed this way is gone.

Output formats

Every route — read, insert, update and delete alike — takes format: json (the default), xml, csv, yaml (or yml), or bson. A read returns the rows; a write returns the same rows_affected count, just re-encoded:

GET .../department?ps=2&pg=0&format=xml&fc=id,name
xml
<?xml version="1.0" encoding="UTF-8"?><rows><row><id>1</id><name>Engineering</name></row><row><id>2</id><name>Platform</name></row></rows>

Once fc selects columns, json, yaml and csv list them in a fixed alphabetical order regardless of the order you wrote them in — fc=name,id comes back id before name all the same:

GET .../department?ps=2&pg=0&format=yaml&fc=name,id
yaml
- id: 1
  name: Engineering
- id: 2
  name: Platform

xml's element order inside a <row> is not fixed the same way — two calls for the same row can come back with its fields in a different order. Parse XML by element name, never by position.

POST .../department?format=xml
xml
<?xml version="1.0" encoding="UTF-8"?><rows_affected>1</rows_affected>

An empty JSON or YAML read is the same thing either format normally uses for an empty list: json answers the two characters [], and yaml answers [] followed by a newline. xml wraps nothing between its <rows></rows> tags. csv answers a genuinely empty body — no header line, because there are no returned rows to name the columns from. Choose your format with that difference in mind if your client tells "no rows" apart from "no response" by body length.

bson answers binary/octet-stream — build a client that decodes it rather than reading it by eye.

The same four operations through MCP

Everything above is also four MCP tools, one per verb: auto_read_rows, auto_insert_rows, auto_update_rows, auto_delete_rows. Each one is a thin wrapper on the exact route just described — same table, same filter grammar, same nineteen operators, same paging cap — with the organization and project taken from your MCP credential rather than passed as arguments.

auto_read_rows takes type, schema, table, page_size, page_number, and an optional query object carrying filters and the fc/df/oy/gy/hv/uf/format/or options by the same names — as object values rather than a raw query string, so page_size/page_number are separate arguments and sending ps/pg inside query is refused:

json
{
  "jsonrpc": "2.0",
  "id": 42,
  "method": "tools/call",
  "params": {
    "name": "auto_read_rows",
    "arguments": {
      "type": "pg",
      "schema": "acme",
      "table": "employee",
      "page_size": 5,
      "page_number": 0,
      "query": { "department_id": "eq.1", "oy": "hired_on.ds" }
    }
  }
}

(LB-Access-Token and X-Session-Id are required on this call too, per Chapter 7 — omitted here to keep the arguments in focus.)

What comes back is the API's own JSON, as the tool result's text content:

json
{
  "jsonrpc": "2.0",
  "id": 42,
  "result": {
    "content": [
      { "type": "text", "text": "[{\"department_id\":1,\"email\":\"diego.lindqvist85@acme.example\",\"full_name\":\"Diego Lindqvist\",\"hired_on\":\"2024-11-06T00:00:00Z\",\"id\":85,\"is_active\":true,\"left_on\":null,\"manager_id\":38,\"phone\":\"+44 20 7925 8418\",\"title\":\"Analyst\"}]" }
    ]
  }
}

auto_insert_rows takes rows instead of a body array; auto_update_rows takes set and query instead of query-string parameters, and refuses a call that also sends ps/pg/fc and the rest inside query; auto_delete_rows takes just query. A refusal from the underlying API — a missing filter, an unknown column, an insufficient role — comes back as an isError result carrying that same status and message, not a JSON-RPC error. Calling auto_delete_rows with no query at all:

json
{
  "jsonrpc": "2.0",
  "id": 43,
  "result": {
    "isError": true,
    "content": [
      { "type": "text", "text": "{\"code\":417,\"status\":\"Expectation Failed\",\"message\":\"unable to perform delete\",\"details\":\"blanket delete detected, rejecting request\"}" }
    ]
  }
}

Only a tool name that does not exist at all is a JSON-RPC-level error instead.

Exercises

  1. Page through a filtered, ordered read. Using the employee table, find every employee in one department hired in the last two years, newest first, five rows per page. Fetch page 0, then page 1, and confirm the second page is either smaller than five rows or comes back [] — either way, you have reached the end.

  2. Build an or group. Find every employee in either of two departments whose name starts with the same letter, ordered by name. Do it as one request with a single or=(...) group combined with an rli filter, not as two separate requests you merge yourself.

  3. Round-trip a write. Insert two new rows into department in a single call, letting the database assign both ids with null. Confirm both rows exist and note the ids you got back. Rename one of them with a PUT and set=. Then try to update the whole table with no filter at all and confirm you get refused — read the status code and message before you delete both rows you created, by id, in one DELETE.

  4. Compare formats. Read the department table as CSV, selecting only id and name, ordered by name. Read the same query as YAML. Then filter for an id you know does not exist and read that as CSV and as JSON — write down exactly what comes back in each case, byte for byte.

6Table Security, the Essentials

Send the same DELETE from the same caller against two different tables in Acme's people database, and you can get two refusals that read identically:

json
{
  "code": 403,
  "status": "Forbidden",
  "message": "insufficient permissions for this operation on this table, got [hr-admin]"
}

On one table, that message means hr-admin is not on the list of roles allowed to delete. On the other, hr-admin is on that list — deletes are just switched off, for everyone, whoever they are. Minimal keeps those as two entirely separate gates, and it does not tell a caller which one just stopped them. Knowing that is the difference between reassigning a template and hunting for a lock nobody remembers setting.

Every indexed table carries three gates. A permission template answers who may act on a table. A lock answers whether anyone may, at all, independent of who they are. Visibility answers whether the table is offered to a caller in the first place. This chapter introduces them in the order you configure them day to day — a template first, since assigning one is the first thing a new table needs — rather than the order Minimal checks them in, which the closing section restates. Narrowing a table to only the rows one caller owns, and applying any of this to many tables in a single call, are Advanced Minimal, Chapter 4's territory — this chapter works one table at a time, which is already enough to run a real deployment.

Permission templates

A permission template is a named, reusable access policy. You create it once in a project and assign as many tables to it as you like; change the template, and every table assigned to it changes with it. A template carries twelve role lists: four verbs — create, read, update, delete — across three channels. auto_api_* governs a caller arriving directly over REST, mcp_* governs an agent calling through an MCP session, and m2m_* governs a machine-to-machine caller such as a scheduled job or another backend service. Each of the twelve fields is a comma-separated list of role names, and an empty list means nobody on that verb and that channel — not everybody.

Acme's directory template is typical: HR writes, a wider set of roles read.

http
POST /minimal/system/api/v1/permission/template HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-User-Id: acme-admin
X-User-Roles: hr-admin
Content-Type: application/json

{
  "name": "acme-directory",
  "description": "Who works here: readable widely, written by HR only",
  "auto_api_create_roles": "hr-admin",
  "auto_api_read_roles": "contractor,hr-admin,hr-analyst,payroll-clerk",
  "auto_api_update_roles": "hr-admin",
  "auto_api_delete_roles": "hr-admin",
  "mcp_create_roles": "hr-admin",
  "mcp_read_roles": "hr-admin,hr-analyst",
  "mcp_update_roles": "hr-admin",
  "mcp_delete_roles": "hr-admin",
  "m2m_create_roles": "hr-admin",
  "m2m_read_roles": "hr-admin,hr-analyst",
  "m2m_update_roles": "hr-admin",
  "m2m_delete_roles": "hr-admin"
}
json
{
  "org_id": "acme",
  "project_id": "people",
  "name": "acme-directory",
  "template_id": "01M1S96P76FQDPNYBV1RFSY8KH"
}

Keep template_id — it is how every later call addresses this template. Creating and managing templates needs a role in the project's write-role list; there is no separate read-only tier for templates, because a template describes a table's security posture, and Minimal treats looking at that as sensitive as changing it.

A name must be unique in the project, but a role configuration does not have to be — except that Minimal refuses to create a second template whose twelve role lists exactly match an existing one:

json
{
  "code": 409,
  "status": "Conflict",
  "message": "a similar permission template already exists",
  "details": "template_id=01M1S96P8445XSX1E88FMZZCQJ name=acme-reference has the same role configuration; set override_similar_template_warning=true to create anyway"
}

Near-duplicate templates are the everyday failure mode once a project has thirty of them, and the check exists to stop you making one by accident; set override_similar_template_warning to true in the create body to make one anyway.

Listing templates pages newest first, and an empty page answers 204 with no body, not 200 with an empty array:

http
GET /minimal/system/api/v1/permission/template/all?ps=20&pg=5 HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-User-Id: acme-admin
X-User-Roles: hr-admin
http
HTTP/1.1 204 No Content

The same listing takes format=yaml alongside its JSON default:

http
GET /minimal/system/api/v1/permission/template/all?ps=20&pg=0&format=yaml HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-User-Id: acme-admin
X-User-Roles: hr-admin
yaml
- auto_api_create_roles: hr-admin
  auto_api_read_roles: hr-admin
  auto_api_update_roles: hr-admin
  auto_api_delete_roles: hr-admin
  name: acme-directory
  org_id: acme
  project_id: people
  template_id: 01M1S96P76FQDPNYBV1RFSY8KH
  ...

Read a template back by id, or by name:

http
GET /minimal/system/api/v1/permission/template?template_id=01M1S96P76FQDPNYBV1RFSY8KH HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-User-Id: acme-admin
X-User-Roles: hr-admin
json
{
  "org_id": "acme",
  "project_id": "people",
  "name": "acme-directory",
  "template_id": "01M1S96P76FQDPNYBV1RFSY8KH",
  "description": "Who works here: readable widely, written by HR only",
  "auto_api_create_roles": "hr-admin",
  "auto_api_read_roles": "contractor,hr-admin,hr-analyst,payroll-clerk",
  "auto_api_update_roles": "hr-admin",
  "auto_api_delete_roles": "hr-admin",
  "mcp_create_roles": "hr-admin",
  "mcp_read_roles": "hr-admin,hr-analyst",
  "mcp_update_roles": "hr-admin",
  "mcp_delete_roles": "hr-admin",
  "m2m_create_roles": "hr-admin",
  "m2m_read_roles": "hr-admin,hr-analyst",
  "m2m_update_roles": "hr-admin",
  "m2m_delete_roles": "hr-admin",
  "custom_api_create_roles": null,
  "custom_api_read_roles": null,
  "custom_api_update_roles": null,
  "custom_api_delete_roles": null,
  "touched_by": "acme-admin",
  "created_at": "2026-09-05T17:17:34.312259Z",
  "updated_at": "2026-09-05T17:17:34.415542Z"
}

GET .../permission/template?name=acme-directory with the same headers returns the identical object — id and name both resolve to the same row. The four custom_api_* fields always come back null: they are reserved on the row for a future channel and read by no check yet, so leave them out of a create body and ignore them on a read. A template by itself governs nothing, though — it has to be assigned to a table.

Assigning a template to a table

http
PUT /minimal/system/api/v1/project/table/permission HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-User-Id: acme-admin
X-User-Roles: hr-admin
Content-Type: application/json

{
  "db_type": "postgres",
  "schema": "acme",
  "table": "department",
  "template_id": "01M1S96P76FQDPNYBV1RFSY8KH"
}
json
{
  "org_id": "acme",
  "project_id": "people",
  "db_type": "postgres",
  "schema": "acme",
  "table": "department",
  "template_id": "01M1S96P76FQDPNYBV1RFSY8KH"
}

The table has to already be indexed. Assigning a template to a table Minimal has never cataloged answers 404, not 400. That is a distinct 404 from the one you get when template_id itself does not name a real template. The two messages differ, so a caller can tell which side of the request was wrong.

The assignment takes effect immediately. Reading it back gives you the whole governance picture for the table in one call, template and lock together — "Table locks," next, explains every lock field in full; for now, notice only permission_template_id, which names what you just assigned:

http
GET /minimal/system/api/v1/project/table/permission?db_type=postgres&schema=acme&table=department HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-User-Id: acme-admin
X-User-Roles: hr-admin
json
{
  "table_name": "department",
  "permission_template_id": "01M1S96P76FQDPNYBV1RFSY8KH",
  "permission_template_name": "acme-directory",
  "lock_mask": 3840,
  "table_create_locked": false, "table_read_locked": false,
  "table_update_locked": false, "table_delete_locked": false,
  "table_locked_ops": "",
  "mcp_create_locked": false, "mcp_read_locked": false,
  "mcp_update_locked": false, "mcp_delete_locked": false, "mcp_locked_ops": "",
  "m2m_create_locked": true, "m2m_read_locked": true,
  "m2m_update_locked": true, "m2m_delete_locked": true, "m2m_locked_ops": "create,read,update,delete"
}

(trimmed of the repeated org_id, project_id and timestamp fields for space.)

The same read takes format=yaml, the identical ?db_type=...&schema=...&table=... query with &format=yaml appended, for a caller who wants the governance picture rendered that way instead.

Notice m2m in that response: every operation on that channel is locked already, independent of anything this chapter has done to the direct channel. The three channels are set separately — closing or opening one never touches the other two — so a table can be wide open to a direct caller and completely closed to a machine-to-machine one at the same time, exactly as department is here.

Clearing an assignment

Clearing is not the same operation as locking, and the difference matters enough to see once:

http
DELETE /minimal/system/api/v1/project/table/permission?db_type=postgres&schema=acme&table=department HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-User-Id: acme-admin
X-User-Roles: hr-admin

That returns 200 with an empty body — no confirmation of what, if anything, changed. Read the table's permission back and permission_template_id is now an empty string. With no template assigned and nothing locked, the table is not closed — it is wide open, to every role, on every channel its lock mask does not itself shut. A caller holding a role that has never appeared in any of Acme's templates can now read it:

http
GET /minimal/api/rest/auto/v1/acme/people/pg/acme/department?ps=2&pg=0 HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-Space-Id: dev
X-User-Id: rando-caller
X-User-Roles: totally-unrelated-role
json
[{"id":1,"name":"Engineering","cost_centre":"CC-1000", ...}]

That succeeds with 200, roles and all. Reassigning acme-directory closes it back down. The lesson is the one the opening of this chapter promised: a template says who; an absent template says everyone; a lock is the only thing that ever says no one at all.

Table locks

A lock is the second gate, and it is independent of whatever template a table carries. A table can be assigned the most permissive template in the project and still be locked shut — the lock is checked first, and a locked operation is refused for every caller regardless of role. Locks mirror a template's shape exactly: four verbs across three channels, twelve boolean toggles. The naming differs in one place: a template's direct-REST column is auto_api_*, a lock's is table_* — same channel, different prefix, on the two APIs that describe it.

A lock write is differential: you send only the toggles you want to change, and everything you omit keeps its stored value. Sending no toggles at all is refused rather than treated as a no-op, so a lock can never be cleared by accident through an empty body. The body is also strict — the opposite of the permission-assignment route above, which drops a misspelled field silently. A lock route rejects any key it does not recognize:

http
PUT /minimal/system/api/v1/project/table/lock HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-User-Id: acme-admin
X-User-Roles: hr-admin
Content-Type: application/json

{"db_type": "postgres", "schema": "acme", "table": "department", "tabel_delete_locked": true}

That typo — tabel_delete_locked for table_delete_locked — answers 400. Nothing is applied: a body carrying even one key the route does not recognize is rejected whole, not filtered down to the toggles it does recognize. On the assignment route, the same typo is silently ignored instead — the two routes look alike and behave oppositely.

One toggle overrides the other two. table_delete_locked blocks delete on every channel a caller might arrive by — direct, agent, or machine-to-machine — with no per-channel toggle able to reopen it. mcp_delete_locked and m2m_delete_locked only take effect when the table-level toggle for that same verb is clear; they exist to close one channel narrowly while leaving the others alone, such as shutting every agent channel on a table while its direct API keeps working.

Locking department's agent channel alone, leaving direct and machine-to-machine untouched, shows the narrow form:

http
PUT /minimal/system/api/v1/project/table/lock HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-User-Id: acme-admin
X-User-Roles: hr-admin
Content-Type: application/json

{"db_type": "postgres", "schema": "acme", "table": "department", "mcp_read_locked": true}
json
{
  "table_name": "department",
  "table_read_locked": false, "table_locked_ops": "",
  "mcp_read_locked": true, "mcp_locked_ops": "read",
  "m2m_read_locked": true, "m2m_locked_ops": "create,read,update,delete"
}

(trimmed of every other unchanged toggle and the identity/timestamp fields, for space.)

A direct read against the same table, same caller, is untouched:

http
GET /minimal/api/rest/auto/v1/acme/people/pg/acme/department?ps=2&pg=0 HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-Space-Id: dev
X-User-Id: acme-admin
X-User-Roles: hr-admin
json
[{"id":1,"name":"Engineering","cost_centre":"CC-1000", ...}]

200, same as before the lock — the toggle only closed the agent channel's read, not the direct one. Unlocking it again is the identical write with "mcp_read_locked": false.

Reading a table's lock status decodes the raw stored number into exactly these named booleans, plus a <channel>_fully_locked summary and a <channel>_locked_ops list of just the verbs currently locked on that channel — the same field names the write route accepts, so a read can be edited and sent straight back. It takes format=csv too, for a caller who wants the decoded state in a spreadsheet rather than JSON:

http
GET /minimal/system/api/v1/project/table/lock/status?db_type=postgres&schema=acme&table=department&format=csv HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-User-Id: acme-admin
X-User-Roles: hr-admin
http
HTTP/1.1 200 OK
Content-Type: text/csv

table_name,table_read_locked,table_delete_locked,mcp_fully_locked,m2m_fully_locked,lock_mask,...
department,false,false,false,true,3840,...

Column order in the CSV is map-iteration order and is not stable across requests — a caller parsing it should read by header name, never by position.

Listing every table's permission at once

A project can hold dozens of tables, each with its own template and lock state. Rather than reading them one at a time, one route lists every table's combined governance picture in a single page, narrowed by whichever of five filters apply:

http
GET /minimal/system/api/v1/project/table/permission/all?ps=20&pg=0&locked=table:read HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-User-Id: acme-admin
X-User-Roles: hr-admin

template_id=<id> narrows to tables carrying one specific template. locked=<channel>:<verb> and its opposite, unlocked=<channel>:<verb>, take one of exactly three channel names — table, mcp, or m2m (never direct, even though that is the word this book otherwise uses for the same channel) — paired with one of create, read, update, delete. Several verbs can share one group with | between them (table:read|update), and several groups can be ANDed together with a comma. locked= matches a table where any listed verb in the group is locked; unlocked= matches only where none of them is — the two are not simple opposites of each other. A malformed channel is refused rather than matching nothing:

json
{
  "code": 400,
  "status": "Bad Request",
  "message": "unknown lock channel \"direct\" -- expected table, mcp or m2m"
}

The remaining filters are plain booleans, one query parameter per toggle: table_delete_locked=true, mcp_fully_locked=false, m2m_read_locked=true, and their siblings for every verb and channel — table_fully_locked, mcp_fully_locked and m2m_fully_locked test the whole channel's four-bit mask at once rather than one verb.

flowchart TD
    A["Call reaches a table"] --> B{"Table visible?"}
    B -- "no" --> B1["Answered as uncataloged —\nno data, absent from listings"]
    B -- "yes" --> C{"Locked for this verb\non the direct channel?"}
    C -- "yes" --> C1["Refused, 403 —\nno channel can override this"]
    C -- "no" --> D{"Locked for this verb\non the caller's own channel\n(agent or machine-to-machine)?"}
    D -- "yes" --> D1["Refused, 403"]
    D -- "no" --> E{"Table carries a\npermission template?"}
    E -- "no" --> F1["Allowed — no template\nmeans open to every role"]
    E -- "yes" --> G{"Caller's role in that\nverb's list for this channel?"}
    G -- "yes" --> F1
    G -- "no" --> D1

For any direct, agent or machine-to-machine call: visibility is checked first, then the lock mask, then the permission template — and the two 403s at the end are worded identically, which is why the lock-status read above exists.

Worked example: locking deletes on department

department currently carries no lock at the table level — table_locked_ops reads "". HR wants to stop deletes on it outright while leaving reads, creates and updates exactly as they were:

http
PUT /minimal/system/api/v1/project/table/lock HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-User-Id: acme-admin
X-User-Roles: hr-admin
Content-Type: application/json

{"db_type": "postgres", "schema": "acme", "table": "department", "table_delete_locked": true}
json
{
  "table_name": "department",
  "table_create_locked": false, "table_read_locked": false,
  "table_update_locked": false, "table_delete_locked": true,
  "table_fully_locked": false, "table_locked_ops": "delete",
  "mcp_create_locked": false, "mcp_read_locked": false,
  "mcp_update_locked": false, "mcp_delete_locked": false,
  "mcp_fully_locked": false, "mcp_locked_ops": "",
  "m2m_create_locked": true, "m2m_read_locked": true,
  "m2m_update_locked": true, "m2m_delete_locked": true,
  "m2m_fully_locked": true, "m2m_locked_ops": "create,read,update,delete",
  "permission_template_id": "01M1S96P76FQDPNYBV1RFSY8KH"
}

(trimmed of org_id, project_id, db_type, db_name, table_type, lock_mask and updated_at for space — the same kind of fields elided from the combined read earlier in this chapter.)

The response is the whole resulting state, in exactly the shape a lock-status read returns — the only way to know the outcome of a differential write, since it cannot be derived from what you sent. table_locked_ops now reads "delete", and mcp and m2m are exactly as they were before this call: locking one table-channel toggle does not touch the other channels. A read still succeeds, untouched by the lock:

http
GET /minimal/api/rest/auto/v1/acme/people/pg/acme/department?ps=2&pg=0 HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-Space-Id: dev
X-User-Id: acme-admin
X-User-Roles: hr-admin
json
[{"id":1,"name":"Engineering","cost_centre":"CC-1000", ...}]

A delete, on the same table, from the same caller:

http
DELETE /minimal/api/rest/auto/v1/acme/people/pg/acme/department?id=eq.999999 HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-Space-Id: dev
X-User-Id: acme-admin
X-User-Roles: hr-admin
json
{
  "code": 403,
  "status": "Forbidden",
  "message": "insufficient permissions for this operation on this table, got [hr-admin]"
}

Refused — even though hr-admin sits on acme-directory's auto_api_delete_roles list, and would be allowed to delete if the lock were not there. The message reads exactly like a role refusal; it is not. This is the pairing from the start of the chapter, now reproduced: the only way to tell the two apart from outside is to read the lock status back, see table_delete_locked: true, and know the template was never even consulted. Unlocking follows the identical shape with the toggle set false.

The same write, as an agent would send it over MCP, looks like this — same table, same toggle, same differential contract:

json
{
  "name": "set_table_lock_mask",
  "arguments": {
    "db_type": "postgres",
    "schema": "acme",
    "table": "department",
    "table_delete_locked": true
  }
}

The tool answers with the same resulting-state object the REST route returns, as its text content — an agent reads table_locked_ops back exactly as a REST caller does.

Visibility: hiding a table from callers

A template and a lock both assume the caller can see the table. Visibility is the gate in front of both: it decides whether a cataloged table is offered to anyone at all. A table marked not visible disappears from the table listing and stops answering through the Auto API — every read of it behaves exactly as if it had never been indexed. Its catalog row, its permission template and its lock mask are untouched underneath; hiding a table removes it from view, it does not erase its governance.

http
PUT /minimal/system/api/v1/project/table/visibility HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-User-Id: acme-admin
X-User-Roles: hr-admin
Content-Type: application/json

{"db_type": "postgres", "schema": "acme", "table": "audit_note", "is_visible": false}
json
{
  "org_id": "acme",
  "project_id": "people",
  "db_type": "postgres",
  "db_name": "acme",
  "table_name": "audit_note",
  "is_visible": 0
}

is_visible is required — there is no default, because defaulting it either way would change a table's visibility silently. The response echoes it back as 1 or 0, not true/false — a client expecting a boolean has to convert it. audit_note is now gone from the project's table listing:

http
GET /minimal/rest/meta/v1/table/list?type=pg&schema=acme HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-User-Id: acme-admin
X-User-Roles: hr-admin
json
[
  "employee",
  "hike",
  "v_employee_directory",
  "v_leave_balance",
  "mv_headcount_by_department",
  "attendance",
  "department",
  "leave_request",
  "salary",
  "holiday"
]

And the Auto API answers as though it had never heard of the table:

http
GET /minimal/api/rest/auto/v1/acme/people/pg/acme/audit_note?ps=2&pg=0 HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-Space-Id: dev
X-User-Id: acme-admin
X-User-Roles: hr-admin
json
{
  "code": 412,
  "status": "Precondition Failed",
  "message": "unable to get table info",
  "details": "no table, view, or materialized view named \"audit_note\" found in schema \"acme\""
}

Nothing in that response distinguishes a withheld table from one that was never indexed — that is deliberate, so a caller cannot use a telling error message to sort names into the two. The same rule covers the governance reads from earlier in this chapter: a lock-status or permission read on a withheld table also answers 404, identically to an unindexed one.

Writes are the one exception. Assigning a template or setting a lock still reaches a hidden table, so a table hidden by mistake can still be governed. It can also be found again, without knowing its name in advance:

http
GET /minimal/system/api/v1/project/table/hidden?ps=20&pg=0 HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-User-Id: acme-admin
X-User-Roles: hr-admin
json
[
  {
    "db_name": "acme",
    "db_type": "postgres",
    "table_name": "audit_note",
    "table_type": "TABLE",
    "updated_at": "2026-09-11T05:40:42.419425Z"
  }
]

That route is itself gated on the write role: only whoever may restore a table may learn it is missing. Restoring uses the same route as hiding, with the toggle reversed:

http
PUT /minimal/system/api/v1/project/table/visibility HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-User-Id: acme-admin
X-User-Roles: hr-admin
Content-Type: application/json

{"db_type": "postgres", "schema": "acme", "table": "audit_note", "is_visible": true}

Reindexing the database does not undo a hidden table's status either way — visibility is its own switch, independent of the catalog scan that discovered the table in the first place.

Put the three gates together and a single sentence covers this chapter: visibility decides whether the table exists to ask about, a lock decides whether the operation runs at all, and a template decides which roles may run it once the first two have let the call through.

Exercises

  1. Create a permission template that lets hr-analyst read the leave_request table but grants no role create, update or delete rights on any channel. Assign it, then prove with a live request that a read succeeds and an update is refused — and read the refusal's role list back to confirm it names hr-analyst, not a lock.

  2. Lock reads on one table for the mcp channel only, leaving its direct REST channel open. Prove both halves: a direct GET against the table still returns rows, while the same read attempted as an MCP tool call is refused. Then undo just that one toggle.

  3. Hide a table other than the ones this chapter's examples used, confirm through the Auto API that it now answers as not-found, then find it again through the hidden-tables listing — without relying on already knowing its name — and restore it.

7Talking to Minimal as an Agent

Chapter 2 talked to Minimal the direct way: you built an HTTP request by hand, set the headers Minimal's REST API expects, and read the JSON that came back. That is one channel into Minimal. This chapter opens the other one — the Model Context Protocol, or MCP — which exists so that a program built around a language model can find Minimal's operations and call them without writing a single line of request-building code.

What MCP is

Here is what that other channel looks like, before any of its vocabulary gets defined. This is start_ai_session, the one call every MCP conversation with Minimal opens with, sent as a JSON-RPC request over plain HTTP, with the _meta block a conforming MCP client attaches to every call:

http
POST /minimal/mcp/rpc/v1 HTTP/1.1
Host: mcp.acme.example
Content-Type: application/json
Accept: application/json, text/event-stream
LB-Access-Token: <your-access-token>
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: start_ai_session
json
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientInfo": { "name": "your-client", "version": "1" },
      "io.modelcontextprotocol/clientCapabilities": {}
    },
    "name": "start_ai_session",
    "arguments": {
      "name": "Chapter 7 walkthrough",
      "context": "Reading and writing Acme's data over MCP"
    }
  }
}

Minimal answers with the session as text content. The field to keep is session_id:

json
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "content": [
      {
        "type": "text",
        "text": "{\"name\":\"Chapter 7 walkthrough\",\"org_id\":\"acme\",\"project_id\":\"people\",\"session_id\":\"01M20681V68Y5421QC5N23F9DB\",\"user_id\":\"mcp:acme-agent\"}"
      }
    ]
  }
}

That one exchange carries every idea this chapter needs. The request names a method — tools/call — and inside params, a tool by name plus an arguments object shaped to match what that tool expects: here, name, a human-readable label for the session, and context, what the session is for, both required. Nowhere does the request name a route or a verb the way Chapter 2's did. The reply is not the tool speaking in its own words — it is Minimal's envelope, a result holding content, one block of it typed text, holding the tool's actual answer as a JSON string packed inside a JSON string.

This is MCP: a protocol for letting an agent discover an API's operations as named, schema-described tools and call them by sending an arguments object that matches the schema, rather than by building a request by hand the way Chapter 2 did. The agent never has to know a route exists as a route; it only has to know the tool's name and what it takes. Minimal serves this over one endpoint, POST /minimal/mcp/rpc/v1, listening on its own port, separate from the REST API you used in Chapter 2. A second route on that same port, GET /minimal/mcp/ops/v1/health, answers whether the listener itself is up — no credential, no session, nothing but {"status":"ok","listener": "mcp"} — the liveness check a deployment probes on a schedule, distinct from ping, which needs a working credential and proves more than the listener merely answering.

The access token you send still does all the work it did in Chapter 2 — it is what carries your organization, project, space, user and roles, in the same LB-Access-Token header — but this endpoint asks two more things of it than the REST API did. Its owner has to be an MCP identity, not an ordinary user, and MCP has to be switched on for its space. A token minted for the wrong kind of owner, or aimed at a space nobody has opened, is refused before any tool runs, with a message saying which of the two is the problem.

Discovering the catalog

Before an agent can send a request shaped like the one above, it has to know start_ai_session exists and what arguments it takes. tools/list is the JSON-RPC method that answers that question. It takes no arguments, and every tool it lists comes back in the same shape. Here is one full entry from a real answer, trimmed to a single tool out of the many tools/list actually returns:

json
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "resultType": "complete",
    "_meta": { "io.modelcontextprotocol/serverInfo": { "name": "minimal", "version": "0.0.1" } },
    "ttlMs": 60000,
    "cacheScope": "private",
    "tools": [
      {
        "annotations": {
          "destructiveHint": false,
          "idempotentHint": false,
          "openWorldHint": false,
          "readOnlyHint": false
        },
        "description": "Answers pong, echoing the optional note. Exists to prove this MCP endpoint is reachable and the caller's credential works; its handler touches no store and no tenant data, though like every tool call it is recorded to the session and audited.",
        "inputSchema": {
          "additionalProperties": false,
          "properties": {
            "note": { "description": "echoed back verbatim in the result", "type": "string" }
          },
          "type": "object"
        },
        "name": "ping"
      }
    ]
  }
}

The envelope carries a little more than the tools themselves — resultType, a cache hint (ttlMs, cacheScope) telling a client how long it may hold this list before asking again, and the server's own _meta block — none of which this chapter has reason to touch again. Every entry inside tools carries name, the value a caller sends as Mcp-Name and again as name inside params on a tools/call; description, written for a model to read, not a person; inputSchema, a JSON Schema object an arguments value has to satisfy; and annotations, a small object of hints about the tool's behavior rather than a contract — four of them on every tool, though only two ever vary. destructiveHint is true on a tool that overwrites or removes, false on one that only adds or reads, and an agent deciding whether to ask you before calling a tool can read it straight off this list instead of guessing from the tool's name. openWorldHint is true only on the handful of tools that dial a live address the caller names in the call itself, rather than one already on file for the project — you will not meet one of those in this book. idempotentHint and readOnlyHint never vary: both come back false on every tool here — readOnlyHint deliberately so, since a call under a session still adds to it even when the tool itself only reads, and calling that side-effect-free would be misleading.

A tool's own entry can also be fetched one at a time, without paging through the whole catalog:

json
{"name": "help", "arguments": {"tool": "ping"}}

The result is the identical object tools/list served above, as help's own text content rather than wrapped inside a list. Asked for a name that names nothing, help refuses rather than answering empty-handed — {"tool": "not_a_real_tool"} comes back isError: true, text reading no tool is named not_a_real_tool; tools/list carries every name this server serves, a pointer back to the one call that actually knows every name. Called with no arguments at all, help answers instead with the server's standing instructions — how sessions work, which header to send, worked examples — the same text a fourth method carries:

json
{
  "jsonrpc": "2.0",
  "id": 0,
  "method": "server/discover",
  "params": {}
}
json
{
  "jsonrpc": "2.0",
  "id": 0,
  "result": {
    "supportedVersions": [
      "2026-07-28"
    ],
    "instructions": "<how sessions work, the header to send, worked examples>"
  }
}

server/discover also names the one protocol revision the server speaks (supportedVersions). An agent calls it once, at the start of a conversation, to learn that Minimal has sessions at all, before it ever calls tools/list.

Tools, not routes

ping, above, is typical of the catalog in one respect and atypical in another. Typical, because almost every tool in Minimal's MCP catalog is a thin wrapper around one REST route, built to take the same identity, hit the same permission checks, and hand back the same answer: auto_read_rows is the tool form of GET /minimal/api/rest/auto/v1/{org}/{project}/{type}/{schema}/{table}; auto_insert_rows is the POST on that same path; meta_list_tables is the tool form of listing a project's cataloged tables. The mapping is close enough that once you know a route, you can usually guess the tool's name and read its schema to confirm the rest. Atypical, because ping wraps no route at all — it exists only inside MCP, to let an agent check its credential works before it spends a real call on anything. The catalog on Minimal's MCP endpoint currently lists 115 such tools.

A tool can also narrow what its underlying route accepts, rather than matching it exactly. update_project's schema has no database field at all, even though the REST route it calls does — changing a project's registered databases through MCP would mean resending every already-registered connection's real password along with the new one, and nothing on this surface holds those in the clear to resend. The route stays reachable over REST for a caller who holds the unmasked read; the tool simply does not offer the field.

"Almost every" carries real weight, because one tool does not map to one route at all. call_api is the MCP door onto Custom API: instead of minting a new tool for every definition a project's authors write, Minimal exposes one tool that takes a definition's uri, method and version and relays whatever that definition answers — Advanced Minimal, Chapter 1 shows it in full. A hundred Custom API definitions are a hundred routes and one tool. Everywhere else, the "one tool per route, roughly" rule holds.

Three tools govern discovery rather than data, and an agent that has never touched a project before runs them in order: meta_list_databases lists the databases the project has registered, each as a type (the two-letter database-type code — pg, ms, ma, ch) and a schema (the name the database was registered under); meta_list_tables takes that pair and lists the tables cataloged under it; meta_describe_table takes a table name from that list and answers with its columns, so a caller writing to a table for the first time knows which columns exist and which the database fills in on its own. Only after that does an agent reach for auto_read_rows, auto_insert_rows, auto_update_rows or auto_delete_rows — the same four verbs Auto API offers over HTTP, one tool each. You will call the first two of those yourself, against real data, later in this chapter.

The session you cannot skip

Here is the one convention that catches every new caller: almost every tool needs a session, and a session has to be opened before anything else runs — the start_ai_session exchange that opened this chapter is not an example shown only for illustration; it is the first call of every conversation an agent has with Minimal over MCP.

A session groups a whole conversation's tool calls together: each call is recorded into it as a turn before the tool runs and again once it answers, so the record of what an agent did outlives the connection that did it. Two tools are the exception and need no session at all: start_ai_session itself, because that is how a caller with no session gets one, and help, which only reads back the server's own instructions or one tool's entry and touches no project data. Every other tool — including ping, shown above — refuses a call that carries no X-Session-Id.

The two turns a call writes into its session — one before the tool runs, one once it answers — share a single id minted for that call, so a later reader of the session can pair a call with its result without guessing from their order. This is also why a very large read is worth paging down rather than widening: a page big enough to overflow the deployment's own body limit fails to record into the session, and Minimal withholds its result along with it. Ask for a modest page_size and read on until a page comes back empty, rather than reaching for the biggest one the schema allows.

Recording happens before the tool even runs — the arguments turn is written first, the schema checked after. That ordering is why some tools structurally refuse to take a real secret as an argument at all, rather than accepting one and merely promising to keep it out of the transcript: a credential sent as an argument would already be written into the session by the time a schema check could reject the call, so the tool omits the field from its schema entirely instead. A few tools whose calls genuinely cannot avoid carrying a secret — minting an access token, submitting a Custom API definition with a live database password — take the opposite approach and record both of their turns sealed, encrypted under the platform's own key, unreadable later even by the session's own owner (Advanced Minimal, Chapter 8, names the full set and explains what sealing does and does not protect).

A session belongs to whoever opened it, and reading one that belongs to somebody else answers "not found" — the identical response a session id that was never minted at all gets. There is no route on this surface that can be used to learn whether a particular session exists without already being its owner.

Every call after start_ai_session carries X-Session-Id: 01M20681V68Y5421QC5N23F9DB, the id from the reply at the top of this chapter. Note what was absent from the arguments on that opening call: no organization, no project, no space. Those come from the access token in LB-Access-Token instead; they appear on no tool's argument list anywhere in this book — a tool that accepted them as arguments would let a caller ask for somebody else's project, so Minimal never offers the option.

sequenceDiagram
    participant Agent
    participant Minimal
    Agent->>Minimal: tools/call start_ai_session {name, context}
    Minimal-->>Agent: session_id 01M20681V68Y5421QC5N23F9DB
    Agent->>Minimal: tools/call auto_read_rows (X-Session-Id: 01M2...)
    Minimal-->>Agent: rows, recorded to the session
    Agent->>Minimal: tools/call auto_insert_rows (X-Session-Id: 01M2...)
    Minimal-->>Agent: rows_affected, recorded to the session
    Agent-xMinimal: tools/call auto_read_rows (no X-Session-Id)
    Minimal--xAgent: isError — call start_ai_session first

The session lifecycle: one call to start it, the id it returns on every call after, and the one shape of call that gets refused — the one that leaves the header off.

A session id survives a restart of the platform. If Minimal's process restarts between two of your calls, the id you were handed still works; there is no re-login step to add to this chapter's checklist.

Reading and writing Acme's data over MCP

Chapter 2 read Acme's employee table over HTTP, and Chapter 5 inserted a new department row the same way. Here is the same kind of round trip, done entirely through tool calls, continuing under the session opened above — LB-Access-Token and X-Session-Id travel on every call below exactly as they did on start_ai_session, omitted from the JSON-RPC bodies here to keep the arguments in focus.

First, confirm which database, and which type code, the people project's data lives under — meta_list_databases takes no arguments at all:

json
{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "tools/call",
  "params": {
    "name": "meta_list_databases",
    "arguments": {}
  }
}
json
{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "content": [
      { "type": "text", "text": "[{\"type\":\"pg\",\"schema\":\"acme\"}]" }
    ]
  }
}

type and schema from that answer are exactly what every auto_* and meta_* tool below takes — this is the pair that Chapter 2's path segments pg/acme spelled out directly in the URL. meta_list_tables takes that same pair and lists what is cataloged under it, the tool form of the route Chapter 2 called directly:

json
{
  "name": "meta_list_tables",
  "arguments": {
    "type": "pg",
    "schema": "acme"
  }
}
json
[
  "employee",
  "department",
  "salary",
  "hike",
  "leave_request",
  "holiday",
  "attendance",
  "audit_note",
  "v_employee_directory",
  "v_leave_balance",
  "mv_headcount_by_department"
]

Now read the first five employees, in id order so the page is reproducible:

json
{
  "jsonrpc": "2.0",
  "id": 3,
  "method": "tools/call",
  "params": {
    "name": "auto_read_rows",
    "arguments": {
      "type": "pg",
      "schema": "acme",
      "table": "employee",
      "page_size": 5,
      "page_number": 0,
      "query": { "oy": "id.as" }
    }
  }
}

Minimal answers with the same JSON array Chapter 2's GET produced, wrapped as text content:

json
{
  "jsonrpc": "2.0",
  "id": 3,
  "result": {
    "content": [
      {
        "type": "text",
        "text": "[{\"department_id\":11,\"email\":\"rafael.delacroix1@acme.example\",\"full_name\":\"Rafael Delacroix\",\"hired_on\":\"2021-01-04T00:00:00Z\",\"id\":1,\"is_active\":true,\"left_on\":null,\"manager_id\":null,\"phone\":null,\"title\":\"Chief Executive\"},{\"department_id\":7,\"email\":\"rafael.muller2@acme.example\",\"full_name\":\"Rafael Müller\",\"hired_on\":\"2021-05-20T00:00:00Z\",\"id\":2,\"is_active\":true,\"left_on\":null,\"manager_id\":1,\"phone\":\"+44 20 7695 8653\",\"title\":\"Manager\"},{\"department_id\":8,\"email\":\"rafael.novak3@acme.example\",\"full_name\":\"Rafael Novak\",\"hired_on\":\"2022-04-05T00:00:00Z\",\"id\":3,\"is_active\":true,\"left_on\":null,\"manager_id\":1,\"phone\":\"+44 20 7811 4273\",\"title\":\"Director\"},{\"department_id\":6,\"email\":\"kwame.haddad4@acme.example\",\"full_name\":\"Kwame Haddad\",\"hired_on\":\"2022-02-26T00:00:00Z\",\"id\":4,\"is_active\":false,\"left_on\":\"2023-04-26T00:00:00Z\",\"manager_id\":3,\"phone\":\"+44 20 7529 0635\",\"title\":\"Senior Analyst\"},{\"department_id\":6,\"email\":\"rafael.haddad5@acme.example\",\"full_name\":\"Rafael Haddad\",\"hired_on\":\"2021-09-17T00:00:00Z\",\"id\":5,\"is_active\":true,\"left_on\":null,\"manager_id\":3,\"phone\":null,\"title\":\"Manager\"}]"
      }
    ]
  }
}

That oy entry inside query is not decoration. auto_read_rows has no default ordering, exactly like the REST route behind it — leave it out and the row order is whatever the database happens to return, which is free to differ from one call to the next.

Now add a department, the same way Chapter 5's REST example did. auto_insert_rows takes rows as an array, one object per row, even for a single insert:

json
{
  "jsonrpc": "2.0",
  "id": 4,
  "method": "tools/call",
  "params": {
    "name": "auto_insert_rows",
    "arguments": {
      "type": "pg",
      "schema": "acme",
      "table": "department",
      "rows": [
        {
          "id": null,
          "name": "Developer Relations",
          "cost_centre": "CC-1200",
          "head_email": "devrel-lead@acme.example",
          "created_at": "2026-09-10T09:00:00Z"
        }
      ]
    }
  }
}

id is not left out of that object — it is sent as null. department.id fills itself in from the database on every insert, but the column is still declared not-null, so the schema marks it mandatory: the key has to be present, and null is what tells Minimal to let the column's own default run rather than reject the row for missing a value. head_email is the one column here that really is optional and could have been left out entirely. The reply is the same shape the REST route answers with:

json
{
  "jsonrpc": "2.0",
  "id": 4,
  "result": {
    "content": [
      { "type": "text", "text": "{\"rows_affected\":1}" }
    ]
  }
}

Two tool calls, no URL built by hand, and the same rows Chapters 2 and 5 read and wrote over HTTP.

When the client does not cooperate

Everything above assumes your MCP client does what start_ai_session's own description says to do: take the session_id out of the result and carry it forward on X-Session-Id for every call after. Not every client is built that way. Some set headers once per server rather than once per call, which means there is no natural place in their configuration for a value that only exists after the first tool call returns. If every one of your tool calls is refused except start_ai_session and help, this is almost always why — the client minted the session and then never attached its id to anything. The fix is manual: start the session first, read session_id out of the reply yourself, and configure X-Session-Id with that value before sending anything else.

What a refusal looks like

Minimal's MCP endpoint says no in three different shapes, and a client that treats them alike will misread one of them. A refusal at the credential layer — no access token, or one that is malformed or expired — is an HTTP status carrying a plain error object; you never reach a tool at all. A refusal at the protocol layer — a required header missing, a body that is not valid JSON, a method this server does not recognize — is an HTTP status carrying a JSON-RPC error code, or in a few cases plain text from the transport itself rather than a JSON-RPC object. Neither of those two is what you get from a tool that ran into a rule of its own.

A tool called correctly but refused on its own terms is the third shape, and it is not a transport error at all: the JSON-RPC exchange succeeds with 200, and the refusal rides inside the result as isError: true, carrying the status and the message the underlying API gave. A role refusal looks exactly like Chapter 2's 403, just wrapped in this envelope instead of an HTTP status:

json
{
  "jsonrpc": "2.0",
  "id": 5,
  "result": {
    "isError": true,
    "content": [
      { "type": "text", "text": "{\"code\":403,\"status\":\"Forbidden\",\"message\":\"insufficient permissions for this operation on this table, got [hr-analyst]\"}" }
    ]
  }
}

Calling a tool with no X-Session-Id set, once a session exists but before you have attached it, rides the same envelope for a different reason — a result naming the tool to call first, not a dropped connection. Arguments that do not match a tool's input schema come back the same way too, as an isError result whose text opens with validating "arguments". A client that treats an isError result as success will report a write that never happened, which is why the three shapes have to stay distinct.

Exercises

  1. Open a session of your own with start_ai_session, giving it a name and context that describe what you are about to do. Use the session_id it returns to call meta_list_tables for type: "pg", schema: "acme", and confirm employee and department both appear.

  2. Using the session from Exercise 1, call auto_read_rows on department with an oy ordering on id and a small page size, then call auto_insert_rows to add one department of your own. Read the table again and confirm your new row is there — and check what its id turned out to be, since you sent that column as null.

  3. Call a data tool — auto_read_rows is fine — without setting X-Session-Id at all, even though you have a valid session open. Compare the isError result you get back to the role refusal shown above: both are isError: true, but a missing session and an insufficient role are different reasons, not the same one wearing a different envelope. Then call help with arguments: {"tool": "auto_read_rows"} and check the schema it returns against the arguments you sent in Exercise 2.

8Errors and Refusals

Send Minimal a request it cannot honor and it still answers. There is no dropped connection, no retry-and-hope. Here is a caller trying to update a space with a single object instead of the array the route expects:

http
PUT /minimal/system/api/v1/space HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-User-Id: acme-admin
X-User-Roles: hr-admin
Content-Type: application/json

{"org_id":"acme","project_id":"people","space_id":"dev","name":"Dev","description":"Engineering","email":"dev@acme.example"}
json
{
  "code": 400,
  "status": "Bad Request",
  "message": "request body is not a valid JSON Array"
}

Every refused request looks like this: a status code that means the same thing it means anywhere else on the web, and a JSON body naming what was wrong. code repeats the HTTP status as a number, and message is the sentence you read. A refusal the routing layer catches before a route even runs — no credential at all, a header missing — carries data instead of status, naming the specific thing that was missing or wrong; a refusal from the route itself carries status, its reason phrase, and sometimes details, a lower-level explanation beyond the message — the 412 and 424 section below shows one. This chapter is the vocabulary of that body: the status codes you meet doing ordinary work, what each one is telling you, and how MCP says the same things without ever failing the way an HTTP request fails.

401 and 403 — who you are, and what you may do

These two are easy to conflate and mean different things. A 401 says a credential was sent and did not check out — malformed, expired, or the wrong kind of token for this request; sending nothing at all is 400 instead, shown in the next section. A 403 says the credential checked out fine and the identity it names may not do this. One is about who you are; the other is about what you are allowed to do once that is settled. That also tells you what to do about each: a 401 means send a different credential, a 403 means the one you sent needs a different role.

A malformed access token, sent as the sole credential, answers 401:

http
GET /minimal/api/rest/auto/v1/acme/people/pg/acme/employee?ps=10&pg=0 HTTP/1.1
LB-Access-Token: <not-a-real-token>
json
{
  "code": 401,
  "data": "malformed access token",
  "message": "access token is not valid for this request"
}

A credential that resolves fine but names a role the table does not recognize answers 403. Here acme-contractor holds only the contractor role, and the table's permission template grants that role nothing on this channel:

http
GET /minimal/api/rest/auto/v1/acme/people/pg/acme/salary?ps=10&pg=0 HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-Space-Id: dev
X-User-Id: acme-contractor
X-User-Roles: contractor
json
{
  "code": 403,
  "status": "Forbidden",
  "message": "insufficient permissions for this operation on this table, got [contractor]"
}

The message echoes back the roles you sent: it tells you what the server believes you presented — useful for catching a header that was not sent as you expected.

404 — nothing here to act on

404 means the thing named in the request does not resolve — an organization, a project, a token, a session. Sending a project id nobody has registered:

http
PUT /minimal/system/api/v1/space HTTP/1.1
X-Org-Id: acme
X-Project-Id: no-such-project
X-User-Id: acme-admin
X-User-Roles: hr-admin
Content-Type: application/json

[{"org_id":"acme","project_id":"no-such-project","space_id":"dev","name":"Dev","description":"Engineering","email":"dev@acme.example"}]
json
{
  "code": 404,
  "status": "Not Found",
  "message": "project not found"
}

A request to this same route that omits X-Org-Id or X-Project-Id outright does not land here — it is refused earlier, with 400, naming which header is missing:

json
{
  "code": 400,
  "data": [
    "X-Org-Id",
    "X-Project-Id"
  ],
  "message": "missing required header"
}

404 on this route means the headers were present and still did not resolve to a real project — a narrower condition than "nothing was sent." This is straightforward here, but 404 is not always this literal — a point the last section of this chapter returns to.

409 — someone else already holds this

409 means the request is well-formed and you are allowed to make it, but it collides with something already there. Creating a permission template under a name the project already uses:

http
POST /minimal/system/api/v1/permission/template HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-User-Id: acme-admin
X-User-Roles: hr-admin
Content-Type: application/json

{"name":"acme-directory","description":"duplicate name check","auto_api_read_roles":"hr-admin"}
json
{
  "code": 409,
  "status": "Conflict",
  "message": "a permission template with this name already exists"
}

The same status also answers a template whose twelve role columns exactly duplicate an existing one (Chapter 6) — a near-duplicate is as much a collision as a repeated name, on the theory that two templates differing by one role are more likely a mistake than a deliberate choice.

412 and 424 — a dependency, not necessarily bad input

On these two routes, both codes mean the same thing: nothing wrong with the request body, but the operation could not go through anyway, because of state the request itself does not control. 412 (Precondition Failed) means something the operation depends on is not in the shape it needs to be. Reading a table that was never cataloged:

http
GET /minimal/api/rest/auto/v1/acme/people/pg/acme/no_such_table_at_all?ps=10&pg=0 HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-Space-Id: dev
X-User-Id: acme-admin
X-User-Roles: hr-admin
json
{
  "code": 412,
  "status": "Precondition Failed",
  "message": "unable to get table info",
  "details": "no table, view, or materialized view named \"no_such_table_at_all\" found in schema \"acme\""
}

424 (Failed Dependency) is the same idea one layer earlier: something the operation needed in order to even form its query was not available. Inserting a row with a column name that does not exist on the table:

http
POST /minimal/api/rest/auto/v1/acme/people/pg/acme/department HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-Space-Id: dev
X-User-Id: acme-admin
X-User-Roles: hr-admin
Content-Type: application/json

[
  {"id": null, "name": "Misspelled Column", "cost_centre": "CC-9400", "created_at": "2026-08-20 09:00:00", "nam": "typo"}
]
json
{
  "code": 424,
  "status": "Failed Dependency",
  "message": "can not form the sql",
  "details": "unknown column \"nam\""
}

Neither code, here or in the table-not-found example above, says the caller did anything invalid in isolation — the request body is well-formed JSON despite the misspelled column name, and the table name is a syntactically ordinary string — only that the specific thing needed to proceed was not available.

Elsewhere in the API these two codes carry other meanings: a 424 sometimes reports nothing more specific than a write that failed to land — Chapter 5's blanket-update refusal (PUT .../department with no filter) answers this way. Read each route's own response codes rather than assuming this pairing holds everywhere.

How MCP surfaces a refusal

Every interaction with the MCP server is one JSON-RPC request answered with a JSON-RPC 200 OK. A refusal from the API a tool calls does not fail that exchange — it comes back as a normal, well-formed result marked isError: true, carrying the same status and message an HTTP caller would have gotten. The protocol exchange succeeded; what it reports is a refusal.

flowchart TD
    R[Request arrives] --> Ch{REST or MCP?}
    Ch -- REST --> A["The route's own checks run,<br/>credential included"]
    A --> S["Status line carries the reason:<br/>400, 401, 403, 404, 409, 412, 424 ..."]
    Ch -- MCP --> MC{Access token present at all?}
    MC -- no --> MC1["401, before the tool layer is reached"]
    MC -- yes --> M{"Session header present,<br/>arguments match the tool's schema?"}
    M -- no --> E1["200 OK, isError: true<br/>(session or schema refusal)"]
    M -- yes --> Call[Tool calls the same route A would]
    Call --> E2["200 OK, isError: true<br/>wraps that route's own status and message"]

REST reports every refusal, credential problems included, in the status line. MCP's own credential check still fails outright, before a tool ever runs; everything after that rides inside a successful response.

A request with no credential at all is the one case that still fails outright, because it never reaches the tool layer:

json
{
  "code": 401,
  "data": null,
  "message": "this endpoint requires an access token: send LB-Access-Token"
}

Once a credential is present, refusals move inside the result. Calling auto_delete_rows on the salary table with the hr-admin role, which has full write access to this table on the direct REST API, still comes back refused on the agent channel — a later section covers why this response alone cannot tell you whether a role or a lock is the reason:

json
{
  "jsonrpc": "2.0",
  "id": 4,
  "method": "tools/call",
  "params": {
    "name": "auto_delete_rows",
    "arguments": {"type": "pg", "schema": "acme", "table": "salary", "query": {"id": "eq.1"}}
  }
}
json
{
  "jsonrpc": "2.0",
  "id": 4,
  "result": {
    "content": [{"type": "text", "text": "{\"code\":403,\"status\":\"Forbidden\",\"message\":\"insufficient permissions for this operation on this table, got [hr-admin]\"}"}],
    "isError": true
  }
}

isError is what a client checks, not the transport status — the HTTP response around this was a plain 200. Two other refusals share this same shape without ever reaching the API at all: a tools/call sent with no X-Session-Id header answers isError: true naming start_ai_session as the call to make first, and one whose arguments do not match the tool's input schema answers isError: true with text beginning validating "arguments". Only a request naming a tool that does not exist, or one malformed as JSON-RPC itself, comes back as an actual JSON-RPC error object rather than a result.

Like any other call made under a session, a refused one is still recorded — before it runs and again once it answers — so an agent's refusals stay in the session's history alongside everything it did successfully.

Telling refusals apart

Not found, or not allowed to know it exists

A 404 (or, on some routes, an equivalent status) does not always mean the thing is literally absent. Minimal sometimes deliberately makes "it exists but is not yours" answer identically to "it never existed," so that a caller cannot probe for the difference. Chapter 6 already showed this under 412 on the Auto API: a table hidden from view by its owner and a table that was never cataloged both answer "unable to get table info," byte for byte the same, because a caller who could tell them apart could use that difference to learn what a project is withholding. Not every "no" works this way — the 403 earlier in this chapter told acme-contractor plainly that the salary table exists and that the contractor role has no access to it. Whether a route confirms existence or conceals it is a property of that route, not a rule you can assume applies everywhere.

A lock refusal and a permission refusal look identical

Chapter 6 covers permission templates and table locks as configuration: which roles a template grants per channel, and how a lock independently blocks an operation regardless of role. At the wire, the two failure modes are indistinguishable. Both are 403, and both use the same message template, naming whatever roles you sent:

json
{
  "code": 403,
  "status": "Forbidden",
  "message": "insufficient permissions for this operation on this table, got [contractor]"
}
json
{
  "code": 403,
  "status": "Forbidden",
  "message": "insufficient permissions for this operation on this table, got [hr-admin]"
}

The first is acme-contractor, whose role genuinely has no read access to salary. The second is acme-admin, holding hr-admin — a role with full write access to the table — refused because the table's delete lock is currently on for every caller, admin included. Nothing in either response says which cause applied. The one way to tell them apart is to ask separately: read the table's lock status, covered alongside locks in Chapter 6, before assuming a 403 means your roles are wrong. If the lock mask shows the operation blocked for your channel, that is your answer regardless of what roles you hold; if it does not, the permission template is where to look next.

Exercises

  1. Using a table and a role you have access to from earlier chapters, deliberately trigger three different refusals: one bad-input error, one not-found, and one permission or lock refusal. For each, write down what you can determine from the response alone — the status code, the message, and whether the cause is stated outright or only narrowed down — before checking against this chapter's explanation of that code.

  2. Trigger the same permission refusal twice: once as a REST GET, once as the equivalent auto_read_rows call over MCP. Compare the two responses — same status and message, inside two different envelopes — and confirm the MCP one still carries isError: true even though the transport itself answered 200.

AQuick Reference

The Acme database's own schema, operators, status codes, and MCP tools from this volume, gathered in one place.

The Acme database

Every worked example in this book reads or writes Acme Corporation's HR database: eight tables — department, employee, salary, hike, holiday, leave_request, attendance, audit_note — plus the two views and the materialized view Chapter 1 uses to make the point that Minimal treats those exactly like tables. department.head_email and employee.manager_id are nullable on purpose: one department has no head, and one employee (the one at the top) reports to nobody, so a join written as an inner join silently drops a row that a left join keeps.

sql
CREATE TABLE department (
    id          SERIAL PRIMARY KEY,
    name        VARCHAR(60) NOT NULL,
    cost_centre VARCHAR(12) NOT NULL,
    -- Nullable on purpose -- see intro above.
    head_email  VARCHAR(120),
    created_at  TIMESTAMP NOT NULL
);

CREATE TABLE employee (
    id            SERIAL PRIMARY KEY,
    full_name     VARCHAR(120) NOT NULL,
    email         VARCHAR(160) NOT NULL,
    department_id INTEGER NOT NULL,
    -- NULL for the one employee nobody reports to -- see intro above.
    manager_id    INTEGER,
    title         VARCHAR(80) NOT NULL,
    hired_on      DATE NOT NULL,
    -- NULL while employed. A leaver has a date, so "active only" filters have
    -- something to exclude.
    left_on       DATE,
    -- Nullable, and genuinely null for some employees.
    phone         VARCHAR(32),
    is_active     BOOLEAN NOT NULL
);

CREATE TABLE salary (
    id           SERIAL PRIMARY KEY,
    employee_id  INTEGER NOT NULL,
    amount       NUMERIC(12,2) NOT NULL,
    currency     VARCHAR(3) NOT NULL,
    effective_on DATE NOT NULL
);

CREATE TABLE hike (
    id           SERIAL PRIMARY KEY,
    employee_id  INTEGER NOT NULL,
    pct          NUMERIC(12,2) NOT NULL,
    reason       VARCHAR(40) NOT NULL,
    effective_on DATE NOT NULL
);

CREATE TABLE holiday (
    id       SERIAL PRIMARY KEY,
    name     VARCHAR(60) NOT NULL,
    region   VARCHAR(4) NOT NULL,
    observed DATE NOT NULL
);

CREATE TABLE leave_request (
    id          SERIAL PRIMARY KEY,
    employee_id INTEGER NOT NULL,
    kind        VARCHAR(16) NOT NULL,
    status      VARCHAR(10) NOT NULL,
    start_on    DATE NOT NULL,
    end_on      DATE NOT NULL,
    days        INTEGER NOT NULL,
    -- Free text, nullable. Carries the apostrophe and non-ASCII cases.
    note        VARCHAR(200)
);

CREATE TABLE attendance (
    id          SERIAL PRIMARY KEY,
    employee_id INTEGER NOT NULL,
    on_date     DATE NOT NULL,
    hours       NUMERIC(12,2) NOT NULL,
    source      VARCHAR(12) NOT NULL
);

CREATE TABLE audit_note (
    id          SERIAL PRIMARY KEY,
    employee_id INTEGER NOT NULL,
    author      VARCHAR(120) NOT NULL,
    body        TEXT NOT NULL,
    written_at  TIMESTAMP NOT NULL
);

CREATE INDEX idx_employee_department ON employee (department_id);
CREATE INDEX idx_employee_manager    ON employee (manager_id);
CREATE INDEX idx_salary_employee     ON salary (employee_id, effective_on);
CREATE INDEX idx_hike_employee       ON hike (employee_id, effective_on);
CREATE INDEX idx_leave_employee      ON leave_request (employee_id, start_on);
CREATE INDEX idx_leave_status        ON leave_request (status);
CREATE INDEX idx_attendance_employee ON attendance (employee_id, on_date);
CREATE INDEX idx_holiday_observed    ON holiday (region, observed);

CREATE VIEW v_employee_directory AS
SELECT e.id,
       e.full_name,
       e.email,
       e.title,
       d.name        AS department,
       d.cost_centre AS cost_centre,
       m.full_name   AS manager_name
FROM employee e
JOIN department d ON d.id = e.department_id
-- LEFT, not inner: the one employee with no manager stays in the view.
LEFT JOIN employee m ON m.id = e.manager_id
WHERE e.is_active = TRUE;

CREATE VIEW v_leave_balance AS
SELECT e.id AS employee_id,
       e.full_name,
       COUNT(l.id) AS requests,
       SUM(CASE WHEN l.status = 'approved' THEN l.days ELSE 0 END) AS days_taken
FROM employee e
LEFT JOIN leave_request l ON l.employee_id = e.id
GROUP BY e.id, e.full_name;

CREATE MATERIALIZED VIEW mv_headcount_by_department AS
SELECT d.id AS department_id,
       d.name,
       d.cost_centre,
       COUNT(e.id) AS headcount,
       SUM(CASE WHEN e.is_active = TRUE THEN 1 ELSE 0 END) AS active_headcount
FROM department d
LEFT JOIN employee e ON e.department_id = d.id
GROUP BY d.id, d.name, d.cost_centre;

-- A UNIQUE index is what REFRESH MATERIALIZED VIEW CONCURRENTLY requires.
CREATE UNIQUE INDEX idx_mv_headcount_department ON mv_headcount_by_department (department_id);

Download the full database — this schema plus over 22,000 seed rows: acme-database.sql. Load it into a fresh Postgres database and register that database against a project to run every example in this book yourself, against the same data, instead of only reading along.

Auto API filter operators

Any query parameter that is not a reserved keyword is a column filter, written as column=operator.value. Nineteen operators are defined; any other word in the operator position is refused.

Operator Meaning Example
eq equals status=eq.approved
ne not equal status=ne.rejected
lt less than department_id=lt.6
gt greater than department_id=gt.6
le less than or equal amount=le.100000
ge greater than or equal amount=ge.100000
bw between two values, dot-joined id=bw.0.10
li contains name=li.smith
rli starts with name=rli.ALPHA
lli ends with email=lli.acme.example
nli does not contain name=nli.smith
nrli does not start with name=nrli.ALPHA
nlli does not end with email=nlli.acme.example
il case-insensitive contains name=il.smith
mt case-insensitive regular-expression match name=mt.^A.*son$
in value is one of a comma-separated list id=in.1,2,3
nin value is none of a comma-separated list id=nin.1,2,3
is IS a fixed word: NULL, NOT NULL, TRUE, NOT TRUE, FALSE, NOT FALSE, UNKNOWN, NOT UNKNOWN manager_id=is.NOT NULL
nis IS NOT a fixed word: NULL, TRUE, FALSE, UNKNOWN manager_id=nis.null

Notes:

  • Filters combine with AND. For OR, use one or=(column.operator.value,column.operator.value) group per request — groups cannot be nested, and in/nin cannot appear inside one.
  • il lowercases only the portion of the filter value before its first dot — write anything after a dot in lower case yourself.
  • A raw semicolon in a filter value must be percent-encoded as %3B. Unencoded, the parameter is not silently discarded — the request is refused rather than answered with a widened result.
  • No filter value may contain a bare SQL statement keyword (SELECT, DROP, UNION, and similar) as a whole word.

HTTP status codes

Code Meaning
200 OK The call succeeded; the response body carries the result.
201 Created A new resource was created; the response echoes its generated id(s).
202 Accepted The work was accepted and continues in the background — poll to see when it finishes.
204 No Content An empty result page, or a write whose success carries no body to return.
208 Already Reported An organization or project with this id or abbreviation already exists; nothing new was created.
400 Bad Request The request body, headers, or query string could not be read, or a required one was missing entirely.
401 Unauthorized A credential was sent and did not check out — malformed, unknown, suspended, expired, or not valid for this method.
403 Forbidden The caller's organization, roles, or the target's lock state do not permit this call.
404 Not Found Nothing matches the id, token, or object named.
406 Not Acceptable A required paging parameter (ps or pg) is missing, non-numeric, or negative.
409 Conflict The request collides with current state — a duplicate, a held lock, an in-flight run, or a reused key.
412 Precondition Failed Something the operation depends on — a table lookup, a driver, a transaction — could not be established.
413 Payload Too Large The request body exceeds the deployment's size cap.
417 Expectation Failed The query as given cannot be turned into a valid statement — an unknown column, an incompatible combination of options, or an invalid fixed word.
424 Failed Dependency Something the operation needed in order to even form its query was not available. A few routes also use it for an unrelated safety refusal (a write with no filter) — check the specific route's own documentation.
500 Internal Server Error An unexpected failure while handling the request.
502 Bad Gateway The backing database could not be reached, could not be read, or refused the operation.
503 Service Unavailable A backing role-or-permission store was unreachable — retry later.

The MCP endpoint answers 401, not 400, when no access token is sent at all — it has no raw-header alternative the way REST does, so a missing credential and an invalid one look the same there.

MCP tools

Every tool below is summarized from the live server's own description: name, a plain-language summary, and the arguments its schema marks required. Everything else on a tool's input schema is optional. This covers only the tools Volume 1 actually names and uses — the full catalog is much larger.

Session and connectivity

Every tool call needs a session except start_ai_session and help. Call start_ai_session first and send the session_id it returns as the X-Session-Id header on every later call.

Tool Description Required arguments
start_ai_session Creates the session every other tool call must carry; returns session_id. name (string), context (string)
ping Confirms the MCP endpoint is reachable and the credential works; echoes an optional note. none
help Returns the server's instructions, or one tool's full entry when given a tool name. none

Metadata

Discovers what a project has registered and cataloged, ahead of reading or writing any of it.

Tool Description Required arguments
meta_list_databases Lists the databases the project has registered, each as a type/schema pair. none
meta_list_tables Lists the tables cataloged under one registered database. type (string), schema (string)
meta_describe_table Answers with one table's columns, read live from the database itself. type (string), schema (string), table (string)

Auto API

Reads and writes rows on a cataloged table, addressed directly by database type, schema, and table name — no definition involved.

Tool Description Required arguments
auto_read_rows Reads one page of rows from a table, with filtering, column selection, ordering, and grouping via query. type (string), schema (string), table (string), page_size (integer), page_number (integer)
auto_insert_rows Inserts rows into a table; an empty rows array inserts nothing. type (string), schema (string), table (string), rows (array)
auto_update_rows Updates the rows a filter matches; set carries the column assignments. A call with no filter in query is refused. type (string), schema (string), table (string), set (string)
auto_delete_rows Deletes the rows a filter matches. A call with no filter in query is refused. type (string), schema (string), table (string)

The database-type code for type is always one of pg (PostgreSQL), ms (MySQL), ma (MariaDB), or ch (ClickHouse) — never the full name.

Table security

Covered in full in Chapter 6.

Tool Description Required arguments
set_table_lock_mask Sets one or more of a table's twelve lock toggles; a differential write, so an omitted toggle keeps its stored value. db_type (string), schema (string), table (string)

Custom API

Tool Description Required arguments
call_api Invokes a Custom API definition by uri, method and version, and relays whatever it answers — the one tool that stands in for every definition a project's authors write. Covered in full in Advanced Minimal, Chapter 1. uri (string), method (string), version (string)
Library← All volumesNext volumeVol. II — Advanced Minimal →
littlebit labs

Your data[base], made addressable — by APIs, by MCP, by identity, by agents. English is the only language you need to speak here.

Products

Agent Studio App Studio Chat Studio API Bay Ask Studio Minimal Core

Platform

Pricing Grants Security Transparency FAQ Docs Changelog Status

Company

About Contact Book a session Terms of Service Privacy Policy Consent notice Cookie settings

Connect

GitHub X LinkedIn Discord YouTube

© 2026 Littlebit Labs. All rights reserved.

Built for humans and machines.