Skip to content
littlebit labs
Products Pricing Grants Company Docs
Products Pricing Grants Company Docs Experience Now
Library Vol. I — Essential Vol. II — Advanced Vol. III — Identity & the Gateway
Experience Now →
BOOK OF MINIMALVOL. II An ostrich standing face-on, engraved in black and white Advanced Minimal
1Custom API and Definitions
Anatomy of a definition What a logic step can do A worked example: looking up one employee Reading, updating, exporting and copying Calling a definition Removing a definition Exercises
2Modules
Creating a module Reading it back Listing modules Using it from a definition Updating a module Viewing history Deleting a module Starlark: the one real difference Modules over MCP Exercises
3DDL: Databases, Runs, and Locks
Registering a database Reading the catalog Running a schema change The run lock Dropping a database Exercises
4Row-Level Security and Advanced Table Control
Row-level security Bulk permission and lock changes Hidden tables Exercises
5Personas, Apps, and the AI Surface
Personas Apps AI sessions and conversations A worked example: the HR Assistant answers a leave question Exercises
6Audit and Observability
Your own trail Your organization's trail What a row tells you The same two trails, as tools A session's own history, narrower still Worked example: from a deletion to the session that made it Exercises
7MCP in Depth
One session, every call The tool surface, by family What an annotation tells you before you call it Two tools that answer misleadingly What the listener refuses before any tool runs Building a prompt around several tools Exercises
8Building for Production
Auto API or Custom API Credential hygiene Schema evolution across more than one database type Is this ready? A closing checklist Exercises Where to go next
9Configuration and Deployment
How the file is read The service block The definition store max_body_bytes The script-step budget auto_api.permission_fail_open access_token agent_identity relay_server The MCP listener Is this configured to run? A closing checklist The full reference file Exercises
10The Boundary in Front of Minimal
What the boundary has to do Two ways to hand identity across, and which one to prefer The shape of it Zitadel as the identity provider Okta as the identity provider Keycloak, briefly A translation step, made concrete Fronting the MCP listener specifically Is the boundary actually load-bearing? A closing checklist Exercises
BFull MCP Tool Reference
1. Proof & health 2. Organization 3. Database 4. Projects 5. Spaces 6. Features 7. Permission templates 8. Table permissions 9. Table locks & indexing 10. Credentials 11. Auto API 12. Modules 13. Custom API 14. Meta 15. Apps 16. Persona 17. AI — session & conversation 18. Audit
CFull API Reference
Proof & health Bootstrap Provisioning Database Organization Projects Spaces Features Permission templates Table permissions Table locks Credentials Auto API Modules Custom API Meta Apps Persona AI sessions and conversations Audit MCP

Book of Minimal, Vol. II

Advanced Minimal

Permission templates, row-level security, the DDL surface, credential lifecycles, authored modules, the audit trail, and MCP in depth.

12 CHAPTERS · 36,755 WORDS · 12 DIAGRAMS

1Custom API and Definitions

Volume 1 introduced the Custom API as Minimal's other door into a table: instead of generic CRUD, you write a definition — one YAML document naming an HTTP method, a path, and an ordered list of logic steps — and Minimal serves it as a real endpoint from that point on. This chapter covers every section a definition's document can carry, everything a logic step can do, and the full lifecycle of a definition once it exists — reading it back, changing it, moving it between spaces, calling it, and removing it for good.

Everything in this chapter happens inside the acme organization, the people project and the dev space you have been working in since Chapter 2 of Volume 1. A definition lives in exactly one space. Nothing about it is visible from another space until you copy it there, which is its own operation later in this chapter.

Anatomy of a definition

A definition's identity is three things together: method, path, and version — the http.method, http.uri and http.version fields. Two of the three, method and path, are what actually distinguish one stored definition from another: a space cannot hold two definitions on the same path answering the same method, whatever version either one carries. Two definitions can share a path, as long as they answer different methods — a GET and a POST at acme/people/report coexist without conflict. Version is not a way to keep two copies of the same endpoint side by side; it is a label the caller has to know and send on every call, so that invoking the wrong version of an endpoint you have since changed fails loudly instead of quietly running against a document you no longer expect.

The path you submit is not the path Minimal serves. The project's own prefix — built from the organization and project abbreviations — is prepended unless it is already there, so http.uri: employee/lookup submitted under acme/people is stored and served as acme/people/employee/lookup. The create call's response carries that final path back to you as final_url; use it, not what you typed, anywhere a full path is needed later.

The document itself has six mandatory top-level sections and three optional ones. Any other top-level key is rejected outright, and the whole document is capped at 16384 bytes.

Section Required Carries
identifiers yes org, project, space and user — must match your caller identity exactly
permission yes roles allowed to call the endpoint directly, as a plain list
http yes method, path, version, input/output format
logic yes the ordered steps that run when the endpoint is called
config yes rollback version, whether calls coalesce, whether results cache
database yes the connection the logic steps run against
metadata no documentation, tags, an api_group list for discovery
mcp no whether and how an agent may call this endpoint
m2m no whether and how a machine-to-machine caller may call this endpoint

identifiers most often trips up a first attempt: org_id, project_id, space_id and user_id inside the document must equal X-Org-Id, X-Project-Id, X-Space-Id and X-User-Id on the request exactly. A mismatch on any of the four is refused with 403, before anything else about the document is even considered. feature_id is the exception — it is stored as sent and never checked against anything; it names an optional Feature, a grouping label that sits alongside a space under a project, mentioned again in Advanced Minimal, Chapter 7.

permission governs only direct callers. mcp.mcp_roles and m2m.m2m_roles are separate lists that gate the other two channels a definition can answer on; a role in permission says nothing about whether that same caller may reach the endpoint as an agent. More on that when we get to calling one.

database is a full connection, not a reference to one already registered on the project — type, username, password, database name, an SSL mode, timeouts, and a pool size, plus one or more host/port entries under machine for a primary and any replicas. A definition owns its own connection because its author may need a different database than the project's own default, or the same database reached with a narrower set of credentials.

config is smaller: a free-text rollback_version label your own tooling can use to track which version of a definition is currently deployed, and two booleans — coalesce, which collapses identical concurrent calls into one, and cache_result, which caches what a call returns. Both booleans are required keys even when you want neither behavior; send them false.

What a logic step can do

logic is an ordered list, and Minimal calls it a waterfall for a reason: each step runs in turn, and its result lands under the name step_0, step_1, and so on, by position. Every step after it can read that result. A sql step's rows are visible to the next step as a list under step_N; a script step's returned fields are visible the same way, and can also fill a later SQL step's placeholders by name.

flowchart LR
    IN["Caller input\n(headers + query or body)"] --> S0
    S0["Step 0 — sql or script"] -->|"available as step_0"| S1
    S0 -.->|"also visible to any later step"| S2
    S1["Step 1 — sql or script"] -->|"available as step_1"| S2
    S2["Step 2 — sql or script"] --> OUT["Response\n(steps not marked omit)"]

A definition's logic steps run strictly in order; every step's output stays visible, by number, to every step that runs after it — not just the one immediately following.

A step is a map holding exactly one of seven step-type keys — let, sql, fnc, case, expr, lark or lua — plus, optionally, omit. Only four of the seven actually run: sql, lua (Lua), lark (Starlark), and expr, a fourth scripting key that takes source the same way lua and lark do but that none of Acme's own definitions use, so this chapter doesn't cover it further. The other three — let, case and fnc — are accepted when you create or replace a definition, but any call that reaches one of them at run time is refused. Stick to sql, lua and lark.

A SQL step is exactly one statement — several separated by semicolons are refused — and its first keyword must be one of select, insert, update, delete, commit, rollback or call. A placeholder inside it, written ?:name, is filled from the caller's own input under that name, or from a field an earlier script step returned under that name:

yaml
logic:
  - sql: |
      SELECT employee_id, start_on, end_on, status
      FROM leave_request
      WHERE employee_id = ?:employee_id
      ORDER BY start_on DESC
      LIMIT 100

A script step — Lua or Starlark — is a function named min_main, taking no arguments, that must return a map. A function returning a string, a number, or nothing at all refuses the step outright. An empty map is a valid, deliberate answer — "this step ran and produced nothing to report" — not an error:

lua
function min_main()
  return {}
end

That step's endpoint answers 200 with an empty object, {} — not a failure.

Starlark reads the same way, as a top-level min_main function rather than a global one, and sees the exact same step_N bindings a Lua step would:

starlark
def min_main():
    rows = step_0
    if rows == None or len(rows) == 0:
        return {}
    approved = [r for r in rows if r["status"] == "approved"]
    return {"employee_id": rows[0]["employee_id"], "approved": len(approved)}

Which language you reach for is a matter of taste, not capability — Acme mixes both across its own definitions, sometimes in the same project. A script step is for shaping and filtering what a SQL step already fetched, not for fetching data of its own — a step that needs another row goes back to sql.

omit: true drops a step's contribution from what the caller sees, without removing it from the waterfall — a later step can still read it as step_N. Acme uses this on every SQL step that feeds a script step, and also on a step whose whole job is protecting data rather than producing it: a directory endpoint that reads full contact rows with a SQL step marked omit, then redacts every email address in a following Lua step before anything leaves the server:

json
{
  "people": [
    {
      "email": "r***@acme.example",
      "full_name": "Rafael Delacroix",
      "id": 1
    }
  ]
}

The unmasked email never exists in any response this endpoint can produce — not filtered out after the fact, never assembled into an outgoing body in the first place.

A worked example: looking up one employee

Put this together into the smallest useful definition: one GET endpoint that takes an employee id and answers with a small, reshaped object rather than a raw row. Two steps — a SQL step that reads the row, marked omit so its raw columns never leave the server, and a Lua step that reshapes what it read.

yaml
identifiers:
  org_id: acme
  project_id: people
  feature_id: directory
  space_id: dev
  user_id: acme-admin

permission:
  - hr-admin

http:
  uri: employee/lookup
  method: GET
  version: "1"
  input_type: JSON
  output_type: json

logic:
  - sql: |
      SELECT e.id, e.full_name, e.email, d.name AS department
      FROM employee e
      JOIN department d ON d.id = e.department_id
      WHERE e.id = ?:id
    omit: true
  - lua: acme_employee_reshape
    code: |
      function min_main()
        local row = step_0[1]
        if row == nil then
          return { found = false }
        end
        return {
          found = true,
          employee = {
            id = row.id,
            name = row.full_name,
            email = row.email,
            department = row.department,
          }
        }
      end

config:
  rollback_version: "1"
  coalesce: false
  cache_result: false

database:
  type: postgres
  username: minimalist
  password: "<your-db-password>"
  name: acme
  ssl_mode: ""
  timeout_secs: 30
  idle_timeout_secs: 600
  max_open_connections: 20
  max_idle_connections: 10
  query_timeout_secs: 60
  machine:
    - host: localhost
      port: "5433"

Register it. The body is YAML, not JSON, whatever Content-Type you send:

http
POST /minimal/rest/definition/v1 HTTP/1.1
LB-Access-Token: <your-access-token>
Content-Type: application/x-yaml

<the document above>
json
{
  "definition_id": "01M2663DB9CY2ZFSX1C1V5NKN4",
  "final_url": "acme/people/employee/lookup"
}

A definition's uniqueness is checked on method and path together, not on version — the store has no notion of "this version is taken but that one is free." Submitting a second definition for a method and path that already has one, at any version, is refused:

json
{
  "code": 409,
  "status": "Conflict",
  "message": "definition already exists"
}

which is also why the smallest useful new definition always needs an unused path, a new method on an existing path, or a replace instead of a create. A logic step's script is checked for syntax before any of this — a step written in Lua or Starlark that fails to parse is rejected at this same create call, 400, before anything is stored, rather than being accepted and only failing the first time somebody calls it.

Keep definition_id — every read, patch and delete-by-id call needs it. Now call the endpoint it created. A GET takes its input as query parameters, version included:

http
GET /minimal/api/rest/v1/acme/people/employee/lookup?version=1&id=28 HTTP/1.1
LB-Access-Token: <your-access-token>
json
{
  "employee": {
    "department": "Field Sales",
    "email": "amara.novak28@acme.example",
    "id": 28,
    "name": "Amara Novak"
  },
  "found": true
}

Row 28 exists, so step_0[1] holds it and the Lua step's first branch runs, returning exactly the three fields the SELECT read, renamed the way the script names them.

An id that matches nothing is not an error — the SQL step simply returns no rows, step_0[1] is nil, and the Lua step's own logic answers accordingly:

http
GET /minimal/api/rest/v1/acme/people/employee/lookup?version=1&id=999999 HTTP/1.1
LB-Access-Token: <your-access-token>
json
{"found":false}

And a caller whose roles do not include anything in permission is refused before either step runs:

json
{
  "code": 403,
  "status": "Forbidden",
  "message": "insufficient permissions: required [hr-admin], got [hr-analyst]"
}

Reading, updating, exporting and copying

Reading a definition back returns its YAML document — re-rendered, so comments and key order are not guaranteed to survive — with database.password replaced by a fixed mask:

http
GET /minimal/rest/definition/v1?definition_id=01M2663DB9CY2ZFSX1C1V5NKN4 HTTP/1.1
LB-Access-Token: <your-access-token>

Never feed that response back into a replace call. The mask is a structurally valid password string, and Minimal has no way to tell it apart from a real one — send it back and the definition's own database connection breaks silently, storing the mask as if it were the actual credential.

Listing returns one summary row per definition in a space, a page at a time, instead of a full document. Narrow the page by HTTP method, by a URI prefix, by a whole tag, or by whether a definition is open to agents — filters combine, and paging is mandatory. fc chooses which fields each row carries, in place of the ten default ones:

http
GET /minimal/rest/definition/v1/list?ps=20&pg=0&uri_prefix=acme/people/directory&fc=definition_id,uri,method HTTP/1.1
LB-Access-Token: <your-access-token>
json
[
  {
    "definition_id": "01M1S96RTF5RT0F83B079JRH7B",
    "method": "get",
    "uri": "acme/people/directory"
  },
  {
    "definition_id": "01M1S96RVZQC04KK1FN5RYFMP9",
    "method": "get",
    "uri": "acme/people/directory/masked"
  }
]

Rows come back in no guaranteed order and the response carries no total count — stop paging once a page comes back empty. This is the call that finds a definition by anything other than its exact id or its exact route, which is what makes it the way to recover an id you were not handed, such as the fresh one a copy mints. A filter matching nothing is not an error — the answer is a plain 200 with [], the same shape an unfiltered list gives once you have paged past the end. fc draws from a fixed allowlist rather than any column on the row: the encoded document itself is deliberately excluded — asking for it here is refused, 400, naming the field, because reading a document is what Exporting, next, exists for.

Exporting is the call built for editing. It returns the document exactly as stored, byte for byte, with the real password in the clear — the only read in this API that discloses a stored credential, which is why it needs a write role rather than a read one, and why every successful export is logged as its own event:

http
GET /minimal/rest/definition/v1/export?definition_id=01M2663DB9CY2ZFSX1C1V5NKN4 HTTP/1.1
LB-Access-Token: <your-access-token>

Build any edit from this response, never from the plain read.

Updating a definition means one of two different things, depending on what changed.

A full replace (PUT /minimal/rest/definition/v1) resubmits the whole document — the same shape the create call takes, found by the http.uri and http.method inside it. This is a whole-document overwrite, not a merge: an optional section you leave out — metadata, mcp, m2m — is cleared, not preserved. The identifiers block still has to match your headers exactly, version is overwritten rather than matched, and any compiled script the definition used is discarded, so new logic takes effect on the very next call. A successful replace answers 200 with an empty body.

Changing only the catalog and channel settings — documentation, tags, discovery groups, the mcp and m2m blocks — is a targeted attributes patch (PATCH /minimal/rest/definition/v1/attributes) instead, and it never touches logic:

http
PATCH /minimal/rest/definition/v1/attributes HTTP/1.1
LB-Access-Token: <your-access-token>
Content-Type: application/json

{"definition_id": "01M2663DB9CY2ZFSX1C1V5NKN4", "tags": ["book-example", "chapter-1"]}
json
{"rows_affected": 1}

Only the fields you send are touched; the rest of the definition is untouched. But a field you do send is set to exactly what you send — an empty string, an empty list, false — which is how a value is cleared, and a body carrying only definition_id is refused with 400 rather than silently doing nothing. This is also the call that opens a definition to agents: set mcp.enabled: true and list the roles allowed to call it in mcp.mcp_roles, and the next call over the agent channel picks it up immediately.

A handful of fields on this route are declared and validated, but nothing yet reads them back to change what a call does. mcp.input_schema and mcp.output_schema are meant to describe the shape of a definition's arguments and answer to an MCP caller, and mcp.requires_approval (with an m2m counterpart for each) is meant as a human-in-the-loop flag on a channel's invocations. All three are stored and validated today — the two schema fields as base64-encoded JSON, requires_approval as a plain boolean, default false — but nothing in call_api or the MCP listener currently reads any of them back to shape or gate a call.

Every one of tags, api_group, mcp.mcp_group, mcp.mcp_roles and m2m.m2m_roles is a comma-safe list — an entry that itself contains a literal comma is refused, 400, rather than silently splitting into two entries you did not write. documentation is base64-encoded text, the same convention as the definition's own script bodies; malformed base64 is refused the same way. embedding, the vector this definition's discovery search indexes it under, is either absent (untouched), a zero-length list (clears it back to unset), or exactly 1536 numbers — any other length is refused, naming the count it actually got.

Copying moves a set of definitions from one space to another in one transaction — how Acme promotes a batch of endpoints from dev into staging:

http
PUT /minimal/rest/definition/v1/space/copy HTTP/1.1
LB-Access-Token: <your-access-token>
Content-Type: application/json

{
  "org_id": "acme", "project_id": "people", "space_id": "dev",
  "target_space_id": "staging", "target_version": "2",
  "user_id": "acme-admin",
  "definition_list": ["01M2663DB9CY2ZFSX1C1V5NKN4"]
}
json
{"rows_affected": 1}

Every copy gets a fresh id and the version label you named — rows_affected counts how many of the ids you listed actually matched something in the source space, silently skipping any that did not, so compare it against what you asked for. The new ids are not returned; list the target space to find them.

The copy's stored document keeps the source space's identifiers.space_id unchanged. If you go on to replace that copy with a full document, you have to edit identifiers.space_id to the target space first — the copy as it stands will fail the same identity check every create and replace call makes.

Calling a definition

The call route is /minimal/api/rest/v1/, followed by the definition's own path. Which definition answers is decided by organization, project, space, method, path and version together — version has no default, and a call that omits it is refused.

What carries the caller's input depends on the method. GET and DELETE read no body; every query parameter becomes an entry in the definition's input, version and format included. POST, PUT and PATCH decode the body in the definition's declared input_type and use that as the input instead. Either way, every request header is merged into that same input — with three exceptions: Authorization, Proxy-Authorization and Cookie are stripped from the request before anything that reads a header runs, on this route and on the MCP listener alike, so no definition can read a credential the caller addressed to another system. A step that references one of the three by name anyway does not get a clear error naming the missing header — it fails the same generic way any step failure does, 424, "could not execute URI," the code this book keeps returning to for "the request was fine, something it depended on was not there."

POST is the only verb that answers 201; the other four answer 200 on success. Steps marked omit are removed before the response is built; when exactly one result remains it comes back unwrapped, not nested under a step name:

http
POST /minimal/api/rest/v1/acme/people/leave/summary?version=1 HTTP/1.1
LB-Access-Token: <your-access-token>
Content-Type: application/json

{"employee_id": 28}
json
{"approved":12,"employee_id":28,"pending":6,"requests":23}

The response is encoded in the definition's own http.output_type — json unless the document says otherwise — regardless of what the caller sends, unless the document also set http.override_output_type: true. Only then does a format query parameter on the call win, letting one endpoint answer json for one caller and yaml for another without the author maintaining two definitions. A definition that never set the override flag ignores format entirely and always answers in its declared type.

Calling a path, method or version nothing serves is 417, the same whether the definition never existed or was deleted:

json
{
  "code": 417,
  "status": "Expectation Failed",
  "message": "error getting details",
  "details": "definition not found"
}

A definition carrying a Lua or Starlark step draws on a shared, organization-scoped script budget. Running out of it is 429 when your own organization is already using its share while another organization has work in flight, or 503 when the server's whole script capacity is briefly exhausted — neither is caused by anything wrong with your request, and both carry Retry-After.

Being registered does not make a definition reachable by an agent. permission gates a direct caller; an agent is checked separately against mcp.mcp_roles, and only when mcp.enabled is true at all. This route alone can put a caller on any of the three channels, without the caller ever going near the separate MCP listener. Presenting an access token, the channel is the one the token's own token_type names (Volume 1, Chapter 4). Presenting the five raw identity headers instead of a token, Minimal reads a prefix off X-User-Id: a value beginning mcp: resolves to the agent channel, one beginning m2m: resolves to machine-to-machine, matched case-sensitively — MCP: matches nothing — and anything else falls back to the direct channel, checked against permission. Whichever channel a caller lands on, only that channel's role list is consulted; a role that would pass permission is never checked against mcp.mcp_roles just because the caller's identity happened to carry the mcp: prefix, and the reverse. Which prefix strings a deployment recognizes is itself configuration, covered in Chapter 9. Minimal's MCP listener exposes the same call as a tool, call_api, taking uri, method, version, and either query or body depending on the method — the same input shape, reached a second way. LB-Access-Token and X-Session-Id travel on this call exactly as they do on every other tool call, omitted below to keep the arguments in focus:

json
{
  "name": "call_api",
  "arguments": {
    "uri": "acme/people/noop",
    "method": "get",
    "version": "1"
  }
}

Called against a definition whose mcp block is not open, the answer is a 403 that names the channel and echoes the caller's own roles, but — unlike the direct-caller refusal above — never names which roles were actually required:

json
{
  "jsonrpc": "2.0",
  "id": 6,
  "result": {
    "content": [{"type": "text", "text": "{\"code\":403,\"status\":\"Forbidden\",\"message\":\"insufficient permissions for mcp access, got [hr-admin]\"}"}],
    "isError": true
  }
}

Removing a definition

Every write to a definition — create, replace, patch — is recorded, newest first, and readable up to the moment the definition itself is removed:

http
GET /minimal/rest/definition/v1/history?definition_id=01M1S96RTF5RT0F83B079JRH7B&ps=5&pg=0 HTTP/1.1
LB-Access-Token: <your-access-token>
json
[
  {
    "checksum": "c868f2ee2e5f600235691b87e0d88c15594ef9960eee2287ae2f154a0459a823",
    "commit_hash": "6a64f0e9",
    "definition_id": "01M1S96RTF5RT0F83B079JRH7B",
    "method": "get",
    "op_kind": "UPDATE",
    "op_ts": "2026-09-05T17:17:37.164428Z",
    "org_id": "acme",
    "project_id": "people",
    "status": "active",
    "touched_by": "acme-admin",
    "updated_at": "2026-09-05T17:17:37.164027Z",
    "uri": "acme/people/directory",
    "version": "1"
  }
]

The row set is fixed — fc does not apply here — and this is the most recent of several for a definition that has been patched more than once. format=yaml renders the same rows that way instead of JSON; unlike most listing routes elsewhere in this book, there is no CSV or XML option here.

Three ways to remove a definition, depending on what you are holding. Every one of them is final: once it is gone, so is this history — the same call against a removed definition's id answers 404 — so read or export what you might want again before you delete.

By identifier, the usual way once you have a definition_id:

http
DELETE /minimal/rest/definition/v1/01M2663DB9CY2ZFSX1C1V5NKN4 HTTP/1.1
LB-Access-Token: <your-access-token>

A definition that is not there answers 412 here — a third code for the same underlying condition: the call route above answers 417, the history read above answers 404, and this route answers 412. Read each route's own response codes rather than assuming one pairing holds everywhere, exactly as Essential Minimal, Chapter 8 found true of the rest of the API:

json
{
  "code": 412,
  "status": "Precondition Failed",
  "message": "definition not found"
}

By route, when you have the stored path, method and version instead:

http
DELETE /minimal/rest/definition/v1?uri=acme/people/noop&method=GET&version=9 HTTP/1.1
LB-Access-Token: <your-access-token>

This call behaves differently on a miss: a path, method or version that matches nothing is a success, not an error — {"rows_affected": 0} — so check that number rather than trusting the bare 200.

And in bulk, up to 500 ids in one call, all or nothing:

http
DELETE /minimal/rest/definition/v1/bulk HTTP/1.1
LB-Access-Token: <your-access-token>
Content-Type: application/json

{"definition_ids": ["01M1S96RTF5RT0F83B079JRH7B", "01ZZZZZZZZZZZZZZZZZZZZZZZZ"]}

If even one id in the list does not resolve, nothing in the batch is removed:

json
{
  "code": 412,
  "status": "Precondition Failed",
  "message": "one or more identifiers do not exist in this org, project and space: 01ZZZZZZZZZZZZZZZZZZZZZZZZ"
}

Run that same request with only valid ids and every one of them goes together, in one transaction.

Exercises

  1. Author a definition of your own: a GET endpoint at a path you choose, version 1, with a two-step waterfall — a SQL step against a table in your own database, marked omit, followed by a Lua or Starlark step that reshapes what the SQL step returned into a smaller object. Create it, then call it twice: once with an input that matches a real row, once with one that matches nothing, and confirm the two responses differ the way the chapter's worked example did.

  2. Take the definition you just created and open it to agents without touching its logic: patch its attributes to set mcp.enabled: true and list one role in mcp.mcp_roles. Read the definition back and confirm the change is visible, then export it and confirm the SQL and script steps are exactly what you wrote — nothing about the logic moved.

  3. Copy that definition into a second space. List the target space to find the copy's new definition_id, export it, and check what its identifiers.space_id says versus which space it actually lives in. Then remove the original by id, and read its history once before deleting it and once after — write down what changed about the second read.

2Modules

Suppose one endpoint's Lua step turns "Jane Doe" into "Doe, Jane" before handing the row back to the caller — a small formatting rule, a half-dozen lines. A second endpoint — a leave-request summary, say — needs the same formatting. Paste the same lines into its own step and you now have two copies of one idea: fix a bug in one, and the other still has it.

A module is Minimal's answer to that: Lua or Starlark source, stored once, that any definition's logic step can load by name. Create one, and it's available to every definition in that space from then on. The two languages get separate stores — a Lua module and a Starlark module can share a name without colliding — and which store a definition's step reads from is decided by that step's own type, lua or lark, not by anything on the module itself.

Creating a module

http
POST /minimal/code/v1/lua 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/yaml
yaml
identifiers:
  org_id: acme
  project_id: people
  space_id: dev
  user_id: acme-admin
module: people_format
code: |
  local M = {}

  function M.last_first(full_name)
    local first, last = full_name:match("^(%S+)%s+(.+)$")
    if not first then
      return full_name
    end
    return last .. ", " .. first
  end

  return M
json
{"rows_affected": 1}

201 Created.

module is the name and the only identifier — there's no separate id, and it must be unique in this organization, project and space. code is plain text: no base64, no escaping beyond what YAML itself needs. The identifiers block has to repeat the same four values as the headers byte for byte; get one wrong and the whole request is refused with 403 and the body "policy violation", before your source is even looked at. A caller whose role holds no write access to the project is refused the ordinary way instead — 403, naming the roles it saw — the ordinary project_write_roles gate every write in this book runs into, not something specific to modules.

The source is compiled before anything is stored. Send something that doesn't parse, and you get back the compiler's own complaint as plain text, not the usual JSON error shape:

text
"people_format.lua at EOF: syntax error"

406 Not Acceptable.

Creating is not idempotent — send the same module a second time and you get 417 with "module already exists". There's a separate call for replacing source; a create only ever makes something new.

Lua's own convention, followed above, is to build a table of functions and return it at the end. Nothing in Minimal requires that shape — a module is just Lua source — but it's what lets a caller write require("people_format").last_first(x) instead of guessing at bare global names.

The body above is YAML, and the block scalar under code: | is the natural way to keep multi-line source readable; a JSON body works exactly as well, since JSON is valid YAML, which matters if you're generating the request programmatically rather than typing it by hand. The whole request is capped at 4 MiB by default — generous for a script library, tight for anything that starts embedding data tables.

Reading it back

http
GET /minimal/code/v1/lua?module=people_format&format=json HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-Space-Id: dev
X-User-Id: acme-admin
X-User-Roles: hr-admin
json
{
  "org_id": "acme",
  "project_id": "people",
  "space_id": "dev",
  "module_name": "people_format",
  "module_code": "local M = {}\n\nfunction M.last_first(full_name)\n  ...\nend\n\nreturn M\n",
  "status": "active",
  "commit_hash": "b34c9ea6",
  "touched_by": "acme-admin",
  "created_at": "2026-09-10T17:35:43.253681Z",
  "updated_at": "2026-09-10T17:35:43.253681Z"
}

200 OK.

module_code comes back decoded, exactly as you sent it — nothing to unwrap. Leave off format and you get YAML, not JSON; among the read routes that take a format parameter at all, this is the one route that defaults away from JSON. A name that isn't live in this space answers 404 with an empty body — there's no message to read, just the status.

Listing modules

http
GET /minimal/code/v1/lua/list?format=json 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

The response is every Lua module in the space, each with its full source, in one array — no paging, no filtering, no ordering you can rely on. That's fine for a handful of modules, but a space that accumulates a hundred large ones should prefer the single-module read above once it knows the name it wants.

Using it from a definition

Register a definition of your own — call it roster — with a step that loads people_format instead of repeating its logic inline:

yaml
identifiers:
  org_id: acme
  project_id: people
  feature_id: roster
  space_id: dev
  user_id: acme-admin

permission:
  - hr-admin

http:
  uri: roster
  method: GET
  version: "1"
  input_type: JSON
  output_type: json

