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.
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 .-> DAn 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.
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>[
{ "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.
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{
"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:
{
"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
Acme's
peopleproject has one Postgres database,acme, holding anemployeetable, asalarytable, 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.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.
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:
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-adminAn 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:
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{
"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/1.1 201 Created
Content-Type: application/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:
GET /minimal/system/api/v1/org HTTP/1.1
Host: api.acme.example
LB-Access-Token: <your-access-token>{
"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:
GET /minimal/rest/meta/v1/table/list?type=pg&schema=acme HTTP/1.1
Host: api.acme.example
LB-Access-Token: <your-access-token>[
"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:
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>[
{
"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:
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[
{
"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/1.1 201 Created
Content-Type: application/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:
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>[
{
"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:
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/1.1 200 OK
Content-Type: application/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:
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-analystHTTP/1.1 200 OKThe insert does not:
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/jsonHTTP/1.1 403 Forbidden
Content-Type: application/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:
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>[
{
"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
The
departmenttable has twelve rows, each with anidand aname. Read all of them —ps=20&pg=0against/acme/people/pg/acme/departmentis 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 reademployeefiltered to it, ordered byhired_onascending instead offull_name. Who was the first person hired into that department, and who is the most recent?Update a different column on the row this chapter inserted — its
phone, say — the same way this chapter just updatedtitle, then read the row back to confirm the new value landed.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=crudgrants the token every verb, so nothing at the token's own level stands between it and aPOST. 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:
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{
"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 --> PRDWhat 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.
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:
{"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:
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/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.
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{
"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:
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-clerkanswers 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.
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/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:
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/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 —
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 —
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:
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/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:
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/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 —
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:
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-adminHTTP/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,peopleReading 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:
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-adminHTTP/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:
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-adminanswers 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
Read your organization's record once with a role that appears in its
org_read_roles, and once with a role that appears in neitherorg_read_rolesnororg_write_roles. You should get a200and a403. 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 the403and the404.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.Update your organization's
org_write_rolesto 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:
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/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:
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/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:
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/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:
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/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:
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-admintag=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/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/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/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/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/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.
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:
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/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
endA 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
Mint two access tokens for a role you hold, identical except for
key_type: one-r--, onecrud. 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 thecrudtoken, 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.Mint a token, then try to widen it:
PATCHthe token route and send arolesvalue 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 widerrolesvalue instead, and explain in one sentence why the amend route and the mint route disagree about whether this is allowed.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 whetherdatanames 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/employeeA full read looks like this:
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[
{
"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:
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{
"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:
GET /minimal/api/rest/auto/v1/acme/people/pg/acme/employee HTTP/1.1
LB-Access-Token: <your-access-token>{
"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_iddf 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{
"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.approvedA misspelled or nonexistent column is not ignored — it fails as an unknown column:
GET .../employee?ps=10&pg=0&no_such_column=eq.1{
"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:
{
"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|jonesil 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 allThis 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{
"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{
"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{
"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{
"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.MThe 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{
"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
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"}
]{"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:
[
{
"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"
}
]{"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:
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>{"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{
"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:
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>{"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 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- id: 1
name: Engineering
- id: 2
name: Platformxml'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 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:
{
"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:
{
"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:
{
"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
Page through a filtered, ordered read. Using the
employeetable, 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.Build an
orgroup. 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 singleor=(...)group combined with anrlifilter, not as two separate requests you merge yourself.Round-trip a write. Insert two new rows into
departmentin a single call, letting the database assign both ids withnull. Confirm both rows exist and note the ids you got back. Rename one of them with aPUTandset=. 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 oneDELETE.Compare formats. Read the
departmenttable as CSV, selecting onlyidandname, 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:
{
"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.
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"
}{
"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:
{
"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:
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-adminHTTP/1.1 204 No ContentThe same listing takes format=yaml alongside its JSON default:
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- 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:
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{
"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
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"
}{
"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:
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{
"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:
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-adminThat 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:
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[{"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:
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:
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}{
"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:
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[{"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:
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-adminHTTP/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:
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-admintemplate_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:
{
"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" --> D1For 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:
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}{
"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:
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[{"id":1,"name":"Engineering","cost_centre":"CC-1000", ...}]A delete, on the same table, from the same caller:
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{
"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:
{
"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.
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}{
"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:
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[
"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:
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{
"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:
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[
{
"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:
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
Create a permission template that lets
hr-analystread theleave_requesttable 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 nameshr-analyst, not a lock.Lock reads on one table for the
mcpchannel only, leaving its direct REST channel open. Prove both halves: a directGETagainst the table still returns rows, while the same read attempted as an MCP tool call is refused. Then undo just that one toggle.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:
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{
"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:
{
"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:
{
"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:
{"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:
{
"jsonrpc": "2.0",
"id": 0,
"method": "server/discover",
"params": {}
}{
"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 firstThe 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:
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "meta_list_databases",
"arguments": {}
}
}{
"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:
{
"name": "meta_list_tables",
"arguments": {
"type": "pg",
"schema": "acme"
}
}[
"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:
{
"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:
{
"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:
{
"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:
{
"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:
{
"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
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 thesession_idit returns to callmeta_list_tablesfortype: "pg",schema: "acme", and confirmemployeeanddepartmentboth appear.Using the session from Exercise 1, call
auto_read_rowsondepartmentwith anoyordering onidand a small page size, then callauto_insert_rowsto add one department of your own. Read the table again and confirm your new row is there — and check what itsidturned out to be, since you sent that column asnull.Call a data tool —
auto_read_rowsis fine — without settingX-Session-Idat all, even though you have a valid session open. Compare theisErrorresult you get back to the role refusal shown above: both areisError: true, but a missing session and an insufficient role are different reasons, not the same one wearing a different envelope. Then callhelpwitharguments: {"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:
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"}{
"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:
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>{
"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:
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{
"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:
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"}]{
"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:
{
"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:
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"}{
"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:
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{
"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:
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"}
]{
"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:
{
"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:
{
"jsonrpc": "2.0",
"id": 4,
"method": "tools/call",
"params": {
"name": "auto_delete_rows",
"arguments": {"type": "pg", "schema": "acme", "table": "salary", "query": {"id": "eq.1"}}
}
}{
"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:
{
"code": 403,
"status": "Forbidden",
"message": "insufficient permissions for this operation on this table, got [contractor]"
}{
"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
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.
Trigger the same permission refusal twice: once as a REST
GET, once as the equivalentauto_read_rowscall over MCP. Compare the two responses — same status and message, inside two different envelopes — and confirm the MCP one still carriesisError: trueeven though the transport itself answered200.
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.
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, andin/nincannot appear inside one. illowercases 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) |