logic:
  - sql: |
      SELECT e.full_name, e.email, d.name AS department
      FROM employee e
      JOIN department d ON d.id = e.department_id
      WHERE e.is_active = true
      ORDER BY e.full_name
      LIMIT 5
    omit: true
  - lua: format_step
    code: |
      local fmt = require("people_format")

      function min_main()
        local out = {}
        for i, row in ipairs(step_0) do
          out[i] = { name = fmt.last_first(row.full_name), department = row.department }
        end
        return { employees = out }
      end

config:
  rollback_version: "1"
  coalesce: false
  cache_result: false

database:
  type: postgres
  username: minimalist
  password: "<your-db-password>"
  name: acme
  ssl_mode: ""
  timeout_secs: 30
  idle_timeout_secs: 600
  max_open_connections: 20
  max_idle_connections: 10
  query_timeout_secs: 60
  machine:
    - host: localhost
      port: "5433"

Register it the same way Chapter 1 did:

http
POST /minimal/rest/definition/v1 HTTP/1.1
LB-Access-Token: <your-access-token>
Content-Type: application/x-yaml

<the document above>
json
{
  "definition_id": "01M2663DB9CY2ZFSX1C1V5NKN5",
  "final_url": "acme/people/roster"
}

require here isn't Lua's own — the standard package library is never opened, since resolving names against the filesystem would let one tenant's script read files off the host. This require resolves the name against the module store instead, scoped to the same organization, project and space as the definition doing the loading. A module in staging is invisible to a definition running in dev, even with the identical name.

Call the endpoint and the module runs as part of the step:

http
GET /minimal/api/rest/v1/acme/people/roster?version=1 HTTP/1.1
LB-Access-Token: <your-access-token>
json
{
  "employees": [
    { "name": "Novak, Amara", "department": "Field Sales" },
    { "name": "Okonkwo, Amara", "department": "Security" },
    { "name": "Okonkwo, Amara", "department": "Design" },
    { "name": "Okonkwo, Amara", "department": "Design" },
    { "name": "Delacroix, Arun", "department": "People" }
  ]
}

200 OK. The three "Okonkwo, Amara" rows are three different employees — full_name is not unique — and the SQL step's ORDER BY e.full_name gives no tiebreaker between them, so which of the three comes back second, third or fourth is not guaranteed to repeat from one call to the next.

The sql step's rows arrive at the Lua step as step_0, exactly as Chapter 1 described the waterfall; people_format never sees the database at all, and the definition never repeats the formatting logic. A second definition that needs the same thing writes the same one line — local fmt = require("people_format") — instead of pasting the function body again.

A module lives in one space, same as a definition does, so promoting a definition the way Chapter 1 did — from dev to staging — does not bring its modules along. Copying a definition copies the YAML — the require line travels with it — but not the modules it names. people_format has to exist in staging too before the promoted definition's first call there, or that call fails at the require line with nothing in the definition itself to explain why.

flowchart LR
    D["logic step<br/>(lua or lark)"] -->|"require(name)<br/>load(name.star, symbol)"| R{"resolve within<br/>org · project · space"}
    R --> LU[("Lua module store")]
    R --> LK[("Starlark module store")]

A logic step's import is resolved against the module store scoped to its own organization, project and space; Lua and Starlark are separate stores under the same names.

Updating a module

Replacing source is a separate call — PUT, not POST — and it takes the whole module, not a patch:

http
PUT /minimal/code/v1/lua 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/yaml
yaml
identifiers:
  org_id: acme
  project_id: people
  space_id: dev
  user_id: acme-admin
module: people_format
code: |
  local M = {}

  function M.last_first(full_name)
    local first, last = full_name:match("^(%S+)%s+(.+)$")
    if not first then
      return full_name
    end
    return last .. ", " .. first
  end

  function M.initials(full_name)
    local out = {}
    for word in full_name:gmatch("%a+") do
      out[#out + 1] = word:sub(1, 1):upper()
    end
    return table.concat(out, ".")
  end

  return M
json
{"rows_affected": 1}

200 OK.

module has to name something that already exists — updating a name that isn't there answers 417 with "module does not exist" rather than creating it.

A compile error on update comes back as the normal JSON error shape:

json
{
  "code": 406,
  "status": "Not Acceptable",
  "message": "code failed to compile",
  "details": "..."
}

Same status as create's own compile failure, 406 — the two disagree only on the body's shape, not the code: create hands you the compiler's raw text instead of this JSON envelope.

The previous source isn't lost — it moves to the module's history, covered next — and a definition that calls people_format picks up initials on its very next invocation, with one caveat: a deployment running more than one server may take a little longer for every server to see the change.

Viewing history

http
GET /minimal/code/v1/lua/history?module=people_format&ps=10&pg=0 HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-Space-Id: dev
X-User-Id: acme-admin
X-User-Roles: hr-admin
json
[
  { "op_kind": "UPDATE", "op_ts": "2026-09-10T17:35:50Z", "commit_hash": "1b133bda", "module_code": "bG9jYWwgTSA9IHt9..." },
  { "op_kind": "INSERT", "op_ts": "2026-09-10T17:35:43Z", "commit_hash": "b34c9ea6", "module_code": "bG9jYWwgTSA9IHt9..." }
]

200 OK.

Three things differ from every other module call. ps and pg are both required — there's no default page, and omitting either is 406, not "give me page one." Newest first, so the INSERT that created the module is the last entry on the last page, however many UPDATEs sit above it. And module_code here is base64, not decoded text — a history row is a snapshot of exactly what was stored, and nothing decodes it for you.

Delete the module and its history stops being readable too, even though the archived rows still exist underneath — read the history first if you might want an old revision back.

Deleting a module

http
DELETE /minimal/code/v1/lua?module=people_format HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-Space-Id: dev
X-User-Id: acme-admin
X-User-Roles: hr-admin
json
{"rows_affected": 1}

200 OK.

Deleting a name that was never there is not an error — you still get 200, just with rows_affected: 0, so check the count rather than the bare status if you need to know whether anything actually went away. Nothing checks whether a definition still calls the module first: delete people_format while the roster endpoint still depends on it, and the next call to that endpoint fails at the require line. Confirm nothing still depends on a module before removing it.

Starlark: the one real difference

Everything above has a Starlark twin at /minimal/code/v1/lark instead of /minimal/code/v1/lua — same six calls, same shapes, same status codes, a completely separate store so a Lua module and a Starlark module can share a name without colliding. The one place the two genuinely diverge is how a logic step pulls a module in. Lua's require takes the bare name:

lua
local bands = require("acme_bands")

Starlark's own load() takes the name with a .star suffix, and names the specific symbols to import rather than handing back a table:

starlark
load("acme_bands.star", "classify")

A module can load another module, not just be loaded by a definition — acme_bands classifies a pay band from a number, and acme_payroll requires it in turn:

lua
-- acme_bands module
local M = {}
function M.classify(amount)
  if amount < 60000 then return "band-1" end
  if amount < 120000 then return "band-2" end
  return "band-3"
end
return M
lua
-- acme_payroll module, requiring acme_bands in turn
local bands = require("acme_bands")
local M = {}
function M.band_for(amount)
  return bands.classify(amount)
end
return M

A Lua compile error's message names the module that failed (people_format.lua at ...); a Starlark parse error's message doesn't — it always reports the placeholder chunk name test.star, regardless of what you called the module. Read the line and column, not the filename, when a Starlark module fails to parse.

Modules over MCP

The same six operations exist as MCP tools, one set per language — create_lua_module, get_lua_module, list_lua_modules, update_lua_module, get_lua_module_history, delete_lua_module, and the _lark_module equivalents. Each call needs a session already open, same as any other tool; your organization, project and space come from the credential, not from an argument.

Tool create_lua_module:

json
{
  "module": "acme_greeting",
  "code": "local M = {}\nfunction M.hello(name)\n  return \"hello, \" .. name\nend\nreturn M\n"
}
json
{"rows_affected": 1}

Tool get_lua_module_history:

json
{
  "module": "acme_greeting",
  "page_size": 10,
  "page_number": 0
}
json
[
  {"op_kind": "INSERT", "op_ts": "2026-09-10T13:51:39Z", "module_code": "bG9jYWwgTSA9IHt9..."}
]

The arguments carry only what the REST body's identifiers and headers together supplied — no org, project or space to repeat, since the session already knows them. Everything else — the compile-before-store behavior, the whole-body replace on update, the base64 in history, the rows_affected: 0 on a delete that found nothing — is identical to the REST route it fronts, because it's the same call underneath.

Exercises

  1. Write a Lua module that masks an email address — "rafael@acme.example" becomes "r***@acme.example" — under a name of your choosing. Create it, read it back and confirm module_code matches exactly what you sent, then delete it and confirm its history route now answers 404.

  2. Take the roster-style definition from this chapter (or Chapter 1's directory endpoint) and add a step that requires a module of your own. Call the endpoint once and note the output. Now update the module — without touching the definition at all — so it produces a visibly different result, and call the same endpoint again. Confirm the response changed, then check the module's history and identify which entry is the INSERT and which is the UPDATE.

3DDL: Databases, Runs, and Locks

Every project in Minimal addresses one or more databases — the actual Postgres, MySQL, MariaDB or ClickHouse instances holding a tenant's tables. Someone has to bring those databases into existence, change their schema over time, and eventually retire them. The DDL family is where that happens: not by handing someone a psql prompt, but as API calls with their own audit trail, their own safety rules, and their own locking.

Two surfaces reach it. Over REST, every route in this chapter lives under /minimal/api/rest/ddl/v1/{org}/{project}/, where {org} and {project} are the organization's and project's lower-case abbreviations. Over MCP, the same operations are tools whose names start with ddl_. The examples below show both, side by side, for a project called people under an organization called acme.

Everything in this family works against a database the project has already registered — a name the project chose, not the host it lives on. Registering one is the first thing this chapter covers.

Registering a database

Bringing a new database under a project's control is two operations disguised as one call: issuing CREATE DATABASE against a server, and telling the project it may now address that database by name. Minimal keeps the two apart on purpose, because the account that may create a database and the account a project uses every day should not be the same thing.

A request to create a database carries two connection blocks:

  • create — a privileged account, used exactly once, to issue the creation itself.
  • register — an ordinary account, stored (encrypted) on the project, and used for every later request against this database: schema runs, catalog reads, and every row the Auto API ever touches.

Nothing in register falls back to create. Both blocks need their own hosts, username and password written out in full, because the point is that the privileged creating account is never the one that gets stored.

http
POST /minimal/api/rest/ddl/v1/acme/people/database HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-User-Id: acme-admin
X-User-Roles: hr-admin
Content-Type: application/x-yaml
yaml
database:
  type: postgres
  name: mcp_ddl_test
create:
  host_details:
    - host: db-primary.internal
      port: "5432"
  username: pg_admin
  password: <privileged-account-password>
register:
  host_details:
    - host: db-primary.internal
      port: "5432"
  username: pg_app
  password: <application-account-password>

A successful call answers 201, with a JSON body whatever the request's own content type was:

json
{
  "run_id": "01M25S5NQPRZ4GA4DTDJW3ZXC9",
  "status": "succeeded",
  "database": {
    "type": "postgres",
    "name": "mcp_ddl_test",
    "character_set": "UTF8"
  },
  "registered": true,
  "started": "2026-09-10T13:47:31.448936Z",
  "ended": "2026-09-10T13:47:31.519812Z"
}

character_set is echoed back even when you sent nothing, so a caller who left it out learns what it resolved to — UTF8 on Postgres, utf16 on MySQL and MariaDB, nothing at all on ClickHouse, which has no such concept on CREATE DATABASE.

Over MCP, ddl_create_database takes the same two blocks, flattened into the tool's top-level arguments instead of nested under a database: key:

Tool: ddl_create_database
json
{
  "type": "postgres",
  "name": "mcp_ddl_test",
  "create": {
    "host_details": [{ "host": "db-primary.internal", "port": "5432" }],
    "username": "pg_admin",
    "password": "<privileged-account-password>"
  },
  "register": {
    "host_details": [{ "host": "db-primary.internal", "port": "5432" }],
    "username": "pg_app",
    "password": "<application-account-password>"
  }
}

The result carries the same fields the REST response does. Creating and registering happen as one operation: there is no window in which the database exists on the server but the project cannot yet address it — with one exception. If the CREATE DATABASE succeeds but the registration write then fails, the call answers 500 naming dba_attention_required, because at that point the database is real and retrying will only try to create it again against a name that is now taken. That is a case for a person, not a retry loop.

Creating a database checks a role from database_create_roles — read from the organization, not the project. Registering databases is an organization-level decision in Minimal's role model, even though the database itself belongs to one project.

database_create_roles and database_drop_roles, on the organization, and schema_write_roles and schema_read_roles, on the project, all start out granted to nobody — a freshly created organization or project answers every DDL caller with a refusal until somebody sets one of these columns. They are absent from the ordinary create and update calls Volume 1, Chapter 3 covers — sending them there is refused, naming this section's routes instead — and can only be populated by a caller holding the deployment's own server key, sys:-prefixed:

http
PATCH /minimal/system/api/v1/org/ddl/roles HTTP/1.1
X-Org-Id: acme
X-User-Id: sys:provisioning-agent
X-Server-Key: <the deployment's server key>
Content-Type: application/json

{"database_create_roles": "hr-admin", "database_drop_roles": "hr-admin"}

The project-level pair is set the identical way, at PATCH v1/project/ddl/roles. Both routes take the role-grant fields and nothing else — a body carrying any other field, name or status included, is refused with 400 rather than applying the role change alongside it.

Reading the catalog

Minimal keeps its own index of what tables exist, and that index can lag behind the live catalog until something re-indexes it. The routes in this section are a different thing entirely: they ask the database itself what is actually there, right now, whether or not Minimal has ever indexed it.

Listing registered databases answers what a project has told Minimal about — names, hosts, pool settings, never a password. A successful call answers 200:

http
GET /minimal/api/rest/ddl/v1/acme/people/database?ps=20&pg=0 HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-User-Id: acme-admin
X-User-Roles: hr-admin
json
[
  {
    "type": "postgres",
    "name": "acme",
    "username": "minimalist",
    "hosts": [{ "host": "localhost", "port": "5433" }],
    "ssl_mode": "",
    "pool": {
      "connect_timeout_secs": 30,
      "idle_timeout_secs": 600,
      "query_timeout_secs": 60,
      "max_open_connections": 20,
      "max_idle_connections": 25
    }
  }
]

Add probe=true and every entry also carries a probe block reporting whether the database answered when Minimal tried it just now — reachable, exists, checked_at. Probing never deregisters anything; an unreachable database is reported, not removed. Over MCP the same data comes from ddl_list_registered_databases, paged the same way.

Listing objects goes straight to the database's own catalog for one registered database — tables, views, materialized views, sequences, functions, procedures, triggers — including objects Minimal has never been told about. A successful call answers 200:

http
GET /minimal/api/rest/ddl/v1/acme/people/objects?type=postgres&name=acme&ps=50&pg=0&object_type=TABLE HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-User-Id: acme-admin
X-User-Roles: hr-admin
json
[
  {
    "schema": "public",
    "name": "department",
    "object_type": "TABLE",
    "owner": "minimalist",
    "persistence": "permanent",
    "access_method": "heap",
    "size_bytes": 57344,
    "size": "56 kB",
    "description": null,
    "governed": true
  },
  {
    "schema": "public",
    "name": "employee",
    "object_type": "TABLE",
    "owner": "minimalist",
    "persistence": "permanent",
    "access_method": "heap",
    "size_bytes": 90112,
    "size": "88 kB",
    "description": null,
    "governed": true
  }
]

governed says whether Minimal already holds a governance row for that object — the thing a permission template and a lock mask attach to, and what decides whether the Auto API can serve it at all. An object the catalog can see but Minimal has never indexed comes back with governed: false, not an error. Filtering with governed=true or governed=false narrows the same list; object_type narrows it to one kind, case-insensitively, and asking for a kind a database cannot hold (TRIGGER against a database with none registered as objects) is a legitimate question that answers [], not a rejection.

Describing one object gets everything about it — columns, indexes, foreign keys, what references it, check constraints, triggers — plus the governance Minimal holds over it. A successful call answers 200:

http
GET /minimal/api/rest/ddl/v1/acme/people/objects/detail?type=postgres&name=acme&object=employee&schema=public HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-User-Id: acme-admin
X-User-Roles: hr-admin

schema here names a namespace inside the registered database — Postgres's public, for instance — which is a different thing from the schema query parameter Volume 1's meta_* tools and Auto API use, which names the registered database itself. The two routes reach two different levels of the same hierarchy under the same parameter name.

json
{
  "database": { "type": "postgres", "name": "acme" },
  "schema": "public",
  "object": "employee",
  "object_type": "TABLE",
  "columns": [
    { "name": "id", "type": "integer", "nullable": false, "has_default": true,
      "default": "nextval('employee_id_seq'::regclass)", "generated": null, "ordinal": 1 },
    { "name": "full_name", "type": "character varying(120)", "nullable": false,
      "has_default": false, "default": null, "generated": null, "ordinal": 2 },
    { "name": "department_id", "type": "integer", "nullable": false,
      "has_default": false, "default": null, "generated": null, "ordinal": 4 },
    { "name": "is_active", "type": "boolean", "nullable": false,
      "has_default": false, "default": null, "generated": null, "ordinal": 10 }
  ],
  "indexes": [
    { "name": "employee_pkey", "primary": true, "unique": true, "columns": ["id"], "method": "btree" },
    { "name": "idx_employee_department", "primary": false, "unique": false,
      "columns": ["department_id"], "method": "btree" }
  ],
  "foreign_keys": [],
  "referenced_by": [],
  "check_constraints": [],
  "triggers": [],
  "governance": {
    "governed": true,
    "table_type": "TABLE",
    "permission_template_id": "01M1S96P76FQDPNYBV1RFSY8KH",
    "lock_mask": 3842,
    "governance_recorded_at": "2026-09-05T17:17:38.440748Z"
  }
}

Every list in this response distinguishes null from [], and the difference carries meaning: null means the database has no such concept at all, an empty array means the database has the concept and this object simply has none. A columns[].generated of null means an ordinary column; identity means the database fills it; stored means it is computed — both of the latter mean you may not supply a value for it yourself. When a name matches more than one object across schemas, the route answers 409 and lists every candidate as schema.name (kind) so you can retry with schema= set. Over MCP, ddl_list_objects and ddl_describe_database_object return the same shapes; one difference: these two tools take the database's full type name (postgres, mysql, mariadb, clickhouse), while meta_list_tables and every auto_* row tool take a two-letter code (pg, ms, ma, ch) for the same database type. meta_list_databases is where that code comes from; it is a narrower, gated view of the same registrations this chapter's routes see in full.

A function or procedure describes differently from a table, view, or materialized view. The relation-shaped fields — columns, indexes, foreign_keys, referenced_by, check_constraints, triggers — stay null rather than [], because a routine genuinely has none of those concepts to report; in their place, a routine block carries its language, return type (null for a procedure, which returns nothing), parameter list with each one's mode, and its own source text — readable by anyone holding schema_read_roles, the same as a view's defining SQL is. A table, view, or materialized view never carries a routine block at all; a routine never carries the relation-shaped fields. The two never overlap on one object.

All three catalog routes require schema_read_roles — read from the project this time, not the organization, and deny-by-default: a project that has never granted the column refuses every caller.

Running a schema change

A schema change is a list of SQL statements, submitted together, run in the order you sent them, against one registered database. The body is YAML rather than JSON — JSON is accepted because it is valid YAML, but the statement list is multi-line SQL, and a YAML block scalar carries it more readably than an escaped JSON string would.

http
POST /minimal/api/rest/ddl/v1/acme/people/run HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-User-Id: acme-admin
X-User-Roles: hr-admin
Content-Type: application/x-yaml
yaml
database:
  type: postgres
  name: mcp_ddl_test
sql:
  - "CREATE TABLE scratch_note (id serial primary key, note text not null)"
  - "ALTER TABLE scratch_note ADD COLUMN created_at timestamptz NOT NULL DEFAULT now()"
on_error: stop
comment: "Adding a scratch table for a reporting experiment"

This targets mcp_ddl_test, the scratch database registered earlier in this chapter — a schema-change run reaches whatever registered database you name, so running this against your own project's real, production database would genuinely add these columns to it.

Every statement is checked before any of them execute, so a rejected body leaves the database untouched. One array item is exactly one statement: an item holding more than one statement, a comment, an unterminated quote, or a multi-action ALTER is refused, and so is one beginning with a transaction or session verb (BEGIN, SET, USE, and their relatives) — a run manages its own transaction, and letting a statement change session state out from under it would be its own kind of bug. A statement containing DROP DATABASE, on any of the four database types, is refused outright: dropping a database is not a schema change, it is a lifecycle event with its own route and its own safeguards, which is what the rest of this chapter is building toward.

json
{
  "run_id": "01M1S96N8QCM689B4QEY7Z3XTD",
  "status": "succeeded",
  "statement_count": 2,
  "executed": 2,
  "started": "2026-09-05T17:17:33.201118Z",
  "ended": "2026-09-05T17:17:33.369736Z",
  "reindex": true,
  "statements": [
    { "idx": 0, "status": "succeeded", "verb": "CREATE TABLE", "target": "scratch_note",
      "role_column": "schema_write_roles", "duration_us": 4210, "duration": "4.21ms" },
    { "idx": 1, "status": "succeeded", "verb": "ALTER TABLE", "target": "scratch_note",
      "role_column": "schema_write_roles", "duration_us": 1870, "duration": "1.87ms" }
  ]
}

role_column names which role list actually gated this statement — schema_write_roles, read from the project, the same column every write in this chapter checks. It is echoed per statement rather than once for the whole run because a future statement type could plausibly check a different column; today, every statement checks the same one.

A run that fails still answers 200 — read status to find out what happened, and read each statement's own status. on_error: stop (the default) marks every statement after the first failure skipped; on_error: continue runs them all and reports each outcome separately, which is the shape you want when you are applying a batch of independent changes and would rather see every failure than stop at the first.

reindex defaults to true, and it changes exactly one thing: what the response's own reindex field reports back — true when you left it alone, false when you set it to skip. Nothing else about the call changes because of it; a table this run just created is not re-cataloged on the strength of this flag, so it can still come back governed: false from the objects listing above until something re-indexes the database. The submitted SQL text is not echoed back in this response; it lives on the run record instead.

Reading one run back by its id is how you get that SQL text later:

http
GET /minimal/api/rest/ddl/v1/acme/people/run/01M1S96N8QCM689B4QEY7Z3XTD HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-User-Id: acme-admin
X-User-Roles: hr-admin

A successful read answers 200, carrying the same summary fields plus a statements array where each entry adds statement_text — the exact SQL as submitted — along with rows_affected and purged_rows where the database reported them:

json
{
  "run_id": "01M1S96N8QCM689B4QEY7Z3XTD",
  "kind": "statement",
  "org_id": "acme",
  "project_id": "people",
  "db_type": "postgres",
  "db_name": "mcp_ddl_test",
  "status": "succeeded",
  "statement_count": 2,
  "executed": 2,
  "started": "2026-09-05T17:17:33.201118Z",
  "ended": "2026-09-05T17:17:33.369736Z",
  "comment": "Adding a scratch table for a reporting experiment",
  "statements": [
    { "idx": 0, "status": "succeeded", "verb": "CREATE TABLE", "target": "scratch_note",
      "statement_text": "CREATE TABLE scratch_note (id serial primary key, note text not null)",
      "role_column": "schema_write_roles", "rows_affected": null, "purged_rows": null,
      "error_text": null, "duration_us": 4210, "duration": "4.21ms" },
    { "idx": 1, "status": "succeeded", "verb": "ALTER TABLE", "target": "scratch_note",
      "statement_text": "ALTER TABLE scratch_note ADD COLUMN created_at timestamptz NOT NULL DEFAULT now()",
      "role_column": "schema_write_roles", "rows_affected": null, "purged_rows": null,
      "error_text": null, "duration_us": 1870, "duration": "1.87ms" }
  ]
}

rows_affected and purged_rows are both null here because CREATE TABLE and ALTER TABLE neither affect existing rows nor discard any — a statement that rewrites or drops data would report a real count in one or the other. This is where a migration applied months ago can still be read back verbatim. Anyone holding schema_read_roles can read that text, including anything sensitive a statement happens to embed.

Listing runs finds them without knowing an id up front, newest first, with optional filters on database type, database name, outcome and idempotency_key — the retry key you may have sent on the original run call, for finding the run a given retry actually landed as. A successful call answers 200:

http
GET /minimal/api/rest/ddl/v1/acme/people/run?ps=10&pg=0 HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-User-Id: acme-admin
X-User-Roles: hr-admin
json
[
  { "run_id": "01M25RR3533Q3NFY0SD4BAFDHA", "kind": "database", "db_name": "mcp_ddl_test",
    "status": "succeeded", "statement_count": 1, "executed": 1 },
  { "run_id": "01M1S96NC212Q5QWJA2QD5N69T", "kind": "statement", "db_name": "acme",
    "status": "succeeded", "statement_count": 1, "executed": 1 },
  { "run_id": "01M1S96N8QCM689B4QEY7Z3XTD", "kind": "statement", "db_name": "mcp_ddl_test",
    "status": "succeeded", "statement_count": 2, "executed": 2 }
]

kind is what tells the two families of run apart in one list: statement for a schema change submitted through run, database for a create or drop that went through the lifecycle routes covered later in this chapter. Both leave a row here. Paging is mandatory — omitting page size or page number is a 406, not an unpaged listing — and a filter value outside its allowed set is refused rather than quietly answered with an empty page, so an empty array here always means "nothing matched," never "you asked a malformed question."

Over MCP, the three operations are ddl_execute_run, ddl_get_run and ddl_list_runs, with arguments matching the REST fields one for one — ddl_execute_run takes type, name and sql as flat top-level arguments rather than a nested database: block, the same flattening every ddl_* tool applies to REST's database.type / database.name pair.

The run lock

A run holds an exclusive lock on the database it targets for as long as it takes to execute. A second run submitted against the same database while the first is still going is refused, not queued — it answers 409, and the body names which run currently holds the lock, who started it, and when.

sequenceDiagram
    participant A as Run A
    participant Lock as database lock
    participant B as Run B
    A->>Lock: acquire (run_id A)
    activate Lock
    B->>Lock: acquire (run_id B)
    Lock-->>B: 409 — held by run A
    A->>Lock: release (run A completes)
    deactivate Lock
    B->>Lock: acquire (run_id B)
    Lock-->>B: granted

Two schema changes racing for the same database: the second waits out the first rather than interleaving with it.

The point of the lock is exactly that: two schema changes against the same database must never interleave. A caller that gets 409 has a straightforward answer — wait for the named run to finish, then resend.

Occasionally the process running a schema change dies mid-run, and the lock outlives it. That is what the release route is for — recovery, not a way past a run that is genuinely still going:

http
DELETE /minimal/api/rest/ddl/v1/acme/people/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/x-yaml
yaml
database:
  type: postgres
  name: mcp_ddl_test
run_id: 01M1S96N8QCM689B4QEY7Z3XTD

run_id is required, deliberately — the point of asking for it is that a release can never be issued blind. The value you name has to come from somewhere: usually the 409 that blocked you a moment earlier. Three answers mean three different things. 200 means it released:

json
{
  "released": true,
  "database": { "type": "postgres", "name": "mcp_ddl_test" },
  "run_id": "01M1S96N8QCM689B4QEY7Z3XTD",
  "locked_by": "acme-admin",
  "acquired": "2026-09-05T17:12:10Z",
  "held_for": "5m23s"
}

404 means no lock was held on that database at all — a lock already released answers this way, not as an error, so retrying a release you are unsure about is harmless. 409 means the lock is real but does not match what you asked for: either a different run holds it than the one you named, or — the case this route exists to prevent — the named run genuinely is still executing, and the message says who started it rather than releasing a live run out from under its own caller. The MCP equivalent is ddl_release_run_lock, taking the same type, name and run_id. Releasing requires schema_write_roles, the same column that let the original caller take the lock in the first place.

Dropping a database

Everything in the run route above is careful and reversible, the way schema changes ordinarily are — check first, execute in order, report each statement's outcome. A database drop is not that kind of operation. It destroys every table inside it, permanently, and there is no compensating call that gets any of it back. That is why the statement classifier refuses DROP DATABASE on every database type when it shows up inside a schema-change run: this needs a different route, with a different kind of gate.

The gate is two confirmation tokens, minted a minimum interval apart, both spent together. Minting the first one tells you plainly that one is not enough, and a successful mint answers 201:

http
POST /minimal/api/rest/ddl/v1/acme/people/database/drop-token HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-User-Id: acme-admin
X-User-Roles: hr-admin
Content-Type: application/x-yaml
yaml
database:
  type: postgres
  name: mcp_ddl_test
json
{
  "token": "ddt_<opaque-token-value>",
  "database": { "type": "postgres", "name": "mcp_ddl_test" },
  "created": "2026-09-10T13:47:36.417636Z",
  "expires": "2026-09-10T14:47:36.417636Z",
  "pair": {
    "have_usable_pair": false,
    "reason": "this is the only live token for this database",
    "second_mint_allowed_from": "2026-09-10T14:10:36.417636Z"
  }
}

Read the pair block rather than assuming numbers: this deployment enforces a 60-minute token lifetime and a 23-minute minimum gap between the two mints, and both are deployment configuration that can differ elsewhere. have_usable_pair: false says outright that one token cannot be spent alone. second_mint_allowed_from says the earliest moment a second mint would actually produce a usable pair — here, 23 minutes after the first. Minting a second token before that moment does not get you anywhere faster: it just produces another token that still cannot pair with the first.

flowchart LR
    M1["mint drop-token #1<br/>13:47:36"] -->|"wait ≥ 23 min<br/>second_mint_allowed_from"| M2["mint drop-token #2<br/>14:10:36 or later"]
    M2 --> SPEND["spend both tokens<br/>DELETE .../database"]
    SPEND --> DROPPED["database dropped<br/>governance rows purged"]
    M1 -.->|"token 1 expires 60 min after minting<br/>14:47:36 — drop_window_closes"| CLOSED["drop window closes"]
    SPEND -.->|"must land before"| CLOSED

The drop-token timeline: two mints separated by a mandatory gap, both tokens spent in one call, before the earlier token expires.

Wait out the gap, then mint the second token — also 201 on success. Mint it at the earliest moment the first response allowed (second_mint_allowed_from, above), and its pair block now reports the other side of the same picture:

json
{
  "token": "ddt_<opaque-token-value>",
  "database": { "type": "postgres", "name": "mcp_ddl_test" },
  "created": "2026-09-10T14:10:36.417636Z",
  "expires": "2026-09-10T15:10:36.417636Z",
  "pair": {
    "have_usable_pair": true,
    "reason": "a second token has now been minted at least 23 minutes after the first",
    "drop_window_closes": "2026-09-10T14:47:36.417636Z"
  }
}

second_mint_allowed_from is gone; drop_window_closes has taken its place, and it is the earlier token's expiry, not the later one's — because that is the token that runs out first, and once it does the pair stops being spendable together no matter how fresh the second one still is. In this example, the window to actually call the drop closes at 14:47:36, 37 minutes after the second token was minted.

Spending both tokens is the drop itself. It needs its own privileged connection too, for the same reason database creation does: the account a project uses for everyday traffic generally cannot drop the database it lives inside.

http
DELETE /minimal/api/rest/ddl/v1/acme/people/database HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-User-Id: acme-admin
X-User-Roles: hr-admin
Content-Type: application/x-yaml
yaml
database:
  type: postgres
  name: mcp_ddl_test
confirm_tokens:
  - "ddt_<token-1-value>"
  - "ddt_<token-2-value>"
drop:
  host_details:
    - host: db-primary.internal
      port: "5432"
  username: pg_admin
  password: <privileged-account-password>
  db_name: postgres
comment: "Retiring the scratch database from last week's experiment"

The body is required on this DELETE, and that is deliberate: the two tokens are bearer secrets, and a path segment or a query string would put them in every access log and proxy trace between here and the server. drop.db_name names a different database on the same server to connect through while dropping — you cannot drop the database your own connection is addressing.

A successful drop answers 200, in the same run-record shape the earlier calls in this chapter use — run_id, status, database, started, ended — plus a purged block, one count per kind of governance data the drop wipes out: how many governed-table rows Minimal held for objects inside that database, and how many of the database's own still-live drop tokens went with it. Nothing else records that those rows ever existed, so this answer is the only place that count is ever reported.

Presenting the two tokens too close together — before second_mint_allowed_from — answers 409:

json
{
  "code": 409,
  "status": "Conflict",
  "message": "the two tokens were minted less than 23 minutes apart"
}

The database is left untouched, and the same two tokens (or a fresh pair, once the gap has genuinely passed) can be resubmitted. A token that is unknown, already spent, expired, or minted for a different database gets the same 409 message regardless of which of those it is — deliberately, so a caller probing at tokens cannot learn which one was wrong.

None of this is a rate limit to be worked around by minting several tokens in advance, or a bug that happens to make dropping a database annoying. It is the one place in this whole family where the safeguard is time itself: no role, no retry and no cleverness shortens the gap, because the gap is the entire point. A caller who genuinely means to drop a database will still mean it 23 minutes later; one acting on a mistaken keystroke, a bad script, or a copy-pasted request against the wrong environment gets a window to notice before anything is gone. Minting the first token costs nothing and commits to nothing — it is safe to do the moment you are fairly sure, and decide for certain while the clock runs.

Both minting and dropping check database_drop_roles on the organization — the same column, so the tokens are a second factor against haste, not a second privilege. A caller who cannot drop a database cannot mint a token toward dropping one either. Over MCP, the two tools are ddl_mint_drop_token and ddl_drop_database, taking the same arguments as their REST counterparts; a minted token is recorded to the calling session sealed, since it is exactly the kind of secret a session transcript should not carry in the clear.

Exercises

  1. Register a scratch database on a server you control, using separate create and register accounts. List your project's registered databases and confirm the new one appears with the stored username — and no password field anywhere in the response. Run a schema change that creates one table with at least three columns, then read that run back by its id and confirm the exact SQL you submitted comes back in statement_text.

  2. Submit a run with two statements where the second is deliberately invalid (reference a column that does not exist, say) — once with on_error: stop and once with on_error: continue. Compare the two statements arrays and explain, in your own words, what each statement's status tells you in each case, and why a caller would ever prefer continue.

  3. Mint a drop token for a database you are willing to lose, and immediately try to mint a second one and drop with both. Show the exact response you get and explain, from the pair block of the first mint, why it happened. Then wait out the real gap, mint the second token, and complete the drop — noting the two times, from your own tokens' created and expires fields, that bound the window in which the drop had to land.

4Row-Level Security and Advanced Table Control

Chapter 6 of the essentials volume gave a table three gates: a permission template, which says which roles may create, read, update or delete on that table; a lock mask, which says whether anyone may do those things at all, independent of role; and visibility, which says whether the table is offered at all. Between them they answer "who," "whether," and "does it exist to ask about." None of them answers "which rows." Two callers holding the identical role, reading the identical table, may need to see different data — a manager reading only their own department's staff, a tenant reading only its own records. This chapter adds that missing layer, then covers the two administrative problems that show up once a project has more than a handful of tables: changing permissions or locks on many tables in one call instead of one at a time, and deliberately hiding a table from the API surface without touching anything about it — the same visibility mechanism Chapter 6 introduced, extended here with what it means alongside a row scope.

Row-level security

Row-level security binds one column on a table to one request header. Once bound, every Auto API call against that table is filtered to the rows where the column equals whatever value the caller sent on that header. An insert cannot escape the binding either — the column is set from the header regardless of what the request body contains. Setting this is a write, so it needs the same project_write_roles role a lock or a permission assignment needs; reading the current binding only needs project_read_roles.

This is a third, independent layer. A lock still decides whether the operation is possible at all; a template still decides whether the caller's role may perform it. Row scope only decides, once those two have already let the request through, which of the table's rows it actually touches:

flowchart TD
    A[Auto API request] --> B{Lock mask blocks<br/>this verb on this channel?}
    B -- yes --> R1[403 refused]
    B -- no --> C{Caller's role holds<br/>this verb in the table's<br/>permission template?}
    C -- no --> R2[403 refused]
    C -- yes --> D{Table is row-scoped?}
    D -- no --> E[Every row, subject<br/>to the template]
    D -- yes --> F[Only rows where the scope<br/>column equals the header's value]

A request narrows in three stages before it reaches data: lock, then template, then row scope.

Worked example: scoping a view by department

Acme's v_employee_directory view carries a department column — plain text, one of "Finance," "Product," "Support," and so on. Scope it to that column, bound to a header named X-Department, and the same read returns a different table depending on which department a request names.

http
PUT /minimal/system/api/v1/project/table/rls 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": "v_employee_directory",
  "rls_column_name": "department",
  "rls_header_key": "X-Department"
}

200 OK

json
{
  "org_id": "acme",
  "project_id": "people",
  "db_type": "postgres",
  "db_name": "acme",
  "table_name": "v_employee_directory",
  "rls_column_name": "department",
  "rls_header_key": "X-Department",
  "updated_at": "2026-09-10T17:31:45.009078Z"
}

Now read the same route with X-Department: Finance:

http
GET /minimal/api/rest/auto/v1/acme/people/pg/acme/v_employee_directory?ps=5&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
X-Department: Finance

200 OK

json
[
  {
    "id": 2,
    "full_name": "Rafael Müller",
    "department": "Finance",
    "title": "Manager",
    "email": "rafael.muller2@acme.example",
    "cost_centre": "CC-1042",
    "manager_name": "Rafael Delacroix"
  },
  {
    "id": 9,
    "full_name": "Rafael Müller",
    "department": "Finance",
    "title": "Specialist",
    "email": "rafael.muller9@acme.example",
    "cost_centre": "CC-1042",
    "manager_name": "Kwame Haddad"
  },
  {
    "id": 17,
    "full_name": "Arun Silva",
    "department": "Finance",
    "title": "Analyst",
    "email": "arun.silva17@acme.example",
    "cost_centre": "CC-1042",
    "manager_name": "Rafael Delacroix"
  },
  {
    "id": 54,
    "full_name": "Priya Okonkwo",
    "department": "Finance",
    "title": "Specialist",
    "email": "priya.okonkwo54@acme.example",
    "cost_centre": "CC-1042",
    "manager_name": "Chidi Fernandes"
  },
  {
    "id": 89,
    "full_name": "Rafael Petrov",
    "department": "Finance",
    "title": "Lead",
    "email": "rafael.petrov89@acme.example",
    "cost_centre": "CC-1042",
    "manager_name": "Lars Haddad"
  }
]

Send the identical request again, changing only the header to X-Department: Product:

200 OK

json
[
  {
    "id": 5,
    "full_name": "Rafael Haddad",
    "department": "Product",
    "title": "Manager",
    "email": "rafael.haddad5@acme.example",
    "cost_centre": "CC-1035",
    "manager_name": "Rafael Novak"
  },
  {
    "id": 38,
    "full_name": "Lars Rossi",
    "department": "Product",
    "title": "Manager",
    "email": "lars.rossi38@acme.example",
    "cost_centre": "CC-1035",
    "manager_name": "Chidi Silva"
  },
  {
    "id": 46,
    "full_name": "Chidi García",
    "department": "Product",
    "title": "Staff Engineer",
    "email": "chidi.garcia46@acme.example",
    "cost_centre": "CC-1035",
    "manager_name": "Oliver Silva"
  },
  {
    "id": 68,
    "full_name": "Diego Ahmed",
    "department": "Product",
    "title": "Lead",
    "email": "diego.ahmed68@acme.example",
    "cost_centre": "CC-1035",
    "manager_name": "Diego O'Brien"
  },
  {
    "id": 78,
    "full_name": "Priya Bergström",
    "department": "Product",
    "title": "Senior Engineer",
    "email": "priya.bergstrom78@acme.example",
    "cost_centre": "CC-1035",
    "manager_name": "Diego García"
  }
]

Every field on the request is identical except the value of one header, and no department=eq.… filter was sent. Omit X-Department entirely and the table refuses to answer at all:

json
{
  "code": 403,
  "status": "Forbidden",
  "message": "this table is row-scoped and the X-Department header carries the scope, but the request did not send it"
}

This is a 403, not the 400 a missing header normally gets — a scoped table treats a missing or empty scope header as a caller with no identity to filter on, closer to "your role does not cover this" than to "you told us nothing about who you are," and it is refused accordingly.

Reading the binding back is the same route as a GET, and clearing it is the same route as a DELETE — no body, both halves of the scope, cleared together:

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

Both answer 200 with the same flat shape the PUT returns; after the DELETE, rls_column_name and rls_header_key both come back as empty strings, and the view answers every caller with every row again.

What a caller has to know before setting one

Every header on this API is sent by the caller, so a caller sending a raw header chooses its own scope — nothing stops the same caller who read Finance above from sending X-Department: Product on the very next request. A scope becomes a boundary between two different callers only where an access token supplies the header's value, because a token overwrites whatever identity headers a request sent with its own owner's. Scoping a table on a header an access token controls keeps one caller out of another's rows; scoping it on a header callers set directly, as the worked example above does, only lets each caller choose which rows they currently want to see.

rls_column_name and rls_header_key are required together — a table cannot carry half a scope. The column must be a real, text-typed column on the table; a numeric one is refused, because the filter always compares the header's value as a quoted string, and a numeric column would have that string read back as a number instead:

json
{
  "code": 400,
  "status": "Bad Request",
  "message": "hike.employee_id is integer, and a scope column has to hold text: the query path compares it against a header value written as a quoted string, and a numeric column makes the engine read that string as a number instead. Scope on a text column that holds the same identity"
}

The column's name is checked too, against the same reserved words Auto API's filters reserve — eq, ps, or, and the rest — because a column no caller could ever name in a filter would be unreachable through the very API row-level security exists to narrow:

json
{
  "code": 400,
  "status": "Bad Request",
  "message": "\"eq\" is a reserved query keyword, so a caller could never filter on that column; scoping a table on it would leave it unqueryable"
}

The header must be a syntactically valid HTTP header name, and it cannot be one the platform already gives a meaning to — X-Org-Id, X-Project-Id, X-Space-Id, X-User-Roles, X-Request-Id, X-Session-Id, LB-Access-Token, Authorization, Proxy-Authorization and Cookie are all refused. X-User-Id is the one identity header that is explicitly allowed, and is the usual choice when the scope really is "the caller's own rows."

A scope binds one cataloged object, not the rows underneath it. v_employee_directory and the employee table it is built from are two separate catalog entries; scoping one does nothing to the other. A view built over a scoped table is a new object and starts unscoped.

Nothing in a table's data or its response headers says it is row-scoped — the only way to find out is to read the scope back through this same route. A table that is currently hidden answers the read exactly as an unindexed one does, but a scope can still be set or cleared on a hidden table's underlying catalog entry — except that setting one requires reading the table's columns to check the named column exists and is text, and a hidden table's columns cannot be read. Make it visible, set the scope, hide it again if that's the workflow; clearing a scope carries no such requirement and reaches a hidden table normally.

Three behaviors follow from the scope at write time. An insert into a scoped table always writes the header's value into the scope column, whatever the request body contained — a caller never has to supply that field. Scoping department on name to X-Test-Scope, an insert whose body names one department while the header names another still lands under the header's value:

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

[{"id": null, "name": "BodyValueB", "cost_centre": "CC-9999", "created_at": "2026-09-11T00:00:00Z"}]
json
[
  {
    "cost_centre": "CC-9999",
    "created_at": "2026-09-11T00:00:00Z",
    "head_email": null,
    "id": 470,
    "name": "ScopeValueA"
  }
]

name came back "ScopeValueA" — the header's value — never the body's "BodyValueB".

Second, an update is refused if it tries to assign the scope column directly, which is what stops a caller from moving a row into someone else's scope:

json
{
  "code": 424,
  "status": "Failed Dependency",
  "message": "unable to perform update",
  "details": "\"name\" is this table's row-scope column and is set from the request header, so it cannot be assigned here"
}

A value prefixed with ~ is still written into the update as a raw expression rather than a plain value, so it bypasses the scope check and should be treated as an administrative override, not something a scoped caller is expected to use routinely.

Third, a delete with no filters at all, which the Auto API normally refuses outright as a blanket delete, stops being refused on a scoped table — the scope itself counts as a filter, and the request becomes "delete every row that is mine."

The scope's value is matched by exact, quoted equality against whatever the header carries — not a prefix, not a cast. A purely numeric string is a legitimate value here precisely because of that: identity "0" is not treated as empty or falsy, and identity "1" never matches a row scoped to "10" the way an unquoted numeric comparison could make it. Quoting is not incidental — an unquoted comparison on some databases coerces the column instead of the header value, and a value like 0 would then match every row whose text does not start with a digit, which is exactly the cross-caller leak a row scope exists to prevent.

The same mechanism over MCP

Every REST route above has a matching tool. Tool set_table_rls takes the same five fields; here it scopes the employee table itself, on its email column, to a header named X-Owner-Email — this is illustrative only, since employee is the table every other chapter's examples read and write with no such header, and leaving it scoped for real would break every one of them:

json
{
  "db_type": "postgres",
  "schema": "acme",
  "table": "employee",
  "rls_column_name": "email",
  "rls_header_key": "X-Owner-Email"
}
json
{
  "db_name": "acme",
  "db_type": "postgres",
  "org_id": "acme",
  "project_id": "people",
  "rls_column_name": "email",
  "rls_header_key": "X-Owner-Email",
  "table_name": "employee",
  "updated_at": "2026-09-10T13:48:58.297773Z"
}

get_table_rls and clear_table_rls take only db_type, schema and table, and answer with the same shape — the read shows the binding above verbatim, and clearing it — {"db_type": "postgres", "schema": "acme", "table": "employee"} through clear_table_rls — shows both fields back to empty strings, exactly as they were before this example.

Bulk permission and lock changes

A project that indexes a database wholesale can end up with dozens of tables, each carrying the lock mask indexing assigns by default. Reassigning a permission template, or unlocking a channel, table by table does not scale to that — and two bulk routes exist for exactly this, one per family, both taking the same shape: a database to act in, a mode, and a list of tables.

mode: "include" acts on exactly the tables named. mode: "exclude" acts on every indexed table in the database except the ones named — an empty tables list in exclude mode means the whole database, which is a real and intended use, not an oversight to guard against.

Locking deletes across a project's reference tables, so nobody can remove a department or a holiday row through the Auto API while updates and reads stay open:

http
PUT /minimal/system/api/v1/project/table/lock/bulk 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",
  "mode": "include",
  "tables": ["department", "holiday"],
  "table_delete_locked": true
}

200 OK

json
{
  "org_id": "acme",
  "project_id": "people",
  "db_type": "postgres",
  "schema": "acme",
  "mode": "include",
  "tables": ["department", "holiday"],
  "set_count": 2,
  "locked": "table:delete",
  "unlocked": ""
}

The response does not report each table's resulting state — the two tables in this call started from different lock masks and end at different ones, so set_count, locked and unlocked describe the write, and a specific table's outcome still comes from the single-table lock-status read. Like the single-table lock route, this write is differential: naming table_delete_locked here changes only that bit on the matched tables, leaving every other toggle — including each table's own mcp_* and m2m_* settings — exactly as it was. That matters more here than on the single-table route, because a bulk write that was not differential would flatten every per-table customization in the database to one shared state in a single call. An empty tables list in include mode is refused outright, before any matching happens, with 400. A call whose tables list is well-formed but matches no table at all — every name misspelled, say — answers 404, never a 200 with set_count: 0, so a caller cannot mistake "changed nothing" for success.

Assigning a permission template across many tables follows the identical mode/tables shape, on a separate route:

http
PUT /minimal/system/api/v1/project/table/permission/bulk 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",
  "mode": "include",
  "tables": ["department", "holiday"],
  "template_id": "01M1S96P76FQDPNYBV1RFSY8KH"
}

200 OK

json
{
  "org_id": "acme",
  "project_id": "people",
  "db_type": "postgres",
  "schema": "acme",
  "template_id": "01M1S96P76FQDPNYBV1RFSY8KH",
  "mode": "include",
  "tables": ["department", "holiday"],
  "assigned_count": 2
}

holiday had been carrying a different template before this call; after it, both tables carry the one named here. assigned_count reports how many rows actually changed — again, not a per-table echo, because that count is what tells a caller whether the call reached the tables it meant to, including the case where mode: "exclude" with an empty tables list is used to retarget an entire database in one call. Both bulk routes always answer JSON regardless of a caller's usual format preference; neither takes a format parameter.

Both have MCP equivalents with the same field names: bulk_set_table_lock_mask takes the same db_type, schema, mode, tables and twelve lock toggles as the REST body above; bulk_assign_table_permission takes db_type, schema, mode, tables and template_id. Both report the same set_count/assigned_count-style summary rather than a state per table, for the same reason: the matched tables did not all start in the same place.

Hidden tables

Locking and scoping a table both assume the table should still be found. Sometimes it shouldn't be — a migration-bookkeeping table, a staging table left over from a bulk load, anything that should not appear in a table picker a caller is browsing. Chapter 6 covers visibility in full; the short version is that hiding a table takes it off the map entirely, independent of who may act on it or which rows they would see if it were offered. What's new here is how it interacts with a row scope.

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

{
  "db_type": "postgres",
  "schema": "acme",
  "table": "department",
  "is_visible": false
}

200 OK

json
{
  "org_id": "acme",
  "project_id": "people",
  "db_type": "postgres",
  "db_name": "acme",
  "table_name": "department",
  "is_visible": 0
}

is_visible has no default — a body that omits it is refused rather than defaulted either way, so a table's visibility never changes as a side effect of a request that forgot the field. Once hidden, department disappears from the table listing:

http
GET /minimal/rest/meta/v1/table/list?type=pg&schema=acme HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-User-Id: acme-admin
X-User-Roles: hr-admin

200 OK — an array of ten table names, department no longer among them. As Chapter 6 already established for the lock-status and permission reads, the row-scope read answers the same way: exactly as it would for a table that has never been indexed at all, with nothing distinguishing a hidden table from an absent one.

Finding a table that was hidden by mistake takes a role permitted to write to the project, not merely read it, the same reasoning Chapter 6 gives for its own hidden-tables listing:

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

200 OK

json
[
  {"db_type": "postgres", "db_name": "acme", "table_name": "department", "table_type": "TABLE", "updated_at": "2026-09-10T17:33:02.298748Z"}
]

Restoring it is the same PUT with is_visible: true:

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

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

200 OK, and department is back in the table listing. Row scope is one thing Chapter 6's own list of survivors didn't cover, since it didn't exist yet — had department carried one before being hidden, a GET on the row-scope route after this restore would show rls_column_name and rls_header_key exactly as they were, unaffected by the table having been hidden in between. Hiding is a visibility toggle only, layered on top of, not a replacement for, the other layers already covered.

set_table_visibility and list_hidden_tables are the matching MCP tools, taking the same fields — db_type, schema, table, is_visible for the first; page_size and page_number, both required, for the second.

Exercises

  1. Pick a table in your own project with a text column that identifies an owner — a user_id, an owner_email, a tenant column, anything similar. Scope it to a header of your choosing, then issue the same read twice with two different header values and confirm the two results don't overlap. Then clear the scope and confirm the table answers every caller identically again.

  2. Using the bulk lock route, lock creates on every indexed table in one of your databases except two you name, in a single call. Read back the lock status of one excluded table and one included table and confirm only the included one changed.

  3. Scope a table, then hide it. Try to read its row-scope status, its lock status, and a page of its rows through the Auto API. Confirm all three answer as though the table had never been indexed, then restore it and confirm its permission template, lock mask, and row scope all came back exactly as they were before you hid it.

5Personas, Apps, and the AI Surface

Three resources round out what Minimal offers for an AI-facing feature. A persona is a named voice — a system prompt, a competency, a point of view, a default question — that a conversation runs as. An app is a catalog entry describing something built on the platform, with content the platform can hand back raw or rendered. A session is the container a conversation with a persona happens inside, and the conversation route is where its turns live. None of the three know about each other at the API level — a session does not reference a persona_id, and an app does not reference either — but together they are what a chat-style feature is built from: pick a persona, start a session, append what was said, read it back.

Personas

A persona bundles everything that shapes how an assistant behaves: system_prompt for the instruction it runs under, competency for what it is good at, lens for the point of view it takes, default_question for what to ask when the caller has nothing in mind, and tools for what it may call on to answer. Personas belong to an organization, not a project — but every route on this resource still reads a project header, because that is where the caller's role is checked. Every request on this resource also carries X-Space-Id, even though no persona route reads it: the five identity headers are required together on every REST and AI-surface route in this chapter, and a request missing any one of them — space included — is refused before the route itself runs.

Acme keeps one for its HR self-service portal: an assistant that answers leave and policy questions, and is instructed never to disclose salary figures.

Creating a persona

http
POST /minimal/rest/ai/personas/v1 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

[
  {
    "persona_id": "acme-hr-assistant",
    "slug": "hr-assistant",
    "name": "HR Assistant",
    "description": "QW5zd2VycyBlbXBsb3llZSBxdWVzdGlvbnMgYWJvdXQgbGVhdmUgYmFsYW5jZXMgYW5kIEhSIHBvbGljeS4=",
    "context": "UnVucyBpbnNpZGUgdGhlIEFjbWUgZW1wbG95ZWUgc2VsZi1zZXJ2aWNlIHBvcnRhbC4=",
    "system_prompt": "TmV2ZXIgZGlzY2xvc2Ugc2FsYXJ5IGZpZ3VyZXMuIEFuc3dlciBvbmx5IGZyb20gdGhlIGxlYXZlIHBvbGljeSBhbmQgdGhlIGVtcGxveWVlJ3Mgb3duIHJlY29yZC4=",
    "competency": "TGVhdmUgYmFsYW5jZXMsIGxlYXZlIHJlcXVlc3RzLCBhbmQgSFIgcG9saWN5IGxvb2t1cC4=",
    "lens": "helpful, precise, HR-appropriate",
    "default_question": "SG93IG1hbnkgdmFjYXRpb24gZGF5cyBkbyBJIGhhdmUgbGVmdCB0aGlzIHllYXI/",
    "best_fit_for": "Employees asking about their own leave balance or HR policy",
    "version": "1",
    "output_spec": "UGxhaW4gdGV4dCwgdHdvIHRvIHRocmVlIHNlbnRlbmNlcywgbm8gbWFya2Rvd24u",
    "tools": "cmVhZF9kaXJlY3RvcnksbGVhdmVfcmVhZCxsZWF2ZV93cml0ZQ=="
  }
]

Answers 201:

json
{"rows_affected": 1}

Two things about that body are easy to get wrong the first time.

The body is always an array, even for one persona. Send a bare object and the route answers 400 before it looks at a single field.

Six fields must be base64: description, context, system_prompt, competency, default_question and output_spec. tools and extra_info are optional, but must still decode as base64 if you send them at all. lens, best_fit_for, slug and name are the opposite — plain text, stored exactly as sent. The fields carrying prompt-shaped text — long, possibly multi-line, possibly holding characters that upset a naive JSON body — are what get encoded; the short, single-line, display-oriented fields do not.

This matters in practice because the two mistakes fail differently. Send system_prompt as plain text and the route answers 400: not valid base64, caught immediately. Send lens as a base64 string by habit — because the field next to it needed one — and nothing complains. It is stored as sent, and every listing that returns it shows the encoded string as the persona's lens, because the API has no way to know you meant something else. Encoding one of the plain-text fields is a silent mistake rather than a rejected one.

Listing and reading one back

There is no separate by-id route for a persona. One query parameter, form, does three jobs:

http
GET /minimal/rest/ai/personas/v1/list?ps=20&pg=0&form=short HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-Space-Id: dev
X-User-Id: acme-admin
X-User-Roles: hr-admin
json
[
  {"persona_id": "acme-hr-assistant", "slug": "hr-assistant", "name": "HR Assistant", "lens": "helpful, precise, HR-appropriate"}
]

form=extended returns every stored field, encoded fields still base64, across every persona your organization owns plus the platform's own built-in ones:

json
{
  "persona_id": "01M27W2C6V784YKXN5Q6WFYGMP",
  "org_id": "acme",
  "slug": "book-ch5-test",
  "name": "Book Chapter 5 Test Persona",
  "description": "UmV2aWV3IHRlc3QgcGVyc29uYSBmb3IgQm9vayBvZiBNaW5pbWFsIENoYXB0ZXIgNQ==",
  "context": "QWNtZSBIUiB0ZXN0IGNvbnRleHQ=",
  "system_prompt": "QW5zd2VyIEhSIHF1ZXN0aW9ucyBwb2xpdGVseS4=",
  "competency": "SFIgcG9saWN5IHF1ZXN0aW9ucw==",
  "lens": "friendly",
  "default_question": "V2hhdCBpcyB0aGUgbGVhdmUgcG9saWN5Pw==",
  "best_fit_for": "HR questions",
  "current_version": "1",
  "output_spec": "UGxhaW4gdGV4dA==",
  "tools": "",
  "compositions": "",
  "extra_info": "",
  "is_active": 1,
  "is_global": 0,
  "touched_by": "mcp:acme-agent",
  "created_at": "2026-09-11T09:16:37.980201Z",
  "updated_at": "2026-09-11T09:16:37.980201Z"
}

form=persona combined with persona_id= is how you fetch one — the answer is still an array, in this same extended shape, holding at most one entry. ps and pg are required on all three variants, even the by-id one, where paging plainly does nothing. format=yaml renders whichever form you chose the same way it does elsewhere in this book — a second, independent parameter, not one of the three form values.

Updating, and the narrower compositions route

PUT on the same path is a full replace: every field in the body overwrites what is stored, including compositions, tools and is_global — omit any of them and they reset, not stay put. persona_id is not generated here; naming one that does not match answers 200 with rows_affected: 0, not 404, so a caller has to check the count rather than trust the status code.

Compositions are how a broader persona is assembled from narrower ones — an HR assistant built from a leave-policy specialist and a payslip reader, each independently maintained. This example assumes both already exist as their own personas, created the same way as the HR Assistant above. A dedicated route changes just that list, without resubmitting the whole persona:

http
PUT /minimal/rest/ai/personas/v1/compositions 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

{
  "persona_id": "acme-hr-assistant",
  "version": "2",
  "compositions": ["acme-leave-policy", "acme-payslip-reader"]
}
json
{"rows_affected": 1}
graph TD
    HR["HR Assistant<br/>persona_id: acme-hr-assistant<br/>version: 2"]
    LP["Leave Policy Lookup<br/>persona_id: acme-leave-policy"]
    PR["Payslip Reader<br/>persona_id: acme-payslip-reader"]
    HR -->|composed from| LP
    HR -->|composed from| PR

The HR Assistant's compositions name two narrower personas it draws on. Replacing the list stamps a new version onto the HR Assistant; neither of the personas it names is touched.

Unlike the full update, this route takes a single object, not an array — the one persona route that breaks the pattern. The list is stored joined into one comma-separated value, so a composition identifier containing a comma will not round-trip intact.

History and retiring a persona

http
GET /minimal/rest/ai/personas/v1/history?persona_id=acme-hr-assistant&ps=20&pg=0 HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-Space-Id: dev
X-User-Id: acme-admin
X-User-Roles: hr-admin
json
[
  {
    "persona_id": "acme-hr-assistant",
    "slug": "hr-assistant",
    "op_kind": "UPDATE",
    "op_ts": "2026-09-11T09:16:58Z",
    "current_version": "2",
    "compositions": "acme-leave-policy,acme-payslip-reader"
  },
  {
    "persona_id": "acme-hr-assistant",
    "slug": "hr-assistant",
    "op_kind": "INSERT",
    "op_ts": "2026-09-05T17:17:33Z",
    "current_version": "1",
    "compositions": ""
  }
]

Every archived version comes back, newest first, each carrying op_kind (what kind of write produced it) and op_ts (when) alongside the persona's full stored shape at that point. This only works for a persona your own organization owns and that is still active — a platform-supplied persona is listable but its history is refused, and deactivating your own persona closes its history for good, the same call that returned versions a moment earlier now answering 404.

Retiring is one-way:

http
PATCH /minimal/rest/ai/personas/v1?persona_id=acme-hr-assistant HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-Space-Id: dev
X-User-Id: acme-admin
X-User-Roles: hr-admin
json
{"rows_affected": 1}

No route reactivates a persona. After this call it disappears from the listing, the by-id read, and the history route, all at once.

Over MCP, create_personas and update_personas take the same six-field base64 rule, just inside an arguments.personas array; list_personas mirrors the form parameter; and update_persona_compositions takes compositions as a real JSON array rather than the comma-joined string the REST route stores — the tool joins it for you. deactivate_persona answers success with rows_affected: 0 for an unknown id, exactly like the REST route: check the count.

Apps

An app record describes something built on top of Minimal — what it is, who owns it, and, inside app_info, whatever that thing needs to know about itself: an entry point, an owning team, the endpoints it depends on. Nothing in app_info is interpreted by the server; it is base64-encoded content the platform stores, and later hands back decoded, as text/html. Acme registers one: the People Portal, the self-service page employees use for leave and payslips.

http
POST /minimal/rest/apps/v1 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

[
  {
    "app_id": "acme-people-portal",
    "name": "Acme People Portal",
    "kind": "web",
    "description": "Self-service for leave, payslips and the staff directory",
    "app_info": "PGgxPkFjbWUgUGVvcGxlIFBvcnRhbDwvaDE+",
    "creator_type": "human",
    "shared": 1,
    "is_public": 1
  }
]

The caller in X-User-Id becomes the app's owner, which is what later governs who may update or delete it — a role alone is not enough; only the owner's own later calls will match.

shared and is_public answer two different questions:

Flag Set to 1 means Governs
shared other callers in this project can see the record appearing in their list and history
is_public the app can be served without regard to ownership or sharing the render route

An app can be shared without being public, and public without being shared — a page anyone in the project can render, but that only its owner sees in their own listing.

Reading it back: detail versus render

Two routes serve the same decoded content, gated differently:

detail render
Path /minimal/rest/apps/v1/detail?app_id=… /minimal/rest/apps/v1/render?app_id=…
Extra gate beyond the project read role none — any active app in the project is_public must be 1
Unknown or unqualified app_id 204 404

detail ignores ownership and sharing entirely: anyone holding the project's read role can pull any active app's content by id. render is the one that checks is_public — an app that exists, that you can even see in your own listing, still answers not-found from render until its is_public flag is set. Rendering is the only one of the two that this platform treats as genuinely public-facing.

http
GET /minimal/rest/apps/v1/detail?app_id=acme-people-portal 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
http
200 OK
Content-Type: text/html

{"entry":"/portal","owner_team":"people-ops","endpoints":["directory","directory/masked","leave/summary"]}

Nothing about app_info requires it to be markup — this app's current content is a JSON blob its own client reads, not HTML, and the server hands it back exactly as it decodes either way, always labeled text/html. render answers with the identical bytes once is_public is set:

http
GET /minimal/rest/apps/v1/render?app_id=acme-people-portal 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
http
200 OK
Content-Type: text/html

{"entry":"/portal","owner_team":"people-ops","endpoints":["directory","directory/masked","leave/summary"]}

Naming an app that does not exist answers differently on the two routes, matching the table above:

http
GET /minimal/rest/apps/v1/render?app_id=acme-not-a-real-app HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-Space-Id: dev
X-User-Id: acme-admin
X-User-Roles: hr-admin
json
{
  "code": 404,
  "status": "Not Found",
  "message": "no such app exists"
}

Listing your own

http
GET /minimal/rest/apps/v1/list?ps=20&pg=0 HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-Space-Id: dev
X-User-Id: acme-admin
X-User-Roles: hr-admin
json
[
  {
    "org_id": "acme", "project_id": "people",
    "app_id": "acme-people-portal", "name": "Acme People Portal", "touched_by": "acme-admin",
    "description": "Self-service for leave, payslips and the staff directory",
    "kind": "web", "shared": 1, "is_public": 1, "creator_type": "human",
    "created_on": "2026-09-05T17:17:37.687359Z"
  }
]

The listing is your own apps plus anyone else's marked shared — never someone else's unshared app, whatever role you hold. Add kind to filter by category and two things change at once: the touched_by column drops out of every row, and the newest-first ordering is no longer guaranteed.

Updating, history, and deleting

PUT is a full replace, gated on ownership: the update only matches an app whose recorded owner is the caller, so someone else's app is silently left alone with rows_affected: 0. Every field is overwritten from what you send — shared and is_public included, so omitting either resets it to 0 regardless of what it was.

http
GET /minimal/rest/apps/v1/history?app_id=acme-people-portal&ps=20&pg=0 HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-Space-Id: dev
X-User-Id: acme-admin
X-User-Roles: hr-admin
json
[
  {"app_id": "acme-people-portal", "op_kind": "UPDATE", "op_ts": "2026-09-05T17:17:37.786479Z",
   "commit_hash": "2eb2e058", "checksum": "c9395b67da4dd79eab9ca8779469e168fbcb058f8ba046df8a14a0b43e07a418",
   "status": "active", "description": "Self-service for leave, payslips and the staff directory"},
  {"app_id": "acme-people-portal", "op_kind": "INSERT", "op_ts": "2026-09-05T17:17:37.687359Z",
   "commit_hash": "70106043", "checksum": "c9395b67da4dd79eab9ca8779469e168fbcb058f8ba046df8a14a0b43e07a418",
   "status": "active", "description": "The self-service portal Acme employees use for leave and payslips"}
]

Returns archived versions newest first. Three fields appear only here: commit_hash and checksum, both computed by the server from app_info on every write (note the write side calls the same thing check_sum, with an underscore — a caller supplying it is ignored either way, since the server always recomputes it), and status. Here, the description was edited between these two writes but app_info itself was not, and the two fields show it differently: checksum is identical across both versions, because it tracks app_info alone, while commit_hash differs on every write regardless of whether the content changed. History follows the listing's visibility, not detail's: an app you cannot see in list answers not-found here even though detail would still serve its content. format=csv renders the same rows as a spreadsheet, commit_hash and checksum included, for whoever is auditing a run of changes outside the API.

http
DELETE /minimal/rest/apps/v1?app_id=acme-throwaway 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

Deletion is permanent and ownership-gated the same way update is, except the failure mode differs: naming an app you do not own answers 424, the same code as naming one that does not exist at all — the two are not distinguished. The delete marks the row deleted before removing it, which is what lets history remain briefly readable; once the row is gone, history answers not-found too.

Over MCP, create_apps and update_apps carry shared and is_public as real booleans rather than 0/1, and the two tools diverge on one point: update_apps marks both fields required, where create_apps — like the REST route — leaves them optional, defaulting to false. The stricter update schema exists for the same reason the REST route's silent reset matters: an optional boolean that can be omitted can also be forgotten, silently clearing a flag nobody meant to touch.

AI sessions and conversations

A session is a running conversation; the conversation route is where its turns live. A session belongs to the caller who created it — the organization, project and user on it are always read from the identity headers, never from the body, so sending them changes nothing. Ownership is tracked by X-User-Id alone, though: writing to this resource still needs a role in the project's project_write_roles, the same project-level gate every other route in this chapter checks. That is a coarser check than Auto API's row-level security from Chapter 4 — there is no "read only your own session" carve-out here, only "hold a role that can write in this project at all." Acme's own configuration grants that to hr-admin and payroll-clerk; an ordinary employee role holds neither, so the self-service client below authenticates its calls with an HR role while still recording Priya's own id in X-User-Id.

http
POST /minimal/api/ai/v1/session HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-Space-Id: dev
X-User-Id: priya
X-User-Roles: hr-admin
Content-Type: application/json

{"session_id": "acme-priya-leave-2026-09", "name": "Priya — leave question", "context": "HR self-service chat, acme-hr-assistant persona"}

Answers 201:

json
{
  "session_id": "acme-priya-leave-2026-09",
  "org_id": "acme",
  "project_id": "people",
  "user_id": "priya",
  "name": "Priya — leave question"
}

session_id is optional and minted for you when omitted; status starts at active and cannot be set from this route no matter what you send.

Appending a turn

http
PUT /minimal/api/ai/v1/conversation HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-Space-Id: dev
X-User-Id: priya
X-User-Roles: hr-admin
Content-Type: application/json

{
  "session_id": "acme-priya-leave-2026-09",
  "role": "user",
  "speaker_id": "priya",
  "content": "How many vacation days do I have left this year?"
}
json
{"rows_affected": 1}

Four fields are required and none may be empty or whitespace-only: session_id, role (who is speaking — free text; user and persona are the conversational roles), speaker_id (which participant — the reason a session can carry two participants under the same role), and content. Five more are optional and left at their column default, which reads back as null, when omitted: input_tokens, output_tokens, cost, definition_id, and tool_call_id (the field that links a tool_call turn to its matching tool_result). This route answers PUT/200, not POST/201 like the session routes beside it, and an incomplete body here answers 400, not the 424 a session route would give for the same kind of omission, so error handling that treats the two resources generically has to account for the difference.

The platform's own MCP recording writes turns under the roles tool_call and tool_result, with a base64-encoded JSON envelope as content — the tool name and arguments, or the result payload. Nothing on this route checks that a turn a caller appends under those two roles actually has that shape; the check is a convention the reader applies, not a constraint the writer enforces.

Reading it back

http
GET /minimal/api/ai/v1/conversation?session_id=acme-priya-leave-2026-09&ps=20&pg=0 HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-Space-Id: dev
X-User-Id: priya
X-User-Roles: hr-admin
json
[
  {"session_id": "acme-priya-leave-2026-09", "role": "user", "speaker_id": "priya",
   "content": "How many vacation days do I have left this year?",
   "created_at": "2026-09-11T09:35:50.232228Z", "input_tokens": null, "output_tokens": null,
   "cost": null, "definition_id": null, "tool_call_id": null}
]

Turns come back oldest first — the opposite order from session listing and session history, both of which are newest first. An empty page is 204 with no body here, where the equivalent empty case elsewhere on this resource — session history — is 200 with an empty array; the two do not agree, so check for both. A closed, archived or deleted session answers the same as one belonging to somebody else: not found. There is no way to read a session's conversation after closing it, so read what you need before you change its status. format=yaml is accepted here too, the same turns rendered without the base64 tool_call/tool_result envelopes reformatted any differently — it changes only the outer encoding, not what a caller has to decode themselves.

Renaming, closing, listing, deleting

PUT /minimal/api/ai/v1/session renames a session — name and context both required, both overwritten, status untouched no matter what is sent. It answers 200 even when the session_id named does not match one of yours; nothing is updated, and the response simply echoes what you sent, so confirm with the listing when it matters.

PATCH /minimal/api/ai/v1/session is narrower on purpose: it writes status and nothing else. name and context are accepted by the same body shape but silently not written — use the full update route for those. This is how a session gets closed:

http
PATCH /minimal/api/ai/v1/session HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-Space-Id: dev
X-User-Id: priya
X-User-Roles: hr-admin
Content-Type: application/json

{"session_id": "acme-priya-leave-2026-09", "status": "closed"}

GET /minimal/api/ai/v1/session/list?ps=20&pg=0 lists your own sessions, most recently updated first; deleted sessions are excluded, archived ones are still shown.

GET /minimal/api/ai/v1/session/history?session_id=…&ps=20&pg=0 is a different thing from the conversation route, easy to conflate because both take a session_id: history is the archived record of the session's own metadata — how it was named, what its status was, at each point — and it never carries what was said in the session. It is also narrower than the listing: an archived session is still listed, but its history answers not-found, the same answer given for a deleted one or somebody else's — one response covering three different causes.

DELETE /minimal/api/ai/v1/session?session_id=… removes the session record permanently, though the conversation's turns are left in place and simply become unreachable, since reading them needs the session. This route requires a stronger role than the other session writes — project_delete_roles rather than project_write_roles.

The MCP session convention is this same resource, used a different way

Chapter 7 introduced a rule for MCP: every tool call but start_ai_session and help needs an X-Session-Id header, obtained once by calling start_ai_session, and carried on every later tools/call. That is the same session resource this chapter has been describing — start_ai_session answers with session_id, org_id, project_id, user_id and name, the identical shape POST /minimal/api/ai/v1/session answers over REST — but it is being asked to do a different job there. In Chapter 7, the session exists to gate and record a connection's tool calls: start it once, wire its id in, and mostly forget about it. In this chapter, the session is the object itself — something you name, close, rename, list, and read the archived history of, the way this section has just shown.

The two uses collide in one concrete way. Every tools/call made while an X-Session-Id is set is recorded into that session as a tool_call turn before the tool runs and a tool_result turn once it answers — including calls to the conversation tools themselves. append_conversation_turn's own tool description says this outright: appending one turn is recorded as two more turns in the session named by X-Session-Id, so one call writes three rows, not one — and if the session_id argument you pass names a different session than the one your connection is carrying, the turn you asked for lands in one session while the recording of the call lands in another.

sequenceDiagram
    participant Client
    participant MCP as MCP listener
    participant A as Session A (session_id argument)
    participant B as Session B (X-Session-Id header)

    Client->>MCP: tools/call append_conversation_turn<br/>X-Session-Id: B, arguments.session_id: A
    MCP->>B: write tool_call turn
    MCP->>A: write the turn itself (role, speaker_id, content)
    MCP->>B: write tool_result turn
    MCP-->>Client: result

One call to append_conversation_turn, naming a session other than the one the connection is carrying, writes three rows total: one in the session it targets, two in the session recording the call.

Calling append_conversation_turn with session_id equal to your own X-Session-Id — appending to the very session your connection is using — is the case that produces the number the tool description states directly: three rows for one turn. read_conversation is likewise a recorded call in its own right, so checking what landed adds to the count you are checking. None of this changes what the conversation route documented above does — the mechanics in this section are exactly the REST behavior already described. What changes is only which session a given MCP call's bookkeeping lands in, and it does not always match the session your feature is actually building.

A worked example: the HR Assistant answers a leave question

Priya, an Acme employee, opens the HR chat panel, already wired to the acme-hr-assistant persona created earlier in this chapter. The client starts a session for her:

http
POST /minimal/api/ai/v1/session HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-Space-Id: dev
X-User-Id: priya
X-User-Roles: hr-admin
Content-Type: application/json

{"session_id": "acme-priya-leave-2026-09", "name": "Priya — leave question", "context": "HR self-service chat, acme-hr-assistant persona"}
json
{
  "session_id": "acme-priya-leave-2026-09",
  "org_id": "acme",
  "project_id": "people",
  "user_id": "priya",
  "name": "Priya — leave question"
}

She types her question. The client appends it as a user turn:

http
PUT /minimal/api/ai/v1/conversation HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-Space-Id: dev
X-User-Id: priya
X-User-Roles: hr-admin
Content-Type: application/json

{"session_id": "acme-priya-leave-2026-09", "role": "user", "speaker_id": "priya", "content": "How many vacation days do I have left this year?"}

The persona answers — in this case by reading Priya's own leave record through the three Custom API endpoints named in its tools field, though a tool call of that kind belongs to a different chapter; what matters here is only that its answer gets appended the same way, as a persona turn, with the optional token and cost fields filled in because the client happens to be tracking them:

http
PUT /minimal/api/ai/v1/conversation HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-Space-Id: dev
X-User-Id: priya
X-User-Roles: hr-admin
Content-Type: application/json

{
  "session_id": "acme-priya-leave-2026-09",
  "role": "persona",
  "speaker_id": "acme-hr-assistant",
  "content": "WW91IGhhdmUgMTIgdmFjYXRpb24gZGF5cyByZW1haW5pbmcgdGhpcyB5ZWFyLCBhcyBvZiB5b3VyIGxhc3QgYXBwcm92ZWQgbGVhdmUgcmVxdWVzdC4=",
  "input_tokens": 142,
  "output_tokens": 27,
  "cost": 0.0031
}

Content on the conversation route is stored exactly as sent, base64 or not — this client happens to send its persona turns pre-encoded, matching how it stores them internally, but the route itself imposes no such rule the way the persona routes do. The panel reads the exchange back before it renders it:

http
GET /minimal/api/ai/v1/conversation?session_id=acme-priya-leave-2026-09&ps=20&pg=0 HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-Space-Id: dev
X-User-Id: priya
X-User-Roles: hr-admin
json
[
  {
    "role": "user",
    "speaker_id": "priya",
    "content": "How many vacation days do I have left this year?",
    "input_tokens": null,
    "output_tokens": null,
    "cost": null
  },
  {
    "role": "persona",
    "speaker_id": "acme-hr-assistant",
    "content": "WW91IGhhdmUgMTIgdmFjYXRpb24gZGF5cyByZW1haW5pbmcgdGhpcyB5ZWFyLCBhcyBvZiB5b3VyIGxhc3QgYXBwcm92ZWQgbGVhdmUgcmVxdWVzdC4=",
    "input_tokens": 142,
    "output_tokens": 27,
    "cost": 0.0031
  }
]

With nothing further to read, the panel closes the session:

http
PATCH /minimal/api/ai/v1/session HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-Space-Id: dev
X-User-Id: priya
X-User-Roles: hr-admin
Content-Type: application/json

{"session_id": "acme-priya-leave-2026-09", "status": "closed"}

The same exchange, driven from an MCP client instead, looks almost identical, with one bookkeeping difference the previous section set up. The client calls start_ai_session once and sends the session_id it gets back as X-Session-Id on everything after:

http
X-Session-Id: 01M25RTVZMFMCXWSW9KKS3CCZ5
json
{"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {
  "name": "append_conversation_turn",
  "arguments": {
    "session_id": "01M25RTVZMFMCXWSW9KKS3CCZ5",
    "role": "user", "speaker_id": "priya",
    "content": "How many vacation days do I have left this year?"
  }
}}

Because the session_id argument here is the same session named by X-Session-Id, this one call writes three rows, not one: the user turn itself, and a tool_call/tool_result pair recording the call that added it. The persona's answer, appended the same way, adds three more. Reading the conversation back with read_conversation therefore shows more than the two turns a caller might expect from having made two calls — it shows every row those two calls wrote, recording included. Nothing about the conversation data is different; only the row count is, and only because this client is using the session that is also gating its own tool calls to hold the conversation it is building.

Exercises

  1. Create a persona of your own — something other than an HR assistant — and get exactly one of the six base64 fields wrong: send lens base64-encoded instead of plain text. Read it back with form=extended and look at what the API actually stored for lens. Then fix it, and this time send system_prompt as plain text instead of base64, and compare how that failure looks different from the first one.

  2. Register an app with shared set to 1 and is_public left at its default. Confirm that detail serves its content and render refuses it. Update the app to set is_public, and confirm render now serves the same content detail does. Then read the app's history and find the three fields that appear only there.

  3. Start two AI sessions for the same caller. Append a user turn to the first one, then append a persona turn to the second one. Read both conversations back and confirm each holds only the turn meant for it. Then, using an MCP client with X-Session-Id set to the first session, call append_conversation_turn naming the second session as its session_id argument, and read both conversations again — find the rows that landed in the session you did not name.

6Audit and Observability

Every call Minimal accepts leaves a row behind — a read as much as a write: who made it, what it touched, when it happened, and what came back. This happens whether you look or not. This chapter is about looking — reading back what happened, at three different scopes, and connecting what you find in one to what you find in another.

There are three places to look. Your own trail, scoped to requests made under your identity. Your organization's trail, scoped to every project and every caller, gated more strictly than almost anything else you've called so far. And a single session's own history — not a request log at all, but the record of what happened to one session object: when it was created, renamed, or had its status changed.

None of them answer "what does this request do" — that's the rest of this book. They answer "what already happened," after the fact, from outside the call that did it. A request that failed halfway, a delete nobody meant to run, a token being used from somewhere it shouldn't be: all of that shows up here, in rows you didn't have to ask Minimal to keep.

Your own trail

The tenant view answers one question: what have I done? Send it a page size and a page number and it hands back the requests recorded under your organization and your user id, one page at a time. Unlike the organization-wide view, it makes no promise about order — there's no ordering parameter and no default sort, so which rows land on which page can shift between two otherwise identical calls.

http
GET /minimal/api/audit/v1?ps=5&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

This is refused, and not for the reason you might expect. Most of the routes you've called so far check whether your roles appear in a role column stored on the project itself — project_read_roles, project_write_roles, and the like. This one doesn't: it checks whether X-User-Roles literally contains the string users or the string admin, and nothing else counts.

json
{
  "code": 403,
  "status": "Forbidden",
  "message": "insufficient permissions: required [users admin], got [hr-admin]"
}

The two names inside required [...] can come back in either order on different calls — the message lists them, not a fixed pair — so match on which names appear, not on their position. hr-admin is a real, working role in this organization — it can create projects, write to tables, and do most of what an operator needs day to day. None of that matters here. Roles are sent as a comma-separated list, and the check only asks whether one of the names in that list is users or admin. Add admin to the header and the same request succeeds:

http
GET /minimal/api/audit/v1?ps=5&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,admin
json
[
  {
    "org_id": "acme",
    "project_id": "people",
    "space_id": "dev",
    "user_id": "acme-admin",
    "user_roles": "hr-admin,admin",
    "user_hash": "user_7f2e9a5c1d8b6034ef2a9c5d7b1e4f8036a2c9d5e7b1f4a8c2d6e9b3f7a1c5d8",
    "method": "DELETE",
    "uri": "minimal/api/rest/auto/v1/acme/people/pg/acme/employee",
    "version": "v1",
    "status_code": 200,
    "client_ip": "10.0.4.12",
    "request_id": "01M3F8K2QYN7X6VZC9TB4RS8HD",
    "session_id": "01M3F7QJ8Y3ZC2Q9K6XG9YABCD",
    "token_id": "01M2Z1N4E8H0T5R7W3C6Y9BQFX",
    "extended_info": null,
    "start_epoch": 1789048800000000,
    "end_epoch": 1789048800001850,
    "latency": 1850,
    "created": "2026-09-10T14:00:00.000000Z"
  }
]

X-Project-Id and X-Space-Id are on the request even though nothing here is filtered by project or space — they're required identity headers on this API surface regardless, and this route just ignores their values. Organization and user id are the only things that narrow what comes back. There's no parameter for reading someone else's trail; this route has no way to answer that question, however your roles are set.

A call you just made can take a moment to show up here. If the row you're looking for isn't on the first page yet, that's usually why — page again a moment later rather than assuming the call went unrecorded.

Your organization's trail

The administrative view answers a different question: what has everyone done? It reads across every project in your organization and every caller who touched one — not just you.

http
GET /minimal/system/api/v1/audit/logs?ps=5&pg=0 HTTP/1.1
X-Org-Id: acme
X-User-Id: acme-admin
X-User-Roles: hr-admin

Two things are already different before you look at the response. This route lives under /minimal/system/api, not the project-scoped /minimal/api the tenant view used — because it isn't scoped to one project, there's no X-Project-Id or X-Space-Id to send. And hr-admin alone is enough here, where a moment ago it wasn't enough for your own trail.

That's deliberate. This route is gated on the same permission that decides who in your organization may create a new project — a bar set well above ordinary project read or write access, and on purpose. Reading everybody's activity across every project in the organization is treated as an organization-administrator capability, not a project one: it's answered by a role column your organization configures, the same way project access is, rather than by the fixed pair of literal role names the tenant view checks.

Narrow it the way you'd narrow any list — by project, or by method:

http
GET /minimal/system/api/v1/audit/logs?ps=5&pg=0&project_id=people&method=DELETE HTTP/1.1
X-Org-Id: acme
X-User-Id: acme-admin
X-User-Roles: hr-admin

project_id and method need no permission of their own — they only narrow within an organization you're already cleared to read in full. method matches case-insensitively, so delete, Delete and DELETE all narrow to the same rows. Leave out project_id and you see every project at once; leave out method and every verb.

What a row tells you

Both trails answer with the same row shape. What differs between them is whose rows you're allowed to see, never what one row contains. The row above breaks down into five groups of fields:

  • Who — org_id, project_id, space_id, user_id, user_roles, user_hash: the full identity the call carried, plus user_hash, a hash of that whole org/project/space/user tuple, stored alongside user_id on every row.
  • What — method, uri, version: the verb, the path, and the API version it hit.
  • Result and timing — status_code, created, start_epoch, end_epoch, latency: what came back, and when. The two epoch values are in microseconds; created is a microsecond-precision UTC timestamp. latency is exactly end_epoch minus start_epoch — the same unit, so the arithmetic always checks out.
  • Trace — client_ip, request_id, session_id, token_id: where the call came from, its own identifier, the session it ran under if it ran under one, and which access token served it if the call used one. session_id comes from whatever X-Session-Id the request carried, so a raw HTTP call that never sends that header — every plain example earlier in this book — records null here, not an error. A call routed through MCP is what actually populates it, the way Chapter 7 introduced.
  • Extra — extended_info: additional detail some calls attach, most often a reason recorded alongside certain refusals; null on an ordinary row like the one above.

Both routes take a format parameter besides ps and pg, defaulting to json when you leave it out, with the same five alternatives on both: xml, csv, yaml (yml also works), and bson. It's not a parameter you'll reach for often, but a row shaped this consistently is easy to pipe into whatever already reads one of those formats.

An empty page — you've paged past the end, or nothing matches your filters — is a 200 with an empty array, not a 404. Leave out ps or pg on either route and you get 406, not a default page; both are mandatory, every time, on both trails.

The same two trails, as tools

Drive Minimal through MCP rather than raw HTTP, and read_my_audit_trail and read_org_audit_trail turn out to be the same two routes as tools. Identity comes from the access token behind your session, the way it does for every other tool — there's no user id or organization id argument to fill in, just the call itself, wrapped in a tools/call request the way every MCP call in this book has been.

json
{
  "name": "read_my_audit_trail",
  "arguments": {
    "page_size": 5,
    "page_number": 0
  }
}

Called under a session whose token carries only hr-admin, this fails the same way as the raw route — refused as an error result, its text naming the API's status and the start of its message:

the API answered 403 Forbidden: {"code":403,"status":"Forbidden","message":"insufficient permissions: required [users admin], got [hr-admin]"}

read_org_audit_trail takes the same project_id and method arguments the route does, plus format — though its format argument only offers json, yaml, yml, xml and csv; bson, which the route itself accepts, has no place in a JSON-RPC argument. read_my_audit_trail's schema doesn't expose format at all — only the two paging fields are there to fill in. Reach for either route directly, under an access token, if you need a trail as CSV, XML, or BSON.

A session's own history, narrower still

get_session_history doesn't read requests at all. It reads the archived record of one AI session object — every time its name, context, or status changed, newest first. It never carries what was said inside the session; that's a different read entirely.

json
{
  "name": "get_session_history",
  "arguments": {
    "session_id": "01M3F7QJ8Y3ZC2Q9K6XG9YABCD",
    "page_size": 5,
    "page_number": 0
  }
}
json
[
  {
    "session_id": "01M3F7QJ8Y3ZC2Q9K6XG9YABCD",
    "org_id": "acme",
    "project_id": "people",
    "user_id": "acme-admin",
    "name": "Nightly reconciliation",
    "context": "Automated cleanup of stale employee rows",
    "status": "active",
    "op_kind": "INSERT",
    "op_ts": "2026-09-10T13:41:37.396733Z",
    "created_at": "2026-09-10T13:41:37.396733Z",
    "updated_at": "2026-09-10T13:41:37.396733Z",
    "total_cost": 0,
    "total_input_tokens": 0,
    "total_output_tokens": 0
  }
]

One row here, INSERT, is the session's own creation — nothing has changed it since. op_kind and op_ts are what set this apart from the ordinary session listing: the listing shows you the current row only; this shows you every version of it that ever existed.

Two things make this narrower than either audit trail. It answers only for a session that's currently active — an archived one, a deleted one, and one that belongs to somebody else all get the same not-found response, so this tool can't even confirm a session id exists. And it needs nothing like the organization-wide gate: an ordinary project-read role is enough, because the question you're allowed to ask is only ever about a session that's already yours.

flowchart TD
    Q[What do you need to see?] --> A{Whose activity?}
    A -->|Only mine| M[read_my_audit_trail]
    A -->|Everyone's, every project| O[read_org_audit_trail]
    A -->|One session's own lifecycle| S[get_session_history]
    M --> MR[Needs the literal role users or admin]
    O --> OR[Needs the role that may create projects]
    S --> SR[Needs a project-read role, and the session must be active]

Three questions about what happened, and the check each answer runs against your roles.

Worked example: from a deletion to the session that made it

Something was deleted from the people project and you want to know what, and by whom. Start with your own trail, since it's the cheapest call to make:

json
{
  "name": "read_my_audit_trail",
  "arguments": {
    "page_size": 5,
    "page_number": 0
  }
}

Refused — this session's credential carries hr-admin, not users or admin. Rather than working around it, move to the organization view, which hr-admin does clear:

json
{
  "name": "read_org_audit_trail",
  "arguments": {
    "page_size": 20,
    "page_number": 0,
    "project_id": "people",
    "method": "DELETE"
  }
}

That returns every DELETE recorded against people, across every caller, newest first. One row names acme-ops as the user — not you, which is exactly the point of this view — and carries uri: "minimal/api/rest/auto/v1/acme/people/pg/acme/employee" and session_id: "01M3G4RN2P7VXK1Q8CJT5WD6ZH", the session the delete ran under. Pivot into that session's own history:

json
{
  "name": "get_session_history",
  "arguments": {
    "session_id": "01M3G4RN2P7VXK1Q8CJT5WD6ZH",
    "page_size": 5,
    "page_number": 0
  }
}

If that session's status moved from active to something else shortly after the deletion, the history shows a second row, an UPDATE, carrying the new status and the timestamp it changed at — telling you not just what was deleted and by whom, but whether the session that did it was closed out afterward or is still sitting open. Three calls, three different scopes, one deletion accounted for end to end.

That second row is also the last one you'll ever get back this way. Once a session's status moves off active, its history stops answering — a fourth call to get_session_history against 01M3G4RN2P7VXK1Q8CJT5WD6ZH now comes back not-found, indistinguishable from a session that never existed. The two audit trails don't have that limit: the row naming this deletion stays readable in the organization view long after the session that made it is gone.

Exercises

  1. Using a credential of your own, call the route or tool for your own audit trail. If you're refused, read the message — it names the exact roles required. Find or request a credential that holds one of them, retry, and page forward until you get back an empty array. Confirm for yourself that the empty page comes back as a 200, not a 404.

  2. Pick a project in your organization and read its trail through the organization-wide view, narrowed to method=PATCH. Take the session_id off the most recent row and read that session's own history. Work out whether the session is still active or was closed after the PATCH ran — and if that row's session_id is absent instead, explain, from what this chapter covered, why that can happen.

7MCP in Depth

Volume I, Chapter 7 got you talking to Minimal over MCP: open a session, carry its id, call a tool, read what comes back. That covers the shape of the protocol. It does not cover the tools themselves — all 115 of them — or the handful of behaviors that only show up once you have used the surface hard enough to hit its edges.

One session, every call

A quick recap, because everything else in this chapter depends on it. Every tool but start_ai_session and help needs a session, carried as an X-Session-Id header on the tools/call request. You get that id by calling start_ai_session once, with a name and a context, both required:

json
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "start_ai_session",
    "arguments": {
      "name": "MCP live test",
      "context": "Exercising org details and project creation"
    }
  }
}

It answers with the session as text content:

json
{
  "name": "MCP live test",
  "org_id": "acme",
  "project_id": "people",
  "session_id": "01M25RTVZMFMCXWSW9KKS3CCZ5",
  "user_id": "mcp:acme-agent"
}

Every call after this one carries X-Session-Id: 01M25RTVZMFMCXWSW9KKS3CCZ5. Your organization, project, space, user and roles all come from the LB-Access-Token credential you present — no tool takes them as arguments, and nothing in a call can override them. The rest of this chapter drops the JSON-RPC envelope for readability and just shows the tool name, its arguments, and the shape of what comes back; assume the envelope and the session header are there on every one of them. Every tool in this chapter is refused the same way, too: as an isError result whose text opens with the API answered <status> <text>: followed by the underlying route's own error body, verbatim.

Here is get_org_details, called right after the session above, with no arguments at all:

json
{
  "abbreviation": "acme",
  "admin_email": "people-ops@acme.example",
  "admin_name": "Margaret Okonjo",
  "created": "2026-09-05T17:17:32.832849Z",
  "org_id": "acme",
  "org_name": "Acme Corporation",
  "org_read_roles": "hr-admin,hr-analyst,payroll-clerk",
  "org_write_roles": "hr-admin",
  "project_create_roles": "hr-admin",
  "status": "active"
}

Which organization it answers for is fixed by your credential — there is no argument for it — but reading it still needs a role in org_read_roles, the same gate Volume I established for this route.

The tool surface, by family

The 115 tools sort into the same resource families the rest of this book uses, in the same proportions the earlier chapters on each resource already gave you the full contract for. This chapter does not re-explain what a permission template's role columns mean, or what a lock mask's twelve booleans control — those chapters did. What follows is the map, family by family, with what a tool call surfaces that is either genuinely different from the equivalent HTTP request or easy to miss until you have used the surface directly.

flowchart TD
  subgraph L1["Organization structure"]
    ORG["Organization — 2"]
    PROJ["Projects — 5"]
    SPACE["Spaces — 5"]
    FEAT["Features — 5"]
  end
  subgraph L2["Access control"]
    PERM["Permission Templates — 6"]
    TPERM["Table Permissions — 8"]
    TLOCK["Table Locks & Indexing — 8"]
    CRED["Credentials — 8"]
  end
  subgraph L3["Schema and data"]
    DDL["Database — 11"]
    META["Meta — 4"]
    AUTO["Auto API — 4"]
  end
  subgraph L4["Application logic"]
    MOD["Modules — 12"]
    CAPI["Custom API — 12"]
    APPS["Apps — 7"]
  end
  subgraph L5["Agents and memory"]
    PERSONA["Persona — 6"]
    SESS["AI Session & Conversation — 8"]
    AUDIT["Audit — 2"]
  end
  subgraph L6["The listener itself"]
    PH["Proof & Health — 2"]
  end

The 115 MCP tools grouped by resource family. Each count is the number of tools in that family.

A few families call for a closer look, because calling them through MCP surfaces something a plain HTTP request leaves less visible.

Organization, Projects, Spaces and Features are the four families that give every other tool something to be scoped inside — you already used get_org_details above, and create_project the moment you built a project to work in. list_projects and get_project_history both mask a registered database's stored password behind a literal "********" in db_info; the field comes back shaped like a password, but it is never the real one. Spaces and features both mint their own ids when you leave one out of the call, but leaving it out is not on its own what clears a naming conflict: a create_spaces call for two spaces named dev and staging, each carrying its own space_id explicitly, was refused with a 424 naming a duplicate-key conflict, because a space by that id already existed in the project's history. Retrying with space_id left out — same name values otherwise — got back the identical 424. Only a third call, naming the spaces dev-mlt and staging-mlt instead, succeeded, and came back with fresh, platform-minted ids. Both families draw the same line twice — list_all_spaces beside list_my_spaces, list_all_features beside list_my_features — and the two halves are gated differently: the all_* tool needs a role that can write the project, because seeing across every owner is treated as an administrative act, while the my_* tool needs only a role that can read it. Do not assume the same split holds everywhere in this catalog: Apps has a list_my_apps too, but its own description says plainly that it is not only your own — it also returns anything another caller made and marked shared, despite the name.

Permission Templates round-trips the same way over MCP as it does over HTTP, tool for tool: check, create, list, read, update, delete. Here is the whole lifecycle from a single session, in order. First, check whether a template with these role lists already exists — check_similar_permission_template ignores name and description and compares role lists alone:

json
{
  "auto_api_read_roles": "hr-admin,hr-analyst",
  "m2m_read_roles": "hr-admin,hr-analyst",
  "mcp_read_roles": "hr-admin,hr-analyst"
}
json
{
  "org_id": "acme",
  "project_id": "people",
  "similar_exists": false
}

Nothing existed, so create_permission_template runs next and answers with a template_id. list_permission_templates and get_permission_template both read it back — the same object, from two different tools, because one lists many and the other reads one. Then update_permission_template changes its description, and delete_permission_template removes it. Read it once more after that and you get a genuine refusal, not a stale copy, its text naming the API's status and the start of its message:

the API answered 404 Not Found: {"code":404,"status":"Not Found","message":"permission template not found"}

That last call matters as a pattern, not just as a cleanup step: once a template, a definition, a module or an app is gone, every read-by-id tool for it answers 404 from then on, the same as it would for an id that never existed at all — a point the second half of this chapter comes back to.

Table Permissions and Table Locks are two families for one reason: a template answers who may act on a table, a lock mask answers whether the platform allows the action at all, on this channel, regardless of who is asking. assign_table_permission and get_table_permission_assignment read back cleanly:

json
{"db_type": "postgres", "schema": "acme", "table": "employee",
 "template_id": "01M1S96P76FQDPNYBV1RFSY8KH"}
json
{"lock_mask": 3842, "permission_template_id": "01M1S96P76FQDPNYBV1RFSY8KH",
 "permission_template_name": "acme-directory",
 "table_create_locked": false, "table_read_locked": false,
 "table_update_locked": true, "table_delete_locked": false,
 "m2m_fully_locked": true, "mcp_fully_locked": false, "table_fully_locked": false,
 "table_name": "employee", "table_type": "TABLE"}

Notice the three channel prefixes on every lock field — table_, m2m_, mcp_ — each locked independently. This table is fully closed to machine-to-machine callers, fully open to agent ones, and partly locked on the direct channel — only update is blocked there; create, read and delete are not. That distinction is exactly what makes the Auto API worked example later in this chapter need a set_table_lock_mask call in the middle of it.

Modules and Custom API sit next to each other because a definition's logic step calls a module — a Lark or Lua module holds the code, a definition holds the route, the roles, and the database it talks to. create_lark_module and create_lua_module both take just a name and a source body; the name is the module's only identifier, so a second create_* on a name already in use is refused rather than overwritten — update_lark_module and update_lua_module are how you change one that exists. Reading a module back (get_lark_module, get_lua_module) or listing them (list_lark_modules, list_lua_modules) both take an optional format, same as most row-shaped reads elsewhere in this catalog.

Meta is the smallest family and the one every other family that touches a database leans on. meta_list_databases answers the pair of values — a two-letter database-type code and a schema name — that meta_list_tables, meta_describe_table and all four Auto API tools expect. meta_describe_project answers the full database-type name instead — postgres, not pg — which is what assign_table_permission, bulk_assign_table_permission, index_project_table and every DDL tool expect. Two different spellings of the same database type, each tied to a different set of tools; get_table_permission_assignment above uses postgres, auto_read_rows further down uses pg. Keep both meta_list_databases and meta_describe_project in view rather than assuming one supplies what the other's callers need.

Database, at eleven tools, covers three different things under one name, plus check_db_config. Bringing a database into existence, or removing one, is one: ddl_create_database needs no token at all, where ddl_drop_database is gated by a minted, short-lived ddl_mint_drop_token. Running schema-changing statements against a database already registered is another: ddl_execute_run, ddl_get_run, ddl_list_runs, ddl_release_run_lock. Reading what a database's own catalog actually holds is a third: ddl_list_objects, ddl_describe_database_object, ddl_list_registered_databases.

A schema change made this way leaves a table's cataloged shape stale until the project's own Table Locks & Indexing family catalogs it again — index_project_table, index_project_tables and get_index_progress, the tools that tell the platform to go re-read a table so the permission and lock surfaces know about it. That last pair matters more than it looks. A table indexed for the first time comes back locked to every channel but direct-channel read, so an agent that indexes a table and immediately tries to write through Auto API or MCP will be refused until someone opens it deliberately with set_table_lock_mask.

Here is ddl_create_database, registering a scratch database on the same server the project already uses:

json
{"type": "postgres", "name": "mcp_ddl_test_20260910191115",
 "comment": "Ephemeral database for a live-test drop flow",
 "create": {"host_details": [{"host": "localhost", "port": "5433"}],
            "username": "minimalist", "password": "<db-password>"},
 "register": {"host_details": [{"host": "localhost", "port": "5433"}],
              "username": "minimalist", "password": "<db-password>"}}
json
{"run_id": "01M25S5NQPRZ4GA4DTDJW3ZXC9", "status": "succeeded",
 "database": {"type": "postgres", "name": "mcp_ddl_test_20260910191115",
              "character_set": "UTF8"},
 "registered": true}

create and register are deliberately two separate connections in the same call — one privileged enough to issue the create statement, one that gets stored for every later call against the database — and both are required in full even when they point at the same server. ddl_mint_drop_token follows the same registered name and answers a short-lived token that a later ddl_drop_database call must present; minting one when a usable pair does not already exist answers have_usable_pair: false with the reason attached, which is your signal that dropping the database still needs a second mint before it is armed. The two tokens also have to be minted at least 23 minutes apart — a deliberate cooling-off period, not a bug — and a drop attempted with a pair minted closer together than that is refused, 409, naming the reason plainly rather than treating the pair as simply invalid.

check_db_config tests a connection the same create/register shape above describes, without registering anything. A connection that answers is 200; one that does not is not a {"reachable": false}-style result to branch on — it is a genuine refusal, 424, carrying the underlying driver's own error text in details.

Credentials covers access tokens end to end: mint, read, patch, list, inspect history, reconcile, delete. create_access_token mints a token bounded by the roles your own credential already holds and hands back the real secret in access_token — the same value get_access_token or list_access_tokens return later, so nothing about the secret is hidden from you after the fact. list_all_access_tokens, the project-wide view across every owner, needs org_write_roles, not a project role — the same administrative-act reasoning Organization structure's all_*/my_* split above already established. Deleting and amending are not gated the same way. patch_access_token is creator-only — no role at any level substitutes for being the token's own owner — but delete_access_token accepts either ownership or org_write_roles: an administrator can delete a token they never created, something amending never lets them do. reconcile_access_token_roles stands apart from the rest of this family: it narrows every token a person owns down to the roles they currently hold, deleting a token that would be left with none. Called with dry_run: true it answers the plan without writing anything:

json
{
  "user_id": "mcp:acme-agent",
  "user_roles": "hr-admin",
  "dry_run": true
}
json
{"user_id": "mcp:acme-agent", "dry_run": true, "roles_before": "hr-admin,payroll-clerk,users",
 "roles_now": "hr-admin", "roles_removed": "payroll-clerk,users",
 "tokens_examined": 3, "tokens_narrowed": 0, "tokens_deleted": 1, "tokens_untouched": 2,
 "narrowed": [],
 "deleted": [{"token_id": "01M1S96TW5RD58JNX9MAHF6M4H",
              "name": "Acme MCP agent, audit reader",
              "had_roles": "payroll-clerk,users"}]}

Send the same call without dry_run and it performs exactly this plan. The tool can only ever subtract — a role the person still holds is never added to a token that lacks it — which is why the family has no separate "grant a role to an existing token" tool at all.

Persona, AI Session & Conversation, and Apps round out the agent-facing side of the surface. Creating a persona with create_personas sends several of its fields — description, context, competency, the default question — base64-encoded, exactly as its input schema says field by field. compositions on that same call is the identifiers of the narrower personas this one is assembled from, sent as an already-joined comma-separated string; update_persona_compositions is the tool that changes it afterward, as a real array rather than a string you joined yourself, taking a version label alongside the new list. deactivate_persona belongs on the "answers misleadingly" list two sections down as much as the two tools named there do: given a persona_id that matches nothing, it still answers 200 with {"rows_affected": 0} rather than a 404 — it never checks existence before running the update, so a typo'd id reads as quiet success unless you check the count. AI Session & Conversation is the family start_ai_session itself belongs to, and it is not limited to the one session your connection already carries: you can open a second, independent session with its own name and context, rename it, append a turn to it with append_conversation_turn, read the turns back with read_conversation, and close it with update_ai_session_status. Apps is the smallest of the three, a catalog of things built on top of Minimal rather than data stored in it — create_apps registers one with a base64-encoded app_info payload, and list_my_apps finds it again by id. Reading one back is where this family gets interesting; the next section covers it.

One convention holds once across the whole catalog rather than needing a note at each tool: page_number and page_size are both required with no default, counting pages from zero, on twenty-six tools spread through every family in the diagram above — every get_*_history, read_conversation, read_*_audit_trail and ddl_list_* tool, and most of the list_* ones. Seven list_* tools take no paging at all — list_all_spaces, list_my_spaces, list_all_features, list_my_features, list_projects, list_lark_modules, list_lua_modules — so check a tool's own schema before assuming a listing call needs paging. auto_read_rows is the one paged tool where the deployment also caps how many rows a page may carry, silently reducing a larger request rather than refusing it, the way Volume I Chapter 5 described; none of the other twenty-five apply that cap. Read on until a page comes back empty regardless — that check works everywhere, and prefer a filter over walking a large result to its end.

What an annotation tells you before you call it

Every tool the tools/list result and the help tool return carries an annotations block with four booleans, alongside its description and its input schema. You can read them without calling the tool at all — help with a tool's exact name returns its whole entry, schema included, and needs no session:

json
{
  "annotations": {
    "destructiveHint": false,
    "idempotentHint": false,
    "openWorldHint": false,
    "readOnlyHint": false
  },
  "description": "Registers one or more apps -- a catalog entry describing an application built on top of this platform: what it is, who owns it, and (inside app_info) whatever it needs to know about itself -- under your project, in a single call. ...",
  "inputSchema": {
    "additionalProperties": false,
    "properties": { "apps": { "description": "required, at least one app to register", "...": "..." } },
    "type": "object"
  }
}

Two of these four flags carry real, checkable information across the whole 115-tool surface; the other two do not.

destructiveHint distinguishes tools that can overwrite or remove something already stored from tools that only add to what is there or read what exists. Every create_* tool in the catalog is false. Every update_*, set_*, patch_* and delete_* tool is true, and so are a handful of tools outside those six prefixes whose own name says what they change — assign_table_permission, reconcile_access_token_roles, index_project_table among them. Every list_*, get_* and read_* tool is false. call_api stands apart from all of these: it is marked destructiveHint: true even though invoking a Custom API definition looks like a read from the tool's own shape, because what a definition actually does is entirely up to whoever registered it — it may run a SELECT, or it may run anything else. The flag is telling you the truth about the tool, not about any one definition behind it; read a definition's own documentation before calling it through call_api.

openWorldHint marks the tools that reach a host and port you name yourself in the call, rather than a database, table or object the project already has registered. Exactly three tools in the whole surface carry it: check_db_config, ddl_create_database and ddl_drop_database. All three take a host_details array — host and port, straight from the caller — because their whole purpose is to reach a server that is not registered with Minimal yet, or to create or drop a database on one. Every other tool that touches a database — every Auto API call, every DDL run, every meta_* read — addresses a database already on file by its registered schema name, never by a host you supply in the call.

An annotation describes the tool, not the one call you are about to make. reconcile_access_token_roles is marked destructiveHint: true even on a call that sends dry_run: true and writes nothing at all — the flag is telling you what the tool is capable of across every way it can be called, not what your particular arguments will do this time. Do not read a false on a create tool as a promise that this specific input will be accepted, either: it only means the tool's shape is additive, not that your input is valid.

The other two flags carry no signal here: idempotentHint and readOnlyHint are false on all 115 tools, list_* and get_* included. Do not build a decision ("this one is safe to call blind, this one is not") on either of them. destructiveHint and openWorldHint are the two flags to check before you write agent logic that calls a tool without a human confirming first.

Flag What it actually tells you here
destructiveHint true on every update_*, set_*, patch_*, delete_* tool, on a handful of others whose name says what they change, and on call_api, the one read-shaped exception. false on every create_*, list_*, get_*, read_* tool.
openWorldHint true only on check_db_config, ddl_create_database, ddl_drop_database — the three tools that take a host and port you supply, rather than a database already registered.
idempotentHint false on every tool. Carries no signal.
readOnlyHint false on every tool, including every listing and read. Carries no signal.

Two tools that answer misleadingly

Most of the 115 tools do exactly what their name and description promise. Two stand apart: the shape of a successful answer can be misleading, and an agent built on the wrong assumption gets it wrong quietly instead of loudly.

get_app_detail answers the same way for two different situations. It reads one app's stored app_info back as raw text. Call it on an app that exists:

json
{"app_id": "mcp-test-app-20260910"}
<h1>MCP Test App</h1><p>A trivial app for live-test verification.</p>

Call it on an app_id that does not exist at all, and you get a 200 success carrying the literal two-character text "[]" — not a refusal, not a 404, a success. The practical consequence: if your agent's logic is "a successful call means the app is there," it is wrong for this one tool. Check the returned text itself against the literal string "[]" before trusting a success as evidence the app exists. (render_app answers the same shape of content, but for a different, narrower reason: it additionally requires the app to be marked public, and unlike get_app_detail, a missing or non-public app there comes back as a genuine, named refusal — not a disguised success. Two tools that look almost identical from their schemas behave oppositely at the one moment you need them not to.)

delete_definition_by_route succeeds even when nothing was deleted. It removes a Custom API definition addressed by its stored path, method and version rather than by id. Here is a call made after the definition originally registered at that path had already been removed by id, and a copy of it had been made into a different space under a different version number:

json
{
  "uri": "acme/people/mcp-live-test/hello",
  "method": "get",
  "version": "2"
}
json
{"rows_affected": 0}

No error. The call reports success either way — whether it matched and removed one row, or matched nothing at all — and the only way to tell which happened is to read rows_affected rather than trust the bare fact that the call did not fail. A script that calls delete_definition_by_route for every path it thinks it created and checks only for an error will report success on paths that were never there, typos included.

Both of these are instances of one pattern that shows up more than twice in this catalog: get_access_token answers not-found identically for a token that belongs to someone else and one that never existed; call_api answers the same 417 for a URI that was never registered and one that was registered and later deleted. Design your error handling around the observable answer — the status, the body, the field you actually have to read — never around an assumption about which of several causes produced it.

What the listener refuses before any tool runs

A request can fail before JSON-RPC, before a session, before tool dispatch — before anything this chapter has assumed so far. The access-token gate on this listener runs ahead of everything else: ahead of the HTTP method check, ahead of protocol-version validation, ahead of JSON-RPC parsing itself. Send anything at all with no LB-Access-Token, and the answer is the identical 401 regardless of what else was wrong with the request:

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

A token that resolves but whose owner does not carry the deployment's configured MCP prefix on its identity is refused a layer further in, 403, naming the mismatch — a token minted for an ordinary user cannot be presented here, whatever roles it carries.

Only once a credential clears both checks do the protocol-level refusals below become reachable. They are easy to trigger by accident, and none of them is a tool refusal, so isError never applies to any of them:

  • A method other than POST on /minimal/mcp/rpc/v1 answers 405, header Allow: POST, an empty body — the one refusal on this listener that carries no JSON body of any kind.
  • A tools/call whose JSON-RPC body claims the modern _meta.protocolVersion shape, sent with no MCP-Protocol-Version HTTP header, answers 400 as a JSON-RPC error, code -32020.
  • An MCP-Protocol-Version header naming a revision the server does not speak answers 400, code -32022, naming both what it supports and what was sent. Minimal currently speaks exactly one revision, 2026-07-28. Leaving _meta off the request entirely — an older-style client — is accepted and answered as the legacy shape rather than triggering this check.
  • Calling initialize on the modern protocol is refused outright — this revision has no handshake step to skip — 404 at the transport level, JSON-RPC error -32601, "initialize" is not supported in the new protocol. A legacy-shaped initialize, with no modern _meta, is still answered normally, a real result.
  • A batched JSON-RPC request — an array of call objects in one POST body, rather than one object — is refused rather than executed: 400, code -32600, naming that batching is not part of this protocol revision. Nothing in the array runs, including a call inside it that would otherwise have succeeded on its own.

A client that only checks isError on a successful exchange will not see any of these five — they never reach a tool, so there is no result envelope for them to ride inside. Handling them means checking the HTTP status and, where the body is JSON-RPC at all, its error object, as a case separate from a tool's own isError result.

Building a prompt around several tools

A single tool call rarely finishes a real task. Here is a full sequence from one session, built around one goal: insert a test row into a table, confirm it is there, change it, then remove it — using Auto API tools together with a Table Locks tool, because the first attempt at the goal runs into a real refusal partway through.

Start with a read, to see the table's actual shape before writing to it:

json
{"type": "pg", "schema": "acme", "table": "employee",
 "page_size": 5, "page_number": 0, "query": {"oy": "id.as"}}

Rows come back with an id column filled — the table generates it. Insert a new row without sending one:

json
{"type": "pg", "schema": "acme", "table": "employee",
 "rows": [{"full_name": "MCP Test Employee", "email": "mcp-test@acme.example",
           "department_id": 11, "title": "Test Engineer",
           "hired_on": "2026-09-10", "is_active": true}]}
the API answered 417 Expectation Failed: {"code":417,"status":"Expectation Failed","message":"missing required fields: [id]"}

A generated column still has to be named in the row, sent as an explicit null, or the insert is refused as missing a required field rather than silently filled in. Retry with "id": null added to the row, and it succeeds:

json
{"rows_affected": 1}

Read it back by the email you just inserted, to get its generated id — 121 — then try to change its title:

json
{"type": "pg", "schema": "acme", "table": "employee",
 "set": "title.Senior Test Engineer", "query": {"id": "eq.121"}}
the API answered 403 Forbidden: {"code":403,"status":"Forbidden","message":"insufficient permissions for this operation on this table, got [hr-admin]"}

The role on the credential is right — hr-admin normally writes to this table — but the table's own lock mask has table_update_locked set, closing updates on this channel to everyone regardless of role. This is a different refusal from a missing role, and it needs a different tool: set_table_lock_mask to open the update lock, not a different credential.

json
{"db_type": "postgres", "schema": "acme", "table": "employee",
 "table_update_locked": false}

The update now succeeds, and the row is deleted afterward the same way it was inserted — auto_delete_rows with a filter naming its id, since a filterless delete is refused on an ordinary table precisely so a mistake like an empty query cannot empty the whole table.

The lesson for prompt design is in the shape of the whole sequence, not any one call. A prompt that tells an agent only "insert, then update, then delete" will stall at the first 417 and the first 403 unless it also tells the agent what a refusal means and which tool answers it: a missing-field refusal on an Auto API write usually means a generated column was left out entirely rather than sent as null; a permissions refusal that names your own role back at you is a lock-mask problem, not a role problem, and the fix is set_table_lock_mask or get_table_lock_status, not a different credential. Write the recovery step into the prompt itself, next to the goal, rather than assuming the happy path is the only path an agent will hit.

Exercises

  1. Using your own MCP session, list every permission template in your project with list_permission_templates, then call get_permission_template on the one whose mcp_read_roles field is non-empty. Report the exact role list it holds, and say whether your own credential's roles appear in it — you will need get_org_details or your own token's roles to answer that second part.

  2. Pick two tables you have write access to — one with row-level security in place (check with get_table_rls) and one without. On each, insert one row using Auto API, then attempt to delete it with auto_delete_rows sending an empty query object. Report exactly what comes back from each, and explain — from the two answers alone, not from this chapter — why an empty filter is treated differently on the row-scoped table than on the ordinary one.

  3. Call help with no arguments, then call it again by name on any five tools of your choosing from five different families in the diagram above. Before reading each one's description, write down what you expect destructiveHint and openWorldHint to be, based only on the tool's name. Then check your five predictions against the real annotations, and for any you got wrong, say what about the name misled you.

8Building for Production

Everything in this book so far has been one endpoint at a time: read a table, lock a table, mint a token, register a definition. A production system is the same endpoints under different pressure — chosen deliberately instead of by habit, credentialed narrowly instead of broadly and kept in step across whatever databases the project actually spans. This chapter does not introduce a new surface. It puts the ones you already know to work under that pressure.

Auto API or Custom API

Take a real table: employee, registered under acme in the people project, ten columns including id, manager_id and is_active. Two requests against it look almost alike and are not.

The first: "give me everyone hired in Engineering since the start of last year, newest first." That is a filter, a sort and a page — nothing about it needs code:

http
GET /minimal/api/rest/auto/v1/acme/people/pg/acme/employee?ps=20&pg=0&department_id=eq.11&hired_on=gt.2025-01-01&oy=hired_on.ds HTTP/1.1
LB-Access-Token: <your-access-token>

Answers 200:

json
[
  {
    "department_id": 11,
    "email": "elena.haddad31@acme.example",
    "full_name": "Elena Haddad",
    "hired_on": "2025-05-01T00:00:00Z",
    "id": 31,
    "is_active": true,
    "left_on": null,
    "manager_id": 25,
    "phone": "+44 20 7152 9962",
    "title": "Engineer"
  },
  {
    "department_id": 11,
    "email": "ingrid.petrov112@acme.example",
    "full_name": "Ingrid Petrov",
    "hired_on": "2025-04-07T00:00:00Z",
    "id": 112,
    "is_active": true,
    "left_on": null,
    "manager_id": 5,
    "phone": null,
    "title": "Director"
  },
  {
    "department_id": 11,
    "email": "chidi.okonkwo107@acme.example",
    "full_name": "Chidi Okonkwo",
    "hired_on": "2025-02-06T00:00:00Z",
    "id": 107,
    "is_active": true,
    "left_on": null,
    "manager_id": 19,
    "phone": "+44 20 7522 9016",
    "title": "Analyst"
  }
]

No fc was sent, so every column of employee comes back on every row, not a chosen few.

The second: "employee 3 is leaving; deactivate them and hand their direct reports to employee 1." That is two statements against one table that must both happen or neither should, and the caller should not have to know that "direct reports" means manager_id = 3 at all. Auto API can do half of it —

http
PUT /minimal/api/rest/auto/v1/acme/people/pg/acme/employee?set=is_active.false&id=eq.3 HTTP/1.1
LB-Access-Token: <your-access-token>

Answers 200:

json
{"rows_affected": 1}

— but the second half is a separate call, with no transaction tying the two together and no way to stop a caller who only ever sends the first one. That gap is what Custom API is for: a definition with a logic waterfall runs its steps as one transaction on every database except ClickHouse, so a failure partway through rolls back what already ran. A submitted document also needs identifiers, permission, config and database sections; only http and logic are shown here, since those are the two that carry this example's point.

yaml
http:
  uri: people/offboard
  method: post
  version: "1"
  input_type: json

logic:
  - sql: >-
      UPDATE employee SET is_active = false, left_on = CURRENT_DATE WHERE id = ?:employee_id
    omit: true
  - sql: >-
      UPDATE employee SET manager_id = ?:new_manager_id WHERE manager_id = ?:employee_id
    omit: true

A caller of the generated endpoint never sees manager_id or left_on, never runs two requests, and never learns the table's column names at all:

http
POST /minimal/api/rest/v1/acme/people/offboard?version=1 HTTP/1.1
LB-Access-Token: <your-access-token>
Content-Type: application/json

{"employee_id": 3, "new_manager_id": 1}

Answers 201:

json
{}

Both steps are marked omit, so the answer is the empty object a definition with no step left to report gives. Nothing stops the last step from being a script step instead of a plain sql one: because each step's output lands at step_<n> and every later one can see it, a closing step can return a count, a formatted string or a decision derived from what came before, in place of the raw rows or the empty array — a value no single UPDATE ever produces on its own. POST is the one verb this surface answers with 201; a definition registered on GET, PUT, PATCH or DELETE answers 200 instead.

That is the whole decision, stated generally: if the request is filtering, sorting or paging one table, Auto API already does it, with no logic to write or review. The moment the request needs more than one statement to succeed or fail together, needs to hand back a value the database itself never stored, or should never expose the table's real shape to whoever is calling it, that is a Custom API definition.

flowchart TD
    A[New request against a table] --> B{"Reads or writes ONE table:\nfilter / sort / page / one row"}
    B -- yes --> C[Auto API]
    B -- no --> D{"Needs more than one statement\nto succeed together, a value\nthe DB never stored, or must\nhide the table's real shape?"}
    D -- yes --> E[Custom API]
    D -- no --> C

Auto API handles generic CRUD on one table; Custom API earns its place the moment a request needs more than that.

Auto API is not weaker for being narrower. Every filter, the nineteen operators, grouping and paging are already covered in earlier chapters, and none of it has to be written or reviewed — that is the whole appeal. Custom API's cost is real: a YAML document capped at 16384 bytes, a role list, a database block carrying its own connection, and logic steps that are syntax-checked at submission but still run live at call time. Reach for it because the request needs what only it offers, not because it feels more like "real" engineering.

Credential hygiene

A token is not a password with a different shape — it is an alias for an identity, scoped down from it. roles on a new access token must be a non-empty subset of the caller's own X-User-Roles; a request asking for a role the caller does not hold is refused before anything is minted. key_type is four letters in create/read/update/delete order, each its own letter or a dash, so a token that should only ever read is minted -r-- rather than crud and trimmed down after the fact — because it cannot be. And token_type decides which of the three permission channels the token's calls are checked against: user, reviewer and anyone sit on the direct channel; mcp sits on the agent channel; m2m, cron and daemon sit on the machine-to-machine channel. A token minted mcp cannot spend an m2m-only grant, and the reverse.

Scoping narrowly means choosing the smallest combination of the three that the caller actually needs, at mint time — not because a wider token would be refused, but because nothing checks that choice again later:

http
POST /minimal/system/api/v1/access/token 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,hr-analyst
Content-Type: application/json

{
  "name": "nightly-headcount-read",
  "description": "Reads the employee and department tables for the nightly headcount job",
  "roles": "hr-analyst",
  "key_type": "-r--",
  "token_type": "m2m",
  "expires_in_days": 30
}

Answers 201:

json
{
  "token_id": "01M25S1VNH9EEXQZTR63MJXB4K",
  "access_token": "m2m:<placeholder-secret>",
  "roles": "hr-analyst",
  "key_type": "-r--",
  "token_type": "2m",
  "status": "active",
  "expires_at": "2026-10-10T13:45:26Z"
}

access_token is disclosed in the clear here, and again later only to the token's own creator, through the detail read or their own listing — a project-wide listing of every token deliberately omits the secret, so an administrator can see that a token exists without being able to spend it.

Sending token_type as the word m2m is accepted, but what comes back in token_type is always the stored two-letter code — 2m here — even though the secret's own prefix in access_token stays the full word. Reading token_type back, expect the code, not the word you sent.

What "sealed" means over MCP

Every call an agent makes through the MCP listener, apart from starting the session itself and asking for help, is recorded as two turns in that session — one before the tool runs, one after — so the conversation carries a full record of what it did. Most turns are recorded in the clear: the arguments and the result, readable later by whoever reads the session back. A handful of tools are the exception, because their arguments or their result carry a real secret rather than a description of one.

create_access_token is one: the response is the only place the token string is disclosed, so both turns it leaves are recorded sealed — ciphertext under the platform's own key, unreadable later even by the session's owner, regardless of whether the call succeeded. create_definition, update_definition and export_definition seal the same way, because a definition document carries the plaintext database password it connects with. So do check_db_config, create_project, ddl_create_database, ddl_drop_database and ddl_mint_drop_token — every call whose document can carry a live connection password or a bearer drop token. get_access_token, patch_access_token, list_access_tokens and get_access_token_history all return the secret in the clear, unsealed, because reading them back already requires owning the session the call was made under — there is no wider audience left to seal it against.

Sealing is not a substitute for scoping a token narrowly; it protects the session transcript, not the call itself. A credential-bearing call still runs with whatever authority the token carries — sealing only decides whether that call's own arguments and answer are legible to someone reading the conversation back later.

Rotating a token

A minted token cannot be widened. roles, key_type, token_type, the expiry and every identity field are refused outright on the amend route — sending any of them is a 400 naming the field, not a silent no-op. What amend does allow is the descriptive surface: name, description, tags, extended settings, and suspending or reactivating the token. So rotation is not an edit, it is a replacement: mint a new token with the roles the caller needs now, move whatever consumes the old one over to it, then retire the old one. Suspending first is the reversible step —

http
PATCH /minimal/system/api/v1/access/token HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-Space-Id: dev
X-User-Id: acme-admin
Content-Type: application/json

{"token_id": "01M1S96T7TJA3ZCQFAEC6662Q5", "status": "suspended"}

— and deleting is the one that is not. When a token's owner leaves a role rather than the organization, a token-by-token replacement is the wrong tool; role reconciliation narrows every token that owner holds down to the roles they still have, in one pass, and deletes any left with none. Run it with dry_run first:

http
POST /minimal/system/api/v1/access/token/reconcile/roles 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

{
  "user_id": "mcp:acme-agent",
  "user_roles": "hr-admin",
  "dry_run": true
}

Answers 200:

json
{
  "user_id": "mcp:acme-agent",
  "dry_run": true,
  "roles_before": "hr-admin,payroll-clerk,users",
  "roles_now": "hr-admin",
  "roles_removed": "payroll-clerk,users",
  "tokens_examined": 3,
  "tokens_narrowed": 0,
  "tokens_deleted": 1,
  "tokens_untouched": 2,
  "narrowed": [],
  "deleted": [
    {"token_id": "01M1S96TW5RD58JNX9MAHF6M4H", "name": "Acme MCP agent, audit reader", "had_roles": "payroll-clerk,users"}
  ]
}

The report only ever subtracts — it can narrow a token's roles or delete one left with nothing, never add a role or widen one. Run it again with dry_run false once the report looks right, and an empty role list is the shape of the call that departure makes: every token that person holds is removed.

Schema evolution across more than one database type

A project can register databases of more than one type — Acme's people project addresses both a Postgres database and a ClickHouse one — and the mechanism for changing a table's shape is the same regardless: one statement list, submitted against the database it targets.

http
POST /minimal/api/rest/ddl/v1/acme/people/run HTTP/1.1
X-Org-Id: acme
X-Project-Id: people
X-User-Id: acme-admin
X-User-Roles: hr-admin
Content-Type: application/yaml

database:
  type: postgres
  name: acme
sql:
  - "ALTER TABLE employee ADD COLUMN remote_location text"
comment: "Add remote_location ahead of the distributed-team rollout"

Answers 200:

json
{
  "run_id": "01M25RR3533Q3NFY0SD4BAFDHA",
  "status": "succeeded",
  "statement_count": 1,
  "executed": 1,
  "statements": [
    {"idx": 0, "status": "succeeded", "verb": "ALTER", "target": "employee"}
  ]
}

The route, the run record, the run history, the per-database lock that serializes concurrent runs, and idempotency_key for a safe retry — all of that is identical whichever of the four database types database.type names. What is not portable is the statement itself and a handful of rules the run route enforces differently per database type: a statement combining DROP with OWNED is refused only on Postgres, TRUNCATE, RENAME or DETACH combined with DATABASE only on ClickHouse, DROP combined with SCHEMA only on MySQL and MariaDB, and PREPARE, EXECUTE, DEALLOCATE and CALL outright only on MySQL and MariaDB. A statement list written and tested against Postgres is not guaranteed to be accepted, let alone correct, against the other three without being rewritten for them.

Transactionality narrows further still. Auto API writes and a Custom API definition's steps both run inside one transaction on every database except ClickHouse — but schema statements are transactional on Postgres only. A multi-statement run against MySQL, MariaDB or ClickHouse can leave some statements applied and later ones failed, which is exactly what on_error and each statement's own status in the run record are for: read them, because the run's own top-level status of partial is the only warning that some but not all of a batch landed.

Reading a table's live shape back is portable, with one exception: schema narrows the search to one namespace, and only Postgres has a namespace layer beneath the database — on the other three the only value that matches anything is the database's own name. And a describe call distinguishes a database type that has no concept of something from one that has the concept but nothing to report: null means the former, an empty list the latter. A generated column reads the same way — generated comes back null for an ordinary column, identity for one the database fills itself, stored for one computed from the row — and which of those three a database type offers for a given column type is itself not uniform across the four.

None of this is visible to Auto API or an MCP caller the moment the ALTER succeeds. The tools that read and write rows check column names against the table's shape as it was last cataloged, not as the database now stands — a column just added is visible to a live describe call and still refused by every write until the table is re-indexed. A brand-new table is stricter again: the first time it is indexed, it is locked on every channel but direct-channel read — an agent or a machine-to-machine caller gets nothing at all, not even read — and indexing assigns it no permission template. A table with no template is not thereby protected — the role check passes automatically for any operation the lock mask still leaves open, whoever is calling — so the lock, not the missing template, is the only thing standing between a freshly indexed table and every caller on every channel it has opened.

flowchart LR
    A["Run a DDL statement\n(one database)"] --> B["Table's cataloged shape\nis now stale"]
    B --> C["Re-index the table\n(or the whole project)"]
    C --> D{"First time this\ntable was indexed?"}
    D -- yes --> E["Locked on every channel but\ndirect-channel read; no template,\nso an open channel passes any role"]
    D -- no --> F["Existing lock mask and\ntemplate carry over"]
    E --> G["Open the lock mask only on\nthe channels it should serve"]
    G --> H["Assign a template to\nrestrict those channels by role"]
    F --> I["Direct / agent / machine-to-machine\nreach exactly what's open"]
    H --> I

A schema change is not usable through the generated APIs until the table is re-indexed; a new table is also wide open on any channel its lock leaves unblocked until a template narrows that by role.

If a change has to reach two databases, run it against each separately, in that database type's own dialect, and re-index each afterward — there is no step that mirrors one database's statement onto another, and none that catalogs a second database because the first one changed.

Is this ready? A closing checklist

Run this against the live system, not against what you meant to configure:

  • Every token in active use is scoped to what it actually spends. No token carries crud where -r-- would do; no token sits on the user channel when everything that calls it is a script. list_access_tokens and the project-wide listing say what is really out there.
  • Every token that should expire does. A token minted without expires_in_days takes the deployment's own configured default expiry, which can itself be "never" — read expires_at back on every standing token and confirm null is a deliberate choice, not an omission.
  • No table an open channel can reach is relying on "no template" as if it were a lock. A table carrying no permission template passes the role check automatically for whatever its lock mask leaves open — it is not protected by omission. Every table reachable on any channel carries a lock mask and a template that were both set on purpose, not the state a fresh index left it in.
  • A table holding one caller's own rows is scoped, not merely filtered by convention. Row-level security, from Chapter 4, is enforced on every call against that table; a filter a client remembers to send is not.
  • Every Custom API definition's mcp and m2m blocks say what you meant them to say. A definition with mcp.enabled: false is unreachable to every agent regardless of its role list, and the reverse is a definition an agent can call that nobody meant to expose.
  • A schema change that had to reach more than one database reached all of them, and each was re-indexed. A change validated on the database you use most and never run against the others is a change that has not shipped for the tenants on them.
  • The audit trail has been read, not assumed. Who has been minting tokens, running DDL, and changing permission templates is answered by read_org_audit_trail, not by memory.
  • A retried write carries an idempotency_key. Especially for schema runs: a retry sent blind after a timeout can run twice: with a key, the second call reliably returns the first run's own result instead.

Exercises

  1. Take a two-request Auto API sequence you can build against a table you can write to — a read followed by a write that depends on it, the way this chapter's is_active/manager_id example did. Rewrite it as a Custom API definition with a logic waterfall marking every step omit, register it, and call it. Confirm for yourself what the response body actually is when no step is left to report — do not assume it matches what an empty-result Auto API read would give you.

  2. Mint an access token scoped to exactly one role you hold and key_type: "-r--". Try to use it for a write, and confirm it is refused. Then run reconcile_access_token_roles with dry_run true against your own identity and read what it reports before running it for real.

  3. Run the same DDL statement against a database you can reach twice, with an identical idempotency_key both times. Confirm the second call's run_id matches the first, then run it a third time with the key changed and confirm a new run is actually executed.

Where to go next

This chapter closes the reasoning about how you use the API; Chapter 9 closes the reasoning about how the deployment itself is configured to run it. The appendices after that carry the reference material both leaned on without restating — every filter operator, every status code, the full permission-template role list, and the MCP tool catalog end to end. Reach for them when the shape of a call is the question, not the decision behind it.

9Configuration and Deployment

Every chapter before this one assumed a running deployment and talked to it as a caller. This one is about the file that decides what that deployment actually is: which port it listens on, how hard it is to reach, how much a single request may do before Minimal cuts it off, and a handful of defaults that are safe to ship and one that deliberately is not. sample/config.yml is the reference copy this chapter walks through; a real deployment's own file is the same shape, with real values in place of the sample's.

How the file is read

Minimal reads one YAML file at startup and does not re-read it while running — changing a setting means restarting the process. Most top-level sections are optional, and "optional" means two different things depending on the section. Leave mcp: out entirely and no second listener opens at all; the feature is simply absent. Leave access_token: out and the server does not refuse to start — it falls back to its own built-in defaults and says so loudly in the startup log, because a token quota and a retention period are deployment policy, and a default nobody actually chose should not pass unremarked. Read a section's own comments before assuming which kind of "optional" it is.

The service block

service names the deployment (name, version, environment) and the port it listens on (host, port). server_key is the credential every provisioning route in this book has required a sys:-prefixed caller to present alongside — organization lifecycle, the DDL-roles grant routes from Chapter 3, dropping a database. It is a single shared secret for the whole deployment, not scoped to one organization or project the way every other credential in this book is.

strict_local_host and cors_enabled are what they say. debug_mode and show_request_details widen what gets logged — real request bodies, real query strings — and are meant for a development deployment, not a production one holding real tenant data. shutdown_timeout bounds how long the process waits for in-flight requests to finish before a restart forces them closed.

Two nested blocks size how long the server waits on things. request_timeouts bounds the HTTP connection itself — how long it may take to read headers, read the body, write the response, and how long an idle keep-alive connection is held open. otel_timeout is unrelated to any caller's request; it bounds how long the server waits for its own telemetry exporter to accept a batch of spans, with a retry backoff (initial_interval, max_interval, max_elapsed_time) in case the collector is briefly unreachable. A caller's request is never blocked on telemetry succeeding — this timeout protects the server's own export loop, not the response path.

tls covers the listener's own certificate, minimum and maximum negotiated versions, cipher suite and curve preferences, and an optional nested mtls block for verifying a caller's own client certificate. mtls.require_and_verify is the one setting worth reading twice: true rejects any connection presenting no client certificate or an invalid one; false validates a certificate only if the caller sends one at all, accepting the connection either way if it doesn't. A deployment that means to require mutual TLS and leaves this false is not enforcing what it believes it is.

The definition store

definition_store is the database Minimal itself is built on — where every organization, project, space, table permission, and Custom API definition this book has created actually lives, as distinct from the tenant databases a project registers and reads rows from. It takes a type (Postgres or MariaDB), connection details, and its own pool sizing — max_open_connections, max_idle_connections, connect_timeout_secs, idle_timeout_secs, default_query_timeout_secs. Size this pool together with every registered tenant database's own pool if they share a server: they are separate pools drawing from the same server-side connection budget, and Postgres's own default (max_connections: 100, three reserved) leaves 97 to divide among every pool that touches it. Exceed it and a connection attempt fails with SQLSTATE 53300 — not a queue, not a retry, a hard refusal.

encrypt_database: true and an encryption_key (sourced from an environment variable, a file, or written inline, per encryption_key_source) are what Chapter 1 relied on when it noted that reading a definition back never discloses its database's real password, and what Chapter 3 relied on for a project's own masked db_info. The same key also seals a project's cached table information, DDL payloads, and any MCP session turn that carries a definition document — the sealed-turns mechanism Volume 1, Chapter 7 named. Rotating this key means re-encrypting everything it has ever sealed; it is not a value to change casually once a deployment holds real data.

max_body_bytes

One cap, applied to every request body Minimal reads, REST and MCP alike: 4 MiB by default, configurable, with no value that means "no cap" — a negative number is read as "unset," not "unlimited." The reason is mechanical: a request body is read into memory in one piece before anything runs against it, so this number is a real memory bound, not a formality. Raise it only once a legitimate body is actually being refused.

The script-step budget

script_steps bounds what a Lua or Starlark logic step, from Chapter 1's Custom API definitions, may do — six separate limits, because a script step is a different kind of risk than an ordinary SQL statement and needs its own accounting.

max_value_depth (default 32) bounds how deeply a value may nest crossing between Go and a script. This is not a size limit — a hand-written body rarely nests past five levels regardless — it is a defense against a self-referential value that would recurse until the goroutine stack is exhausted, a failure nothing can recover from once it starts.

max_execution_time_ms (default 1000) is best-effort, not a hard deadline, and the reason is concrete: the interpreter only checks this deadline between instructions, and a single call into a built-in function is one instruction as far as the interpreter is concerned — building a 1 GiB string with string.rep takes about 87 milliseconds and reports success regardless of what this setting says, because the deadline check never runs mid-call. What actually bounds a runaway built-in is max_value_bytes and max_pattern_length below, checked before the built-in is entered, not this setting.

max_value_bytes (default 1 MiB) is the setting that genuinely bounds memory: the largest single value one built-in call may produce or consume. Asking for an impossible one does not return an error — it kills the whole process with an out-of-memory abort no error handling can catch — so this is the number standing between a script and that outcome, sized here at a quarter of max_body_bytes by default, since a step's output has nowhere to go once it exceeds the response body's own cap.

max_pattern_length (default 256) bounds the longest pattern a string.find/match/gmatch/ gsub call will accept. It is a sanity bound, not the primary defense — the actual expensive patterns tend to be short, and what refuses a catastrophic one is a separate, non-configurable check weighing pattern complexity against subject length before the match ever starts.

max_concurrent (default 64) bounds how many script-bearing runs may execute at once, across the whole server — a definition with no script step never touches this limit at all, and a definition that has one holds its slot from the first script step through the end of its logic, SQL steps included. max_concurrent_per_org_pct (default 50) is a ceiling on top of that: the most of max_concurrent any one organization may hold while a second organization also has a run in flight, so one busy tenant cannot starve every other tenant sharing the deployment. A caller refused this way is told which of two things happened — 429 means their own organization's share is already spent and someone else is running; 503 means the server's whole script capacity is exhausted and nothing about the caller's own request caused it. Both carry Retry-After.

max_queue_wait_ms (default 1000) bounds how long a run waits for a free slot before being told the server is saturated, because nothing upstream of this service cancels a request's context on its own — without this bound, a queued run would wait on the caller's patience alone while holding an open transaction underneath it.

auto_api.permission_fail_open

One setting here is worth stopping on rather than cataloging alongside the rest. The sample configuration ships permission_fail_open: true, and its own comment marks it temporary: a permission-cache lookup failure — a transient database error, or a table that has not finished being assigned a permission template yet — is treated as allowed rather than denied while this is set. Read alongside Volume 1, Chapter 6's own rule that a table carrying no template passes every role check automatically for whatever its lock mask leaves open, this setting widens the same gap one step further: even a table that has a template, but whose permission lookup fails for an unrelated reason — the cache being cold, a transient store error — falls through to the same "allowed" outcome while this flag is set, not to a refusal. A deployment that has finished rolling out permission-template assignment to every table it governs is the deployment that can safely turn this to false and get an actual deny-by-default on a lookup failure; shipping it true indefinitely is carrying the sample's own bootstrapping default into production.

The rest of auto_api is sizing rather than policy: avg_columns_per_table and indexing_page_size tune how the background catalog scan batches its own work, and max_rows_per_page is the cap Volume 1, Chapter 5 already covered — the ceiling Minimal silently lowers an oversized ps to, rather than refusing the request outright.

access_token

The whole section is optional — and, per the file's own comment, deliberately loud about it when left out, since a token quota and a retention period are deployment policy rather than defaults nobody chose. max_per_user (default 50) is a ceiling per space, not per person; a caller present in ten spaces may hold ten times this many tokens in total, because a token is scoped to one space and its quota follows. default_expiry_days (default 0, meaning no expiry) applies only when a mint call omits expires_in_days itself — Chapter 8's closing checklist already asked you to confirm which of the two a standing token's null expiry actually is. retain_dead_days (default 30) is how long an expired token's row survives before the sweeper deletes it, with 0 disabling the delete pass while leaving the sweeper itself running. sweep_interval_secs (default 86400, once a day) is floored at 43200 (twelve hours) at startup regardless of what the file says — a non-positive interval would panic the sweeper's own goroutine before its recovery handler is even in place, so the floor exists to make that particular misconfiguration unreachable rather than merely inadvisable.

agent_identity

This is the section Chapter 1 pointed to when it described a caller reaching the plain REST Custom API route with raw identity headers instead of a token, and landing on the agent or machine-to-machine channel by the prefix on X-User-Id rather than by any token's token_type. mcp_prefix and m2m_prefix are exactly the two strings that mechanism checks against — "mcp:" and "m2m:" in the sample file — matched case-sensitively, with no fallback if the case is wrong. The whole section is optional; omit it, or leave a prefix blank, and Minimal never recognizes that channel from a raw header this way at all, so an otherwise agent-shaped caller falls through to being checked as direct against a definition's plain permission list every time — the same fallback Chapter 1 showed for a wrong-case prefix, just from a different cause. A deployment that mints access tokens for every agent caller, rather than admitting raw headers carrying this prefix, can leave the whole section out without losing anything it actually uses.

relay_server

An optional connection to a separate licensing and relay service — its own URL, a slug identifying this deployment to that service, TLS settings, and retry/timeout policy for reaching it, with an optional custom_tls block for a private certificate authority. Nothing in the rest of this book depends on this section; it governs how Minimal talks to infrastructure outside itself, not how a caller talks to Minimal.

The MCP listener

Every MCP example in this book and the last one assumed this section was already enabled: true. The sample ships it false — a second port, on the same process, speaking the Model Context Protocol, closed by default. Turning it on needs the block's own service sub-block at minimum (host and port); version, environment, log level, trace endpoint and server key are all inherited from the top-level service block when left blank on this one, and the admin and debug routes from the main listener are withheld here regardless of what the rest of the file says — this port is meant to serve agents, not double as a second administrative surface.

behind_reverse_proxy defaults false. The protocol stack refuses a loopback-received request whose Host header is not itself loopback, as DNS-rebinding protection. That default is correct for a listener exposed directly on loopback. Set this true only when a reverse proxy genuinely sits in front of this port and genuinely preserves the caller's original Host header end to end — turned on without that proxy actually in place, the effect is a total outage on this listener, not a security downgrade.

Is this configured to run? A closing checklist

Where Chapter 8's checklist asked whether a deployment's data is governed the way you meant, this one asks whether the process itself is:

  • permission_fail_open is false, or you have a specific, stated reason it is not, and you know which tables still lack a permission template if it must stay true a while longer.
  • server_key is a real secret, generated for this deployment, not the sample file's own value carried forward unchanged.
  • encryption_key is set from a real secret source, and you have a plan for what rotating it actually costs before you need to do it under pressure.
  • mtls.require_and_verify says what you mean it to — true if every caller must present a client certificate, false only if some legitimately do not.
  • access_token is set deliberately, not left to its defaults by omission, if a 50-tokens- per-space ceiling or a never-expiring token is not what this deployment actually wants.
  • mcp.behind_reverse_proxy matches how the listener is actually reached — false for a directly exposed loopback port, true only with a proxy in front of it that preserves Host.
  • max_body_bytes and the script_steps budgets are sized for what this deployment's own definitions actually do, not left at the sample's own defaults without having read what each one bounds.

The full reference file

Everything above, in the one file it actually comes from — the same sample/config.yml shipped in the repository, reproduced here in full so you can copy it directly and change the values rather than reassembling it section by section from this chapter's prose. Every key, comment, and default is exactly as the file ships; only the handful of values that are genuinely secret or specific to one machine — server_key, the definition store's password, the TLS certificate paths, and the relay license path — are replaced with placeholders, the same convention every credential in this book follows. Everything else, including every numeric default and every explanatory comment, is unchanged.

yaml
service:
  name: minimal
  host: "127.0.0.1"
  port: "3045"
  version: "0.0.1"
  environment: "development"
  server_key: "<a real secret, generated for this deployment>"
  log_level: "debug"
  debug_mode: true
  otel_endpoint: "localhost:4317"
  strict_local_host: true
  cors_enabled: true
  middleware_enabled: true
  trace_to_stdout: false
  show_request_details: false
  shutdown_timeout: 30
  otel_timeout:
    enable: true
    timeout: 10
    initial_interval: 1
    max_interval: 30
    max_elapsed_time: 120
  request_timeouts:
    read_header_timeout_secs: 10
    read_timeout_secs: 120
    write_timeout_secs: 120
    idle_timeout_secs: 120

  tls:
    enabled: false
    cert_file: "/path/to/your/cert.pem"
    key_file: "/path/to/your/key.pem"
    min_version: "1.3"         # allowed: "1.2", "1.3"
    max_version: "1.3"
    prefer_server_ciphers: true
    cipher_suites:
      - "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384"
      - "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384"
      - "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305"
      - "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305"
    curve_preferences:
      - "X25519"
      - "P256"
    mtls:
      enabled: false
      client_ca_file: "ca-bundle.pem"
      # true  = reject if cert absent or invalid
      # false = validate only if client sends one
      require_and_verify: true

# database and its configuration where all the
# definitions by users are stored 
definition_store:
  type: postgres
  port: "5433"

#  type: mariadb
#  port: "3306"

  username: minimalist
  password_source: file
  password: '<your-definition-store-password>'
  host: localhost
  schema: minimal
  # TLS negotiation for this connection. Optional -- one of "" (default),
  # disable, allow, prefer, require, verify-ca, verify-full. Left empty/
  # omitted, this keeps the same opportunistic-TLS behavior as before this
  # option existed (connects with TLS if the server offers it, falls back
  # to plaintext if not) -- nothing to change here on upgrade unless you
  # want to tighten it, e.g. "require" to reject a plaintext fallback.
  ssl_mode: ""
  connect_timeout_secs: 30
  idle_timeout_secs: 600

  # Connections this service opens to the definition store.
  #
  # Read it together with every TENANT database's own max_open_connections:
  # they are separate pools, and if they share a server they share its budget.
  # Postgres ships max_connections: 100 with 3 reserved, so 97 is the whole
  # budget for every pool on it. Exceed it and connections fail with SQLSTATE
  # 53300, which is not a queue and not a retry.
  #
  # This value sizes the definition store's own pool. A tenant pool is sized by
  # whatever registered or referenced that database, and a zero there is the
  # DRIVER's default rather than this number -- max(4, CPU count) on
  # PostgreSQL, unlimited on MySQL, MariaDB and ClickHouse. Set tenant pools
  # explicitly.
  max_open_connections: 20

  # Ignored on Postgres. pgxpool has no cap on retained idle connections --
  # its nearest setting is a floor it keeps warm, which is the opposite
  # quantity -- so this applies on MySQL, MariaDB and ClickHouse only. On
  # Postgres, idle_timeout_secs above is what bounds idle connections.
  max_idle_connections: 25
  default_query_timeout_secs: 60
  mode: single
  encrypt_database: true
  # The key that seals definition bodies, a project's database connection
  # details and cached table information, DDL payloads, and the MCP session
  # turns that carry a definition document. Required. Rotating it means
  # re-encrypting all of those.
  encryption_key_source: env
  encryption_key: ''

# Caps every request body. Omit it and 4 MiB applies; there is no value that
# disables the cap, and a negative one is read as "unset" rather than
# "unlimited". Raise it only if a legitimate body is being refused -- the
# ceiling exists because the body is read into memory in one piece.
max_body_bytes: 4194304

# Limits applied to a logic step written in a scripting language -- Starlark
# today, Lua next. Language agnostic on purpose: a caller must not be able to
# pick a language to get a laxer bound.
script_steps:
  # How deeply a value may nest when it crosses between Go and a script, in
  # either direction. Omit it and 32 applies.
  #
  # This is a safety bound, not a preference. Its real job is refusing a value
  # that refers to itself: that recurses until the goroutine stack is
  # exhausted, which is a fatal error no recovery can catch, so the process
  # dies and takes every in-flight request with it. Finite nesting is not the
  # danger -- 100,000 levels convert without incident -- a cycle is.
  #
  # Raise it if a legitimate payload is ever refused. A SQL result set is two
  # or three levels deep and a hand-written body rarely passes five, so 32
  # refuses shapes nobody writes on purpose. Values above 1024 are lowered to
  # 1024 with a log line, and zero or negative is read as "unset" rather than
  # "refuse everything".
  max_value_depth: 32

  # How long a single script step may run, in milliseconds. Omit it and 1000
  # applies.
  #
  # A step is one item in a waterfall that also opens a transaction, runs SQL
  # and formats a response, all inside one HTTP request -- so this is generous
  # for the shaping work a step exists to do and mean for anything else.
  #
  # Best-effort, not a hard limit, and here is the reason in one sentence: a
  # call into a built-in function is a SINGLE interpreter instruction, and the
  # deadline is only looked at between instructions, so whatever a built-in
  # starts doing it finishes doing. Building a 1 GiB string takes 87ms and
  # reports success; one 19-character pattern against a 24-character subject
  # runs for 3 seconds. Neither is stopped by this setting, at any value.
  #
  # What actually bounds those is max_value_bytes, max_pattern_length and
  # max_concurrent below, which are checked before the built-in is entered.
  # Treat this one as a bound on well-behaved scripts.
  #
  # Values above 600000 (ten minutes) are lowered to it. That is a backstop
  # against a budget set to hours, not a recommendation -- a step that needs
  # minutes is doing work that belongs somewhere else.
  #
  # Zero or negative is read as "unset" rather than "expire immediately",
  # which would fail every script step before it ran an instruction.
  #
  # Two limits sit above this one and neither is raised by changing it. A step
  # is still bound by service.request_timeouts.write_timeout_secs -- which is a
  # deadline on the CONNECTION's write, not on the handler: it does not cancel
  # the request, it just means the answer cannot be delivered. So a budget
  # larger than that buys nothing, because the connection is gone while the
  # step keeps running. And a SQL
  # step in the same waterfall has its own definition_store.default_query_
  # timeout_secs. This governs one script step, not the request it sits in.
  max_execution_time_ms: 1000

  # The largest single value a script built-in may produce or be handed, in
  # bytes: one string.rep, one string.format, one table.concat, and the text a
  # pattern is matched against. Omit it and 1 MiB applies.
  #
  # This is the setting that bounds memory, because max_execution_time_ms
  # cannot. A script asking for a 1 GiB string gets one in 87ms with no error;
  # asking for an impossible one does not fail, it kills the whole process with
  # an out-of-memory abort that no error handling anywhere can catch.
  #
  # 1 MiB is a quarter of max_body_bytes above. A step shapes a request into
  # the next step's input and its answer travels back out in a response body,
  # so a single value larger than the body cap has nowhere to go. Raise it only
  # if a legitimate value is being refused; values above 67108864 (64 MiB) are
  # lowered to that with a log line, and zero or negative is read as "unset"
  # rather than "refuse everything".
  max_value_bytes: 1048576

  # The longest pattern string.find, string.match, string.gmatch and
  # string.gsub will accept, in bytes. Omit it and 256 applies.
  #
  # A sanity bound, not the main protection. The expensive patterns are short:
  # the 3-second one mentioned above is nineteen characters. What refuses that
  # one is a check on the search itself, which weighs how long the text is
  # against how many quantifiers (* + - ?) the pattern contains and refuses the
  # combination before the match starts. That check is not configurable, on
  # purpose -- it is what stands between one request and a pinned CPU core.
  #
  # 256 is about eight times the longest pattern anyone writes by hand. Values
  # above 4096 are lowered to it.
  max_pattern_length: 256

  # How many script-bearing definition runs may execute at once, across the
  # whole server. Omit it and 64 applies.
  #
  # Every other limit here is per call. This one bounds how many of those calls
  # can be happening at all.
  #
  # The slot is taken when a definition reaches its FIRST script step and held
  # until its logic block ends. So:
  #
  #   - A definition with no script step never touches this limit.
  #   - It is one slot per request, not per step.
  #   - Any step after the first script step is inside the slot, SQL included.
  #     A slow query there holds a slot for as long as it runs.
  #
  # Raising it does not buy parallelism on its own -- Go caps that at GOMAXPROCS
  # regardless -- and it multiplies max_value_bytes above, so at 64 a 64 MiB
  # value limit is 4 GiB resident in the worst case. On a non-GET the tenant
  # database's pool is reached before this limit is, so size the pool first and
  # this second. Values above 4096 are lowered to it.
  max_concurrent: 64

  # How long a run may wait for a free slot before the caller is told the server
  # is saturated, in milliseconds. Omit it and 1000 applies.
  #
  # It has to be bounded here: nothing in front of this service cancels a
  # request's context on a timeout, so without it a queued run waits on the
  # client's patience alone while holding an open transaction. Values above
  # 60000 are lowered to it.
  max_queue_wait_ms: 1000

  # The most of max_concurrent any ONE organisation may hold, as a percentage.
  # Omit it and 50 applies.
  #
  # max_concurrent says how much the server may spend on script work; this says
  # whose requests spend it. Without it, callers are served in arrival order and
  # the tenant with the most requests in flight takes nearly all of them.
  #
  # It is a CEILING on each tenant, not a floor under any of them: it caps the
  # loudest and reserves nothing for one that arrives later. Divide 100 by the
  # number of tenants you expect to be busy at the same time -- 50 for two, 25
  # for four, 10 for ten -- and keep max_concurrent at or above that count.
  #
  # It applies only while a second organisation has a run in flight, so a
  # single-tenant deployment reaches the whole of max_concurrent. An
  # organisation at its share is refused even when slots are free: admitting it
  # because one happened to be free is how a busy tenant keeps its occupancy
  # high. Values above 100 are lowered to it.
  #
  # A refused caller is told which of two things happened:
  #
  #   429  your organisation is already running its share and someone else is
  #        here. It does not mean the server had room to spare.
  #   503  the server is running as much script work as it can. Nothing you did
  #        caused this.
  #
  # Both carry Retry-After, in seconds.
  max_concurrent_per_org_pct: 50

cache_settings:
  ttl_seconds: 1800
  permission_refresh_secs: 300
  __internal__enable_license: false

auto_api:
  avg_columns_per_table: 20
  indexing_page_size: 100
  max_rows_per_page: 100
  # TEMPORARY for this release -- true means a permission-cache lookup
  # error (transient DB error, or a table not indexed yet) is treated as
  # allowed rather than denied, since nothing assigns
  # project_table_info.permission_template_id yet. Remove this key (and
  # let it default to false/fail-closed) once table-permission assignment
  # is rolled out -- see auto/design/archive/auto/permission-cache-wiring.md and
  # /TODO.md.
  permission_fail_open: true


# Access tokens -- the alias a caller sends as one LB-Access-Token header
# instead of the five identity headers.
#
# The whole section is optional. Left out entirely, the server starts on the
# values below and says so loudly in the log, because a token quota and a
# retention period are deployment policy and defaults nobody chose should
# not pass unremarked.
access_token:
  # Most tokens one owner may hold in one space. A person present in ten
  # spaces may therefore hold ten times this many, which is intended: a
  # token is scoped to a space, so its quota is too. Suspended tokens count
  # (one call brings them back); expired ones do not.
  max_per_user: 50

  # Applied when a create omits expires_in_days. 0 means the token does not
  # expire.
  default_expiry_days: 0

  # How long an expired row is kept before the sweep deletes it. 0 disables
  # the delete pass entirely -- the sweeper still runs, it just removes
  # nothing.
  retain_dead_days: 30

  # How often the sweeper runs. Once a day by default. Anything below 43200
  # is raised to that floor at startup: this is hygiene, not a control loop,
  # so there is no reason to run it often and one good reason not to -- a
  # non-positive interval would panic the sweeper's goroutine before its
  # recover() is in place and take the process with it.
  sweep_interval_secs: 86400


# Optional. Recognizes MCP/M2M callers from a prefix on X-User-Id --
# minted upstream by the identity provider, not by minimal.v2. Omit this
# whole section (or leave a prefix blank) to leave that channel
# unrecognized -- such a caller then falls through to "direct" and is
# checked against the definition's plain `access:` roles, same as today.
# See api/design/archive/api/mcp-m2m-execution-gating.md.
agent_identity:
  mcp_prefix: "mcp:"
  m2m_prefix: "m2m:"

relay_server:
  url: http://localhost:3099
  slug: acmelabs
  tls_version: v1.3
  retry_count: 10
  timeout_secs: 10
  license_file: /path/to/your/license.json
  custom_tls:
    enabled: false
    cert_file: /path/to/your/internal-ca.crt
    tls_version: v1.2

# Optional. The MCP listener: a second port, in this same process, speaking
# the Model Context Protocol (revision 2026-07-28) to AI agents. Omit the
# whole section and no second port is opened. `enabled: false` with the
# block kept is a configured deployment switched off -- turning it on is one
# word. `enabled: true` requires the `service:` block below (host and port
# at minimum); version, environment, log level, trace endpoint and server
# key are inherited from the top-level `service:` when left blank, and the
# admin/debug routes are withheld on this port whatever the file says.
mcp:
  enabled: false
  service:
    name: minimal-mcp
    host: "127.0.0.1"
    port: "3046"
  # Set true only when this listener binds loopback with a reverse proxy in
  # front that preserves the caller's original Host header. The protocol
  # stack refuses loopback-received requests carrying a non-loopback Host
  # (DNS-rebinding protection) -- right for a directly exposed loopback
  # port, and a total outage behind such a proxy. Default false keeps the
  # protection.
  behind_reverse_proxy: false

Exercises

  1. Read your own deployment's configuration file and find auto_api.permission_fail_open. If it is true, list every table in one project that currently carries no permission template — Volume 1, Chapter 6 covers how — and explain what a lookup failure on any other table would currently do while this flag is set.

  2. Mint an access token with token_type: "mcp" for an identity that does not carry your deployment's agent_identity.mcp_prefix. Present the raw five identity headers instead of the token against a Custom API definition whose mcp.enabled is true, using that same unprefixed identity, and confirm which channel the call is actually checked against — the one the token would have granted, or the one the raw identity's missing prefix falls back to.

  3. If your deployment has the MCP listener enabled, read behind_reverse_proxy's current value and confirm, from how the listener is actually reached, whether that setting matches reality. If you have no proxy in front of it, confirm the setting is false.

10The Boundary in Front of Minimal

Volume 1 named the boundary before it named almost anything else: Minimal trusts X-Org-Id, X-Project-Id, X-Space-Id, X-User-Id, and X-User-Roles exactly as sent, because something in front of it — a private network, an internal sub-domain, or a gateway — is assumed to have already turned a real caller into those five values. Every chapter since has used that boundary without building one; the examples ran on a machine only you could reach, which makes sending the headers directly exactly as safe as running any other admin tool locally. This chapter builds the thing those examples assumed: a real boundary, backed by a real identity provider, standing between an open network and a deployment holding real tenant data.

Minimal has no code that knows Zitadel, Okta, or Keycloak exists. There is no plugin, no connector, and no configuration section named after any of the three — agent_identity and access_token, both from Chapter 9, are the entire surface Minimal offers for this, and both are provider-agnostic. What follows is how you use that narrow surface with three real identity providers, not a feature Minimal ships for any of them.

What the boundary has to do

Four things, in order, and the second one is where most first attempts get quietly wrong.

Authenticate the real caller. A password, a passkey, an existing corporate session, a client certificate, a signed service-account credential — whatever the identity provider already does. Minimal is not part of this step and never sees it.

Decide the five values, including which of the three channels the caller lands on. Chapter 1 covered how a caller ends up on the direct, agent, or machine-to-machine channel: an access token's own token_type, or a mcp:/m2m: prefix on X-User-Id when the caller presents raw headers instead. Minimal does not decide which real caller deserves which channel — it only compares a prefix string or a stored type code. That decision, and the mapping from whatever the identity provider calls a group or a role to whatever a Minimal permission template or Custom API definition calls a role, is the boundary's job alone.

Set those five headers itself, on the request Minimal actually receives — not add them to whatever the caller already sent. This is the step worth stopping on. Minimal's own DropSensitiveHeaders middleware strips Authorization, Proxy-Authorization, and Cookie from every request before anything reads them, on both listeners, so a caller's credential to some other system never reaches a Custom API definition or an MCP tool call. It does not strip X-Org-Id, X-Project-Id, X-Space-Id, X-User-Id, or X-User-Roles — Minimal reads whichever of those five arrive at its own listener and trusts them regardless of who set them. If your reverse proxy or gateway adds its own copy of X-User-Roles without first deleting one the original request already carried, and your proxy's header semantics append rather than replace, both values can survive to the origin depending on how the origin's HTTP library folds repeated headers — a gap that costs nothing to close and everything to leave open. Confirm your gateway strips these five names on the way in before it sets its own on the way out; do not assume "I set X-User-Roles" and "the caller's X-User-Roles is gone" are the same fact.

Be the only path in. sample/config.yml ships service.host as 127.0.0.1 — Minimal starts bound to loopback by default. Turning that into a deployment other people reach is what a boundary is for: either the boundary's own network interface is the only one with a route to Minimal's actual listener, or Minimal's service.tls.mtls block (Chapter 9) requires a client certificate only the boundary holds. A caller who reaches Minimal at all is assumed, by construction, to have already cleared this step — there is no fifth check inside Minimal that re-verifies it.

Two ways to hand identity across, and which one to prefer

A caller can present the five headers on every request, or present an access token and let Chapter 4's model do the rest. The boundary can implement either, and the two carry genuinely different risk.

Raw headers mean the boundary — or whatever sits between it and Minimal — has to get the translation right on every single request, forever, with nothing to catch a lapse: a header Minimal receives is trusted the moment it arrives, with no signature, no expiry, and no server- side record tying it back to the identity provider's own decision. A minted access token moves that translation to one moment — the login, or the machine credential's own provisioning step — and everything after it is a token lookup Minimal already does for every other caller in this book: expiring, revocable by suspending or deleting the row (Chapter 4), and capped per space by access_token.max_per_user (Chapter 9). The boundary authenticates the real caller once, decides its roles and channel once, calls POST /minimal/system/api/v1/access/token itself — using the five headers for that one call, the same as any caller minting a token — and hands the resulting LB-Access-Token to whoever it just authenticated. Every request after that carries only the token; nothing about the caller's own traffic needs the boundary to keep translating headers in real time.

The trade this makes explicit: an access token is a bearer credential like any other, so a leaked one is live until its expires_in_days runs out or someone suspends it. Set that expiry deliberately for exactly this reason, and prefer it over the never-expiring default Chapter 9's closing checklist already asks about. Raw headers, by contrast, are live for exactly as long as the boundary keeps asserting them and not a moment longer, at the cost of trusting the boundary's own translation logic to run correctly on every request rather than once. Either is real infrastructure. A deployment that mints tokens can leave agent_identity out of its configuration file entirely, per Chapter 9 — every non-direct caller then arrives already carrying the channel its token type names, and no raw mcp:/m2m: prefix is ever recognized at all.

The shape of it

graph LR
    Client["Real caller\n(person, AI agent, or service)"] -->|"OIDC login,\nor client credentials"| IdP["Identity provider\n(Zitadel, Okta, Keycloak)"]
    Client -->|"HTTPS"| GW["Gateway\n(validates the token,\nstrips inbound identity headers,\nsets its own)"]
    IdP -.->|"JWKS, token introspection"| GW
    GW -->|"X-Org-Id, X-Project-Id,\nX-Space-Id, X-User-Id,\nX-User-Roles — or LB-Access-Token"| Minimal["Minimal\n(loopback or private network)"]

The identity provider authenticates; the gateway translates and is the only thing on Minimal's network; Minimal does what every chapter before this one has shown, unaware that either exists.

The gateway is the part you build. It is not Minimal, it is not the identity provider, and nothing in this book supplies it — a JWT-validating reverse proxy (Envoy's JWT authentication filter, oauth2-proxy, Traefik's forward-auth middleware paired with a small auth service, or an API gateway's own OIDC plugin) is a well-worn shape, and any of them can play this role provided it does the four things above.

Zitadel as the identity provider

Zitadel issues a JWT access token on successful login or on a client-credentials exchange, whose claims your gateway reads after validating the signature against Zitadel's own JWKS endpoint. Zitadel organizes people into projects and roles: a project groups the applications and roles that belong together, a role is a named string granted to a user or to a whole organization via a project grant, and both surface as claims on the token your gateway receives once you turn on "assert roles on token" for that project. A service user — Zitadel's name for a non-interactive account — authenticates with its own client credentials and receives a token the same shape, which is what a machine-to-machine caller presents with nothing resembling a browser login anywhere in the exchange.

None of that is a Minimal role by itself. Your gateway is what turns a Zitadel role claim into a value on X-User-Roles, and turns "this token came from a service user" into the decision that this caller belongs on the machine-to-machine channel rather than direct or agent:

Zitadel claim What your gateway does with it
sub Becomes X-User-Id, or the identity a minted access token's owner_id records
Project roles on the token Mapped, by a table your gateway owns, to the role strings X-User-Roles carries
Token obtained via a service user's client credentials Channel decision: machine-to-machine — the gateway sets m2m: on X-User-Id, or mints a token with token_type: "m2m"
Token obtained via an interactive login, for a caller acting as an AI agent Channel decision: agent — mcp: on X-User-Id, or token_type: "mcp"
Everything else Channel decision: direct

The organization, project, and space a caller reaches are a Minimal-side fact Zitadel has no concept of at all — your gateway's mapping table is what supplies X-Org-Id, X-Project-Id, and X-Space-Id, typically keyed off which Zitadel organization or project grant the caller's token came from. Zitadel has no opinion on this either; the mapping is entirely yours to define and store.

Okta as the identity provider

Okta's shape is the same, with different names for the same three pieces. Okta issues a JWT from an authorization server, and the claims your gateway trusts after JWKS validation come from groups (Okta's own grouping of users, which a custom claim can surface on the token as a list) and from a custom authorization-server claim you define yourself, sourced from a user or app profile attribute. A machine-to-machine caller is an Okta service app using the client- credentials grant — no user, no browser, a token minted directly for the application itself.

Okta claim What your gateway does with it
sub Becomes X-User-Id, or the identity behind a minted access token
groups, or a custom claim built from one Mapped, by a table your gateway owns, to X-User-Roles
Token minted via a service app's client-credentials grant Channel decision: machine-to-machine
Token minted via an interactive login, for a caller acting as an AI agent Channel decision: agent
Everything else Channel decision: direct

Two things worth being deliberate about with Okta specifically. First, a profile attribute a user can edit themselves should never be the thing your gateway trusts for a role — read only a claim sourced from something an administrator controls, such as group membership, the same caution Chapter 6's row-level security chapter already applies to any value a caller can influence. Second, an Okta access token's audience is a claim about which application the token was minted for, not a statement about which Minimal organization, project, or space that caller may reach — your gateway's own mapping table is what decides that, the same as with Zitadel, and an audience check alone is never a substitute for it.

Keycloak, briefly

Keycloak follows the same three-piece shape: a realm is the tenant boundary, a client belongs to it, and a realm role or client role is granted to a user and surfaced on the token through a protocol mapper you configure to include it as a claim. A machine-to-machine caller is a client with its service accounts setting turned on, authenticating via client credentials exactly as Zitadel's service user and Okta's service app do. Keycloak is self-hosted — there is no managed edition supplying the gateway for you — so the JWT-validating gateway in front of Minimal is not optional infrastructure the way it might feel with a cloud-hosted Zitadel or Okta tenant; it is the piece that makes a self-run Keycloak into a boundary at all, same shape as the mapping table above with realm and client roles in place of Zitadel's project roles or Okta's groups.

A translation step, made concrete

Whichever product issues the token, the gateway's own logic is the same shape regardless of brand, and it is worth writing down once, plainly, rather than as a specific product's configuration file that will be stale by the time you read it:

on every request:
    delete any incoming X-Org-Id, X-Project-Id, X-Space-Id, X-User-Id, X-User-Roles
    read the bearer token; reject if missing, expired, or badly signed
    validate the signature against the issuer's current JWKS
    look up (issuer, subject) -> org, project, space, roles, channel
        in a table this gateway owns -- not in a claim the caller could shape
    if no row: refuse before the request reaches Minimal
    set X-Org-Id, X-Project-Id, X-Space-Id, X-User-Id (with mcp:/m2m: prefix if applicable),
        X-User-Roles
    forward to Minimal

or, for the token-minting variant from earlier in this chapter, the same lookup runs once at login or credential-provisioning time, and what the gateway hands the caller afterward is the access_token string Minimal's own mint route returned — nothing about the request-time logic above runs again until that token expires.

Fronting the MCP listener specifically

Everything above applies identically whether the boundary sits in front of the main listener or the separate MCP port from Chapter 9. One MCP-specific setting needs to agree with reality once a boundary is in the picture: mcp.behind_reverse_proxy, false by default, exists because the MCP protocol stack refuses a loopback-received request whose Host header is not itself loopback, as protection against DNS rebinding. Set it true only once the boundary genuinely sits in front of this port and genuinely preserves the caller's original Host header end to end; set it true without that proxy actually in place and the listener refuses every request outright, which is a total outage, not a security gap.

Is the boundary actually load-bearing? A closing checklist

  • The boundary strips the five identity headers from every inbound request before setting its own, not only after — confirmed by sending a request that already carries a forged X-User-Roles and checking which value Minimal actually receives, not which value the gateway config says it sets.
  • Minimal's own listener has no route from outside the boundary — service.host is not a public interface, or mtls.require_and_verify is true and only the boundary holds a matching client certificate.
  • The channel decision — direct, agent, or machine-to-machine — is made from something the identity provider controls, such as group membership or which credential type authenticated the call, never from a claim a caller can edit on their own profile. a
  • You have picked raw headers or minted tokens deliberately, not by accident of which one was easier to wire up first, and you know which one this deployment actually runs, since a boundary that does both inconsistently is harder to reason about than either alone.
  • If agent_identity is configured at all, its prefixes match exactly what the boundary writes — matched case-sensitively, with no fallback, per Chapter 9.
  • mcp.behind_reverse_proxy is true only where the boundary actually fronts that port and actually preserves Host, and false everywhere else.

Exercises

  1. Sketch the mapping table your own boundary would need for one identity provider you already use: one row per (issuer, subject) or one row per (issuer, group), and the org, project, space, and roles it resolves to. Identify which of those columns your identity provider could supply directly as a claim, and which only your own table can answer.

  2. Using the pseudocode in "A translation step, made concrete," write out what your gateway should do with a request that carries a valid, correctly signed token and a caller-supplied X-User-Roles: hr-admin header. Confirm your answer against what "set those five headers itself" actually requires, not against what seems like enough.

  3. Decide, for your own deployment, whether a machine-to-machine caller should present raw headers with an m2m:-prefixed X-User-Id, or a minted access token with token_type: "m2m". Write down the one sentence that justifies your choice — expiry, revocability, and how often that caller's roles change are the three considerations this chapter has actually made.

BFull MCP Tool Reference

Every tool below is invoked at the MCP endpoint via a tools/call, under the session convention from Chapter 7: call start_ai_session once and send the session_id it returns as X-Session-Id on every later call. Two tools are the exception and need no session — start_ai_session and help. Organization, project, space, user and roles all come from the LB-Access-Token (or the five identity headers) that the caller's credential presents, and most tools take none of them as an argument. A handful are the explicit, role-gated exception — update_org names an org_id other than the caller's own, reconcile_access_token_roles names a user_id other than the caller's, and copy_definitions names a target_space_id other than the caller's — each of those is called out in its own row below.

Each entry gives the tool's name, its purpose in one line, and the arguments its schema marks required — the ones a call is refused for omitting. Optional arguments, enum values, exact status codes, and field-level edge cases are not repeated here; read them from the tool's own schema with help (tool: "<name>"), or from the chapter that covers its resource. A handful of tools carry a required argument that JSON Schema cannot express — such as "send at least one of these two" — and those are called out in the purpose line itself.

None of the entries below cover the listener's own transport-level refusals — a non-POST method, a missing or unsupported protocol version, calling initialize on this revision, a batched JSON-RPC request, or the credential gate that outranks all four of those and runs first. Chapter 7's "What the listener refuses before any tool runs" covers that surface in full; nothing on it reaches a tool, so no row below answers for it.

Eighteen families are listed below. Provisioning does not appear: organization lifecycle and the DDL-roles grant are the routes gated on the deployment's server key, and every one of them is out of MCP's surface by definition, so no tool calls any of them. Bootstrap is not a family of its own either: creating a project and its spaces and features, and indexing its tables, are the same calls this book lists under Projects, Spaces, Features, and Table Locks & Indexing below; only creating the organization itself needs the server key.

1. Proof & health

The tool a client calls to prove the endpoint is reachable, and the one it calls to learn what everything else does. Of the two, only help needs no session; ping is recorded to the session and audited like every other tool.

Tool Purpose Required arguments
ping Answers pong, echoing an optional note, to prove the endpoint is reachable and the credential works. none
help With no argument, returns the server's session-convention instructions; with tool, returns that one tool's full entry (name, description, schema, annotations). none

2. Organization

The two reads and writes on the organization record itself — everything else about an organization (creating one, changing its status, its DDL-roles grant) is gated on the server key and has no tool.

Tool Purpose Required arguments
get_org_details Reads the caller's own organization's full record: contact details, the org role columns, status, and who last touched it. none
update_org Changes editable fields on an organization named explicitly by org_id, rather than the caller's own from the credential — it succeeds against any org whose org_write_roles overlaps a role the caller holds. org_id

3. Database

Registering a live connection, running DDL against it, and reading its catalog — the tools that exist because a table can't be read or written until its database is registered and the table itself indexed.

Tool Purpose Required arguments
check_db_config Tests whether a database connection is reachable, without registering it. An unreachable connection is a named refusal, not a reachable: false result to branch on. host_details, username, db_name, db_type
ddl_create_database Issues CREATE DATABASE on a server and registers the result on the project, as one call. type, name, create, register
ddl_describe_database_object Describes one object — table, view, function, trigger and the rest — read live from the database's own catalog, ignoring the platform's visibility gate. type, name, object
ddl_drop_database Permanently drops a registered database, spending two confirmation tokens and deregistering it from the project in the same call. type, name, confirm_tokens, drop
ddl_execute_run Runs one or more SQL statements against a registered database as one classified, all-or-nothing batch. type, name, sql
ddl_get_run Returns one schema run's statements, in the order they were sent, with the exact SQL text submitted. run_id
ddl_list_objects Lists everything a registered database actually holds, read live from its own catalog, one page at a time. type, name, page_size, page_number
ddl_list_registered_databases Lists the databases the project has registered as the DDL routes see them — database type, host, pool settings, never a password. page_size, page_number
ddl_list_runs Lists the project's schema runs, newest first, each as a summary. page_size, page_number
ddl_mint_drop_token Mints one of the two confirmation tokens ddl_drop_database requires; the pair must be minted at least 23 minutes apart. type, name
ddl_release_run_lock Clears a schema-change lock a crashed process left behind on a database. type, name, run_id

4. Projects

Creating, reading, changing and deleting the project a credential is scoped to.

Tool Purpose Required arguments
create_project Creates a new project under the caller's org and registers its first database connections. name, abbreviation, description, database
update_project Changes settings — name, description, embedding config, role lists — on the caller's own project; a role list sent replaces the whole column. none (at least one field)
delete_project Permanently deletes the caller's project and everything beneath it — spaces, features, definitions, templates, modules, apps, tokens, indexed tables, AI sessions. none
list_projects Lists every project under the org that the caller's roles can read. none
get_project_history Reads the archived history of the caller's project, newest first, gated against the project's current role list. page_size, page_number

5. Spaces

A space is the unit definitions, modules and tokens live inside, one level under a project.

Tool Purpose Required arguments
create_spaces Creates one or more spaces under the caller's project in a single call. spaces
update_spaces Replaces one or more spaces in full — every field on every entry is required and overwritten. spaces
list_all_spaces Lists every space in the project, whoever owns it — the administrative view. none
list_my_spaces Lists the spaces the caller owns in this project. none
get_space_history Reads the archived history of one space the caller owns, newest first. space_id, page_size, page_number

6. Features

Features mirror spaces structurally — a named, owned resource under a project.

Tool Purpose Required arguments
create_features Creates one or more features under the caller's project in a single call. features
update_features Replaces one or more features in full — every field on every entry is required and overwritten. features
list_all_features Lists every feature in the project, whoever owns it — the administrative view. none
list_my_features Lists the features the caller owns in this project. none
get_feature_history Reads the archived history of one feature the caller owns, newest first. feature_id, page_size, page_number

7. Permission templates

A template is a named set of role lists, one per Auto API verb crossed with caller channel (direct, agent, machine-to-machine), that a table's permission assignment points at.

Tool Purpose Required arguments
create_permission_template Creates a new permission template from up to twelve role lists. name
update_permission_template Replaces an existing template in full — a role list left out becomes empty, not left unchanged. template_id, name
delete_permission_template Removes one template by id, immediately, with no way back and no check for tables still assigned to it. template_id
get_permission_template Reads one template by id or by name. none (send template_id or name)
list_permission_templates Lists every template in the project, newest first. page_size, page_number
check_similar_permission_template Previews whether a role configuration collides with an existing template, writing nothing. none

8. Table permissions

Assigning a permission template to a table, and marking a table for row-level scoping so a caller only ever sees the rows that belong to them.

Tool Purpose Required arguments
assign_table_permission Assigns a permission template to one table, replacing whatever it already had. db_type, schema, table, template_id
bulk_assign_table_permission Assigns one template to many tables in a database at once, by explicit list (mode: "include") or by exclusion (mode: "exclude"). db_type, schema, template_id, mode (plus non-empty tables in include mode)
clear_table_permission Clears a table's template assignment, reverting it to fully open on every channel its lock mask doesn't close. db_type, schema, table
get_table_permission_assignment Reads one table's current template assignment and its decoded lock state together. db_type, schema, table
list_table_permission_assignments Lists every indexed table's template assignment and lock state in the project, filterable on any lock bit. page_size, page_number
set_table_rls Marks a table with a row-scoping column and the request header matched against it, so reads and writes are confined to the caller's own rows. db_type, schema, table, rls_column_name, rls_header_key
get_table_rls Reads a table's row-scoping marking without writing one to find out what it is. db_type, schema, table
clear_table_rls Removes a table's row-scoping marking; every caller sees every row again, subject to template and locks as before. db_type, schema, table

9. Table locks & indexing

Per-table, per-channel CRUD locks; visibility; and the indexing that catalogs a table before anything else here can address it.

Tool Purpose Required arguments
set_table_lock_mask Locks or unlocks one table's CRUD verbs per channel (direct, agent, machine-to-machine), independent of any template assignment. db_type, schema, table (plus at least one toggle)
bulk_set_table_lock_mask Applies the same lock toggles to many tables in a database at once, by explicit list (mode: "include") or by exclusion (mode: "exclude"). db_type, schema, mode (plus at least one toggle, and non-empty tables in include mode)
get_table_lock_status Reads one table's lock mask decoded into named booleans, plus per-channel fully-locked summaries. db_type, schema, table
set_table_visibility Hides or shows one table — a withheld table answers every read exactly as one that was never indexed. db_type, schema, table, is_visible
list_hidden_tables Lists every table the project is withholding. page_size, page_number
index_project_table Indexes one table synchronously and answers with the outcome (created or refreshed) directly. db_type, schema, table
index_project_tables Starts indexing every table in every database the project has registered; the call returns immediately. none
get_index_progress Reads whether the indexing started by index_project_tables has finished. none

10. Credentials

Minting and managing access tokens — the bearer credential that stands in for the five identity headers on a request.

Tool Purpose Required arguments
create_access_token Mints a new access token for the caller, bounded by the roles their own credential already holds. name, roles, key_type, token_type
get_access_token Reads one of the caller's own tokens by id, including the real secret string. token_id
patch_access_token Amends one of the caller's own tokens; only sent fields are touched. Only the token's own creator may call this. token_id
delete_access_token Removes one token by id, immediately, with no way back. Unlike amending, an org_write_roles holder may delete a token they did not create. token_id
list_access_tokens Lists the caller's own tokens, newest first, every filter optional. page_size, page_number
list_all_access_tokens Lists every token in the project across every space, newest first — the key column is never returned on this listing. Needs org_write_roles, not a project role. page_size, page_number
get_access_token_history Reads the archived writes to one of the caller's own tokens, newest first, token_key included. token_id, page_size, page_number
reconcile_access_token_roles Narrows every token a person owns down to the roles they still hold; can only subtract, never add. user_id, user_roles

11. Auto API

Row-level reads and writes on any cataloged table, gated per table by its lock mask and permission template.

Tool Purpose Required arguments
auto_read_rows Reads rows from one table, one page at a time; filters, column selection, ordering and grouping ride the query object. type, schema, table, page_size, page_number
auto_insert_rows Inserts rows into a table; an unknown column or a missing mandatory one is refused. type, schema, table, rows
auto_update_rows Updates the rows a filter matches; a filterless update is refused. type, schema, table, set
auto_delete_rows Deletes the rows a filter matches; a filterless delete is refused except on a row-scoped table, where it deletes every row that is the caller's own. type, schema, table

12. Modules

Reusable Starlark and Lua source that a Custom API definition's logic step calls into — two separate, otherwise identical stores, one per language.

Tool Purpose Required arguments
list_lark_modules Lists every Starlark module in the space, source included, unpaged. none
get_lark_module Reads one Starlark module by exact name: its source and the record around it. module
create_lark_module Creates a new Starlark module from supplied source; the name must be free. module, code
update_lark_module Replaces the source of an existing Starlark module in full; the old version stays readable in history. module, code
delete_lark_module Removes one Starlark module from the space, with no way back and no check for definitions still calling it. module
get_lark_module_history Reads the recorded versions of one Starlark module, newest first, name trimmed before matching. module, page_size, page_number
list_lua_modules Lists every Lua module in the space, source included, unpaged. none
get_lua_module Reads one Lua module by exact name: its source and the record around it. module
create_lua_module Creates a new Lua module from supplied source; the name must be free. module, code
update_lua_module Replaces the source of an existing Lua module in full; the old version stays readable in history. module, code
delete_lua_module Removes one Lua module from the space, with no way back and no check for definitions still calling it. module
get_lua_module_history Reads the recorded versions of one Lua module, newest first, name trimmed before matching. module, page_size, page_number

13. Custom API

Definitions — YAML documents declaring a path, method, version, role gates and an ordered logic pipeline — plus the one tool that invokes them.

Tool Purpose Required arguments
list_definitions Lists the Custom API definitions in the space, one page at a time; being listed does not by itself mean the caller may call one. page_size, page_number
get_definition Reads one definition's document by id, database password masked — not resubmittable as-is. definition_id
export_definition Returns one definition's stored document byte for byte, with the real database password in the clear — the copy to edit and resubmit. definition_id
create_definition Registers a new Custom API definition from one YAML document; the endpoint it declares goes live on success. document
update_definition Replaces an existing definition in full with a new YAML document, matched by path and method. document
patch_definition_attributes Changes a definition's documentation, tags, groups, and mcp/m2m channel settings without resubmitting the document — this is how a definition is opened to agents. definition_id
delete_definition Removes one definition by id, with no way back; its history becomes unreadable too. definition_id
delete_definition_by_route Removes one definition addressed by its stored path, method and version rather than by id. uri, method, version
delete_definitions Removes several definitions by id in one call, all or nothing. definition_ids
copy_definitions Copies a set of definitions from the caller's space into another, giving every copy a fresh id and a new version label. target_space_id, target_version, definition_ids
get_definition_history Reads the recorded writes to one definition, newest first. definition_id, page_size, page_number
call_api Invokes a Custom API definition by its URI, method and version, and relays whatever it answers — admitted only when the definition's mcp block is enabled and names one of the caller's roles. uri, method, version

14. Meta

Read-only discovery of what a project's registered databases actually contain, mostly from the platform's own catalog rather than a live query — except meta_describe_table, which reads columns live.

Tool Purpose Required arguments
meta_list_databases Lists the databases the project has registered, as the type/schema pair every table and row tool takes. none
meta_list_tables Lists the tables the platform has cataloged for one database in the project — cataloged and visible, not necessarily readable or writable. type, schema
meta_describe_table Describes one table's columns, read live from the database itself, so it reflects a schema change immediately. type, schema, table
meta_describe_project Describes the caller's project: name, role lists, schema-DDL roles, embedding config, and each registered database's connection settings (no passwords). none

15. Apps

A catalog entry describing an application built on the platform — a name, a kind, and a free-form app_info blob nothing here interprets.

Tool Purpose Required arguments
create_apps Registers one or more apps under the project in a single call; name must be unique per project. apps
update_apps Replaces one or more apps the caller made; every field on every entry is overwritten, no partial update. apps
delete_app Permanently removes one app the caller made — an app only shared with the caller can't be removed here. app_id
get_app_detail Reads one app's stored app_info, decoded from base64, as raw text; any project reader may read any app's detail. app_id
get_app_history Reads the archived history of one app, newest first, in the caller's chosen format; visibility follows list_my_apps. app_id, page_size, page_number
list_my_apps Lists the apps in the project the caller made, plus any others marked shared. page_size, page_number
render_app Reads one app's stored app_info as raw text, gated additionally on the app being marked public — shared alone is not enough. app_id

16. Persona

An organization-scoped named role an assistant can adopt: system prompt, competency, lens, and a default question.

Tool Purpose Required arguments
create_personas Creates one or more personas in the caller's organization; six fields must be sent base64-encoded. personas
update_personas Replaces one or more personas in a single call; every field on every entry is overwritten, no partial update. personas
deactivate_persona Retires one persona — a permanent, one-way deactivation invisible to every later read. Succeeds, rows_affected: 0, on an id that never existed rather than refusing. persona_id
update_persona_compositions Replaces one persona's compositions (the narrower personas it's assembled from) and its version label together. persona_id, version, compositions
list_personas Lists the personas available to the caller's organization — its own plus the platform's built-in ones, active only. page_size, page_number
get_persona_history Reads the archived history of one persona, newest first; a deactivated persona's history is closed for good. persona_id, page_size, page_number

17. AI — session & conversation

The session every other tool call runs under, and the turn-by-turn record of what happened inside it.

Tool Purpose Required arguments
start_ai_session Creates the AI session every later tool call acts under, and returns its session_id. name, context
rename_ai_session Sets the name and context of one of the caller's own sessions; both are written every time. session_id, name, context
update_ai_session_status Sets the status of one of the caller's own sessions, leaving name and context untouched. session_id, status
delete_ai_session Removes the record of one of the caller's own sessions; its conversation rows become unreachable. session_id
list_ai_sessions Lists the caller's own AI sessions, most recently updated first. page_size, page_number
get_session_history Reads the archived history of one of the caller's own AI sessions, newest first; narrower than list_ai_sessions, active sessions only. session_id, page_size, page_number
append_conversation_turn Appends one turn — user, persona, or a tool_call/tool_result pair — to one of the caller's own sessions. session_id, role, speaker_id, content
read_conversation Reads the turns of one of the caller's own sessions, oldest first, one page at a time. session_id, page_size, page_number

18. Audit

The recorded trail of every request made against the deployment — the caller's own, or, with the broader role, the whole organization's.

Tool Purpose Required arguments
read_my_audit_trail Reads the caller's own audit trail — every recorded request made under their identity in this organization, every tool call on this listener included. page_size, page_number
read_org_audit_trail Reads the whole organization's audit trail — every recorded request across every project and caller, newest first. page_size, page_number

CFull API Reference

Every REST route covered in either volume, one row per route, grouped by resource in the API's own order — not the order in which chapters introduced them. Method, path, what the call does, and what it takes to call it — nothing else. For request and response bodies, worked examples, and error conditions, see the chapter for that resource.

Base URLs. A path below is relative to your deployment's REST base URL (https://api.example.com, in this appendix's examples). Two routes, marked (MCP listener), live on the separate MCP port instead and are relative to that listener's own base URL.

Identity. X-Org-Id, X-User-Id and X-User-Roles are required on every route below. Outside the /minimal/system surface, X-Project-Id and X-Space-Id are both required too on every route except /minimal/rest/meta and the DDL prefix (four headers, no space) and /minimal/proof/ (none) — and that holds regardless of whether the resource itself has any concept of a space: Auto API, Apps, Persona and AI Session & Conversation all require X-Space-Id even though none of their handlers read it. Within /minimal/system, which headers beyond the three above are required varies route by route; check each row's own path and Auth column. X-Request-Id / X-Session-Id ride along optionally everywhere. Present a single LB-Access-Token in place of the identity headers instead, and it works on every route below, org- and project-record routes included. The Auth column names only what a route checks beyond identity: the role list it consults, or the fact that it consults none.

Path placeholders.

Placeholder Meaning
{org} The organization's abbreviation, lower case.
{project} The project's abbreviation, lower case.
{type} The database-type code: pg, ch, ms, ma are served; or, ss and md are recognized but not yet served.
{schema} The name the database was registered under in this project.
{table} A table name.
{language} lua or lark (Starlark).
{id} A resource's own identifier.
{path} A Custom API definition's own URI — any segment(s) its author chose.

Proof & health

Two unauthenticated endpoints for checking that a deployment is up and that a credential you hold is real — callable before a caller has any identity at all.

Method Path Purpose Auth
GET /minimal/proof/v1/ping Confirms the service is up and reachable; a liveness check. None
GET /minimal/proof/v1/validate/server/key Confirms a deployment key matches this server's configuration. The key is the raw request body — no JSON wrapper. None (the key itself is the credential)

Bootstrap

The sequence that takes a new deployment to a working tenant: create an organization, a project, its spaces and features, and index its tables. Every call it makes is one already documented under its own resource — Provisioning (organization), Projects, Spaces, Features, and Database (indexing) — so no new routes are listed here.


Provisioning

Organization lifecycle and cross-tenant administration. Every route here is gated by the deployment's own server key plus a sys:-prefixed X-User-Id, never by a role column — deciding who may create, suspend or destroy a tenant is the platform operator's act, not something a tenant's own administrators can grant themselves.

Method Path Purpose Auth
POST /minimal/system/api/v1/org Registers a new organization — the top-level tenant every project, space, feature and table permission sits beneath. Server key + sys:-prefixed X-User-Id
DELETE /minimal/system/api/v1/org Permanently deletes an organization and everything beneath it. Server key + sys:-prefixed X-User-Id
GET /minimal/system/api/v1/org/all Lists every organization in the deployment. Server key + sys:-prefixed X-User-Id
PATCH /minimal/system/api/v1/org/status Sets an organization's lifecycle status — the tenant kill switch. Anything but active blocks every call naming that organization. Server key + sys:-prefixed X-User-Id
PATCH /minimal/system/api/v1/org/ddl/roles Grants or revokes who may create or drop a database at the organization level. Server key + sys:-prefixed X-User-Id
PATCH /minimal/system/api/v1/project/ddl/roles Grants or revokes who may change or read a project's database shape. Server key + sys:-prefixed X-User-Id

Database

Registering the databases a project addresses, cataloging what they hold, and running schema changes against them.

Method Path Purpose Auth
POST /minimal/system/api/v1/check/db/config Opens a connection using a supplied set of details and runs a trivial query — a pre-flight check before saving them. project_create_roles (organization)
POST /minimal/system/api/v1/project/index Starts cataloging every table in every database the project has registered; the call returns before the work finishes. project_write_roles
GET /minimal/system/api/v1/project/index Reports how the indexing that call started is progressing, one entry per database. project_read_roles
POST /minimal/system/api/v1/project/table/index Catalogs exactly one table synchronously, and reports whether its entry was created or refreshed. project_write_roles
GET /minimal/api/rest/ddl/v1/{org}/{project}/database Lists the databases a project has registered, with hosts, stored username and pool settings. schema_read_roles (project)
POST /minimal/api/rest/ddl/v1/{org}/{project}/database Creates a new database on a server and registers it on the project in one call. database_create_roles (organization)
DELETE /minimal/api/rest/ddl/v1/{org}/{project}/database Destroys a registered database, spends its two confirmation tokens, and removes the registration. database_drop_roles (organization)
POST /minimal/api/rest/ddl/v1/{org}/{project}/database/drop-token Mints one of the two confirmation tokens a database drop must present. database_drop_roles (organization)
GET /minimal/api/rest/ddl/v1/{org}/{project}/objects Lists what a registered database actually holds, read live from its own catalog. schema_read_roles (project)
GET /minimal/api/rest/ddl/v1/{org}/{project}/objects/detail Describes one database object live — its columns, indexes, foreign keys, constraints and triggers. schema_read_roles (project)
POST /minimal/api/rest/ddl/v1/{org}/{project}/run Runs a list of schema statements against a registered database and records what each one did. schema_write_roles (project)
GET /minimal/api/rest/ddl/v1/{org}/{project}/run Lists a project's schema runs, newest first, filterable by database type, database, outcome and retry key. schema_read_roles (project)
GET /minimal/api/rest/ddl/v1/{org}/{project}/run/{id} Returns one schema run together with the exact statements it carried. schema_read_roles (project)
DELETE /minimal/api/rest/ddl/v1/{org}/{project}/lock Clears a schema-run lock left behind by a process that died. schema_write_roles (project)

Organization

Reading and updating the organization record itself, as an ordinary caller holding ordinary roles.

Method Path Purpose Auth
GET /minimal/system/api/v1/org Returns the full stored record for one organization — its details, role lists and status. org_read_roles
PUT /minimal/system/api/v1/org Updates an organization's details or role lists. Only the fields sent are changed. org_write_roles

Projects

A project is what a database connection is registered against, and what spaces, features and definitions sit beneath.

Method Path Purpose Auth
GET /minimal/system/api/v1/project Returns one project's stored record, with every database password masked. project_read_roles
GET /minimal/system/api/v1/project/unmasked The same record, with real decrypted database passwords instead of a mask. project_write_roles
GET /minimal/system/api/v1/project/all Lists the projects in an organization the caller may read, checking project_read_roles once per project; unreadable ones are omitted, not redacted. project_read_roles
GET /minimal/system/api/v1/project/history Returns every archived version of a project, newest first. Reads the role list off the project's own live row, so a deleted project's history stops being readable at all. project_read_roles
POST /minimal/system/api/v1/project Creates a project, registers the databases it addresses, and starts cataloging their tables; the call returns before the work finishes. project_create_roles (organization)
PUT /minimal/system/api/v1/project Updates a project's details, database registrations, role lists or embedding settings. project_write_roles
DELETE /minimal/system/api/v1/project Permanently deletes a project and everything inside it — spaces, features, definitions, templates, modules, apps, tokens, indexed tables, AI sessions. No undo. project_delete_roles

Spaces

The container that features, definitions and modules live inside. The same definition can exist in more than one space at a different version, which is how a change is proven before it reaches production.

Method Path Purpose Auth
GET /minimal/system/api/v1/space Lists the spaces in a project that the calling user owns. project_read_roles
GET /minimal/system/api/v1/space/all Lists every space in the project, regardless of owner. project_write_roles
GET /minimal/system/api/v1/space/history Returns the archived record of every write to a space the caller owns. project_read_roles
POST /minimal/system/api/v1/space Creates one or more spaces in a project. project_write_roles
PUT /minimal/system/api/v1/space Updates the name, description and contact address of existing spaces. project_write_roles

No route deletes a space.


Features

A feature groups related work inside a project, the way a product area does. The surface mirrors Spaces exactly.

Method Path Purpose Auth
GET /minimal/system/api/v1/feature Lists the features in a project that the calling user owns. project_read_roles
GET /minimal/system/api/v1/feature/all Lists every feature in the project, regardless of owner. project_write_roles
GET /minimal/system/api/v1/feature/history Returns the archived record of every write to a feature the caller owns. project_read_roles
POST /minimal/system/api/v1/feature Creates one or more features in a project. project_write_roles
PUT /minimal/system/api/v1/feature Updates the name, description and contact address of existing features. project_write_roles

No route deletes a feature.


Permission templates

A named, reusable permission set: twelve role lists — four verbs (create/read/update/ delete) across three channels — direct (auto_api_*), agent (mcp_*), and machine-to-machine (m2m_*) — assignable to any number of tables.

Method Path Purpose Auth
POST /minimal/system/api/v1/permission/template Creates a reusable access policy naming, per channel, which roles may create, read, update and delete. project_write_roles
GET /minimal/system/api/v1/permission/template Returns one access policy in full, by id or by name. project_write_roles
GET /minimal/system/api/v1/permission/template/all Lists a project's access policies, newest first. project_write_roles
POST /minimal/system/api/v1/permission/template/check/similar Checks whether a role configuration would collide with an existing template, without creating anything. project_write_roles
PUT /minimal/system/api/v1/permission/template Replaces an existing access policy in full — name, description, all twelve role lists. project_write_roles
DELETE /minimal/system/api/v1/permission/template Deletes one access policy from the project. project_write_roles

Every verb here, reads included, checks the write role: a template describes a table's security posture, so listing templates is treated as privileged.


Table permissions

Attaching a permission template to a table. Until a table carries one, the Auto API has nothing to consult for it.

Method Path Purpose Auth
PUT /minimal/system/api/v1/project/table/permission Points one indexed table at a permission template, or re-points it at a different one. project_write_roles
PUT /minimal/system/api/v1/project/table/permission/bulk Assigns one template to many tables at once — an explicit list, or every table but the ones named. project_write_roles
GET /minimal/system/api/v1/project/table/permission Shows one table's template assignment together with its lock state, decoded. project_read_roles
GET /minimal/system/api/v1/project/table/permission/all Lists every indexed table's template assignment and lock state, optionally narrowed. project_read_roles
DELETE /minimal/system/api/v1/project/table/permission Removes a table's template assignment. Does not delete the table's index record or touch its locks. project_write_roles

Table locks

An independent gate alongside a table's permission template, checked first. The template answers "which roles may do this"; the lock answers "may anyone do this at all," for every caller regardless of role.

Method Path Purpose Auth
PUT /minimal/system/api/v1/project/table/lock Locks or unlocks individual operations on one table, per access channel. project_write_roles
PUT /minimal/system/api/v1/project/table/lock/bulk Applies the same lock changes to many tables at once. project_write_roles
GET /minimal/system/api/v1/project/table/lock/status Reports one table's lock state, decoded into plain booleans. project_read_roles
PUT /minimal/system/api/v1/project/table/visibility Decides whether a cataloged table is offered to callers at all. project_write_roles
GET /minimal/system/api/v1/project/table/hidden Lists every table in the project marked not visible. project_write_roles
PUT /minimal/system/api/v1/project/table/rls Scopes a table to the caller, naming the scope column and the header matched against it. project_write_roles
GET /minimal/system/api/v1/project/table/rls Reports whether a table is row-scoped, and by which column and header. project_read_roles
DELETE /minimal/system/api/v1/project/table/rls Removes a table's row scoping; it goes back to answering every caller with every row. project_write_roles

A newly indexed table starts with a default lock mask closing eleven of its twelve operations, open only on the direct channel's read — the agent and machine-to-machine channels get nothing at all, not even read — so a table must be explicitly unlocked before anything but a direct read will reach it.


Credentials

A way to avoid sending the five identity headers on every call. It adds no authority beyond what those headers would carry.

Method Path Purpose Auth
POST /minimal/system/api/v1/access/token Mints an access token for the caller in one space — a single credential standing in for the five identity headers. No role column; any caller may mint a token carrying a subset of roles they already hold
GET /minimal/system/api/v1/access/token Reads one of the caller's own access tokens in full, token string included. No role column (owner only)
GET /minimal/system/api/v1/access/token/list Lists the caller's own access tokens in one space, with search filters. No role column (owner only)
GET /minimal/system/api/v1/access/token/all Lists every access token in the project, across all spaces and owners — the administrator's view. org_write_roles
PATCH /minimal/system/api/v1/access/token Amends one of the caller's own tokens: name, description, tags, or suspended state. Not its roles, verbs or expiry. No role column (owner only)
GET /minimal/system/api/v1/access/token/history Returns every archived version of one of the caller's own tokens. No role column (owner only)
POST /minimal/system/api/v1/access/token/reconcile/roles Narrows every token one person owns to the roles they still hold; a token left with none is deleted. org_write_roles
DELETE /minimal/system/api/v1/access/token Permanently removes one access token. No role column if deleting your own; org_write_roles to delete another's

Auto API

CRUD over a tenant table with no definition involved — the table is addressed directly in the path, one route per verb.

Method Path Purpose Auth
GET /minimal/api/rest/auto/v1/{org}/{project}/{type}/{schema}/{table} Reads rows. Column selection, filtering, ordering, grouping and paging are all query parameters; an unrecognized parameter is read as a column filter. Table's permission template, the read role list for the caller's channel (auto_api_read_roles on the direct channel; mcp_read_roles/m2m_read_roles for a token minted on the other two), checked after the table's lock state
POST /minimal/api/rest/auto/v1/{org}/{project}/{type}/{schema}/{table} Inserts one or more rows; the body is always an array. Table's permission template, the create role list for the caller's channel, checked after the table's lock state
PUT /minimal/api/rest/auto/v1/{org}/{project}/{type}/{schema}/{table} Updates rows matching a required filter — an update naming no filter is refused, not run unfiltered. Table's permission template, the update role list for the caller's channel, checked after the table's lock state
DELETE /minimal/api/rest/auto/v1/{org}/{project}/{type}/{schema}/{table} Deletes rows matching a required filter — same guard as update. Table's permission template, the delete role list for the caller's channel, checked after the table's lock state

Paging (ps, pg) is required on the read route; there is no default row ordering without oy.


Modules

Shared script code, stored server-side, that a Custom API definition's logic step can import. Starlark and Lua are separate stores with an identical, parallel route set.

Method Path Purpose Auth
POST /minimal/code/v1/{language} Creates a new module in one space, under a chosen name. project_write_roles
GET /minimal/code/v1/{language} Returns one module by name, source included in plain text. project_read_roles
GET /minimal/code/v1/{language}/list Lists every module in a space, each with full source. No paging. project_read_roles
PUT /minimal/code/v1/{language} Replaces the source of an existing module; it is recompiled before being stored. project_write_roles
GET /minimal/code/v1/{language}/history Returns the archived revisions of a module, newest first. project_read_roles
DELETE /minimal/code/v1/{language} Permanently removes a module. Its archived revisions survive. project_delete_roles

Custom API

A definition is a YAML document describing one HTTP endpoint and the logic behind it — write it once and it is served at the path, method and version it names.

Method Path Purpose Auth
POST /minimal/rest/definition/v1 Registers a new custom endpoint from a single YAML document. project_write_roles
GET /minimal/rest/definition/v1 Returns one definition's YAML, with its database password masked. project_read_roles
GET /minimal/rest/definition/v1/export Returns one definition's YAML byte-for-byte, real password included. project_write_roles
GET /minimal/rest/definition/v1/list Lists definitions in a space, filterable by method, channel, URI prefix or tag. project_read_roles
PUT /minimal/rest/definition/v1 Replaces an existing definition in full with a new YAML document — a whole-document replace, not a merge. project_write_roles
PATCH /minimal/rest/definition/v1/attributes Amends a definition's catalog and channel metadata (docs, tags, mcp/m2m settings) without resubmitting the YAML. project_write_roles
GET /minimal/rest/definition/v1/history Returns a definition's change history, newest first. project_read_roles
PUT /minimal/rest/definition/v1/space/copy Copies a named set of definitions from one space into another, with fresh identifiers. project_write_roles
DELETE /minimal/rest/definition/v1 Removes one definition, addressed by its path, method and version rather than its id. project_delete_roles
DELETE /minimal/rest/definition/v1/{id} Removes one definition by its identifier. project_delete_roles
DELETE /minimal/rest/definition/v1/bulk Removes several definitions at once, by identifier — all of them or none. project_delete_roles
(as defined) /minimal/api/rest/v1/{org}/{project}/{path} Invokes a live custom endpoint, at the method, URI and version its definition names. Requires ?version=. The definition's own role list for the caller's channel — direct, agent or machine-to-machine

Authorization, Proxy-Authorization and Cookie are stripped from every request before a definition's logic runs, so no definition can read a credential the caller addressed to another system.


Meta

What tables a registered database has, and what columns each one has — the pair of calls a UI makes before it can draw anything.

Method Path Purpose Auth
GET /minimal/rest/meta/v1/table/list Lists the tables already cataloged for one database, from the platform's own catalog. project_read_roles
GET /minimal/rest/meta/v1/table/detail Describes one table's columns live from the database — names, types, nullability, defaults. project_read_roles

Apps

An app record describes an application built on the API — a catalog entry rather than anything the server executes.

Method Path Purpose Auth
POST /minimal/rest/apps/v1 Creates one or more apps in a project. project_write_roles
GET /minimal/rest/apps/v1/detail Returns one app's stored content, decoded and ready to display. project_read_roles
GET /minimal/rest/apps/v1/list Lists the apps in a project the caller can see — their own, plus anyone else's marked shared. project_read_roles
GET /minimal/rest/apps/v1/render Serves an app marked public, as an HTML document, regardless of who owns it. project_read_roles
PUT /minimal/rest/apps/v1 Replaces the stored details of existing apps in full. project_write_roles
GET /minimal/rest/apps/v1/history Returns every archived version of an app. project_read_roles
DELETE /minimal/rest/apps/v1 Deletes one app the caller created. project_delete_roles

Persona

A persona is a named character an AI session runs as: its context, its system prompt, what it is competent at, and what it must not do.

Method Path Purpose Auth
POST /minimal/rest/ai/personas/v1 Creates one or more AI personas for the organization. project_write_roles
GET /minimal/rest/ai/personas/v1/list Lists active personas, or returns one in full, at one of three detail levels. project_read_roles
PUT /minimal/rest/ai/personas/v1 Replaces the stored details of existing personas in full. project_write_roles
PUT /minimal/rest/ai/personas/v1/compositions Replaces one persona's composition list and stamps a new version label. project_write_roles
GET /minimal/rest/ai/personas/v1/history Returns every archived version of a persona. project_read_roles
PATCH /minimal/rest/ai/personas/v1 Deactivates a persona; it stops appearing in listings and by-id reads. project_delete_roles

AI sessions and conversations

A session is a running conversation with a persona; the conversation routes are the turns inside it. A session belongs to the caller who created it.

Method Path Purpose Auth
POST /minimal/api/ai/v1/session Starts a new AI session owned by the calling user. project_write_roles
GET /minimal/api/ai/v1/session/list Lists the caller's own sessions, most recently updated first. project_read_roles
PUT /minimal/api/ai/v1/session Renames a session and replaces its context text. project_write_roles
PATCH /minimal/api/ai/v1/session Changes a session's status — for example, archiving it. project_write_roles
GET /minimal/api/ai/v1/session/history Returns the archived record of changes to a session's name, context and status — never what was said. project_read_roles
DELETE /minimal/api/ai/v1/session Permanently removes one of the caller's own sessions. project_delete_roles
PUT /minimal/api/ai/v1/conversation Appends one turn to a conversation in one of the caller's own sessions. project_write_roles
GET /minimal/api/ai/v1/conversation Reads back the turns of a conversation, oldest first. project_read_roles

Audit

Every call this API accepts leaves a row here — a read as much as a write. Two distinct views read it back.

Method Path Purpose Auth
GET /minimal/system/api/v1/audit/logs Reads the organization's audit trail — the administrator's view, across every user. project_create_roles (organization)
GET /minimal/api/audit/v1 Reads the caller's own audit trail — requests made under this organization by this user id only. X-User-Roles must include users or admin (not a role-column check)

MCP

The Model Context Protocol endpoint: one listener, on its own port, that lets an AI agent call this platform's APIs as tools. Every tool is a thin call onto an API the rest of this reference already covers — an agent gets no capability a direct caller does not have.

Method Path Purpose Auth
GET /minimal/mcp/ops/v1/health (MCP listener) Reports that the MCP listener specifically is serving, distinct from the REST port. None — the only credential-free route on this listener
POST /minimal/mcp/rpc/v1 (MCP listener) The single JSON-RPC endpoint: discovers the server, lists tools, or calls one. LB-Access-Token bound to an MCP identity, plus Content-Type, Accept, MCP-Protocol-Version, Mcp-Method (and Mcp-Name / X-Session-Id depending on the call); MCP must be enabled for the token's space
PATCH /minimal/system/api/v1/space/mcp Switches the MCP protocol on or off for one space. Server key + sys:-prefixed X-User-Id

An individual tool call is not a separate route: every tool is a tools/call JSON-RPC request against the one POST route above, distinguished by its Mcp-Name header and params.name. The tool catalog itself — names, descriptions, input schemas — is covered in the book's MCP chapters, not in this route reference.

Previous volume← Vol. I — Essential MinimalNext volumeVol. III — Identity and the Gateway →
littlebit labs

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

Products

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

Platform

Pricing Grants Security Transparency FAQ Docs Changelog Status

Company

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

Connect

GitHub X LinkedIn Discord YouTube

© 2026 Littlebit Labs. All rights reserved.

Built for humans and machines.