# Tablemere — full reference for agents and people Generated by platform/scripts/docs_build.py from the CLI and the control plane source on the day of the last site release. The HTML version is at https://tablemere.com/docs/. Support: hello@tablemere.eu. ## What Tablemere is A managed Apache Iceberg warehouse: an Iceberg REST catalog and S3-compatible object storage run as one system in Nuremberg, Germany, by Tablemere SL, a company in formation in Spain. Bring your own engine (DuckDB 1.5.5 or newer, PyIceberg 0.12, Spark 3.5 + Iceberg 1.11); Tablemere is never in the query path. Every warehouse is a separate catalog with its own storage identity, enforced by the catalog; storage credentials handed to engines are per table and short-lived. Open formats: Iceberg tables and Parquet files you can take elsewhere. Hostnames: - https://tablemere.com — website, signup, docs - https://api.tablemere.eu — the control plane API (accounts, projects, warehouses, tables, recipes, organisations, usage) - https://catalog.tablemere.eu — the Iceberg REST catalog (engines talk to it with the warehouse credential) - https://s3.tablemere.eu — S3-compatible storage (path-style, region us-east-1) The free plan meters one thing: storage, 5 GB. No card. Paid plans to be announced. # Getting started From an email address to a queryable Apache Iceberg catalog in a few minutes, from the terminal or from an agent. Everything here works against https://api.tablemere.eu today; nothing is described that the service does not do. ## What you get A catalog is one Iceberg REST catalog with its own object storage, run in Nuremberg, Germany, by Tablemere SL: it organises your tables and connects them to your tools; Tablemere includes the storage and the table maintenance. You bring the engine: DuckDB, PyIceberg or Spark read and write it directly; Tablemere is never in the query path. Credentials are per catalog, storage sessions are per table and short-lived, and every tenant is separated at the catalog. (The API calls a catalog a warehouse: /v1/warehouses, warehouse_id; the CLI accepts both words.) The free plan meters one thing, storage: 5 GB. No card. ## 1. Install the command line One Python file, standard library only, verified against a published SHA-256. Python 3.9 or newer. No sudo. ``` curl -fsSL https://tablemere.com/install.sh | sh export TABLEMERE_URL=https://api.tablemere.eu tablemere --version ``` Or with pip tooling: pipx install tablemere (the package on PyPI follows the same file; until the first release is published there, install from the URL above). The installer puts tablemere in ~/.local/bin and prints a PATH hint if that directory is not on your PATH. The CLI talks to https://api.tablemere.eu by default; set TABLEMERE_URL only for a self-hosted or local stack. Details: CLI reference (https://tablemere.com/docs/cli/). ## 2. Create your account and catalog Two calls. A six-digit code goes to your inbox (from no-reply@tablemere.eu), valid for ten minutes, five attempts. Verifying it creates your account, an organisation you own, and a catalog named lake. Accepting the Terms of Service (https://tablemere.com/legal/terms/) is required and recorded with the version you accepted; an agent should show them to a person and ask. ``` tablemere signup --email you@company.com --workspace acme # A 6-digit code was sent to your inbox for you@company.com (valid 10 minutes). Finish with: tablemere signup --email you@company.com --code 123456 --accept-terms --save ``` --save writes the API key to ~/.config/tablemere/credentials.json (mode 0600, keyed by API URL) so later commands find it. Without it, export TABLEMERE_API_KEY=al_live_… works, but the line lands in your shell history; prefer the file. The key is shown once and cannot be recovered, only replaced (tablemere recover --email …, or /recover (https://tablemere.com/recover)). Over HTTP: POST /v1/signup/start then POST /v1/signup/verify (API reference (https://tablemere.com/docs/api/)). In a browser, /signup (https://tablemere.com/signup) asks for your name, email, organisation name and handle and mails you a link to set your password; you then log in at /app/ (https://tablemere.com/app/?login) with your email and password and create tokens for your agents there; no API key is shown to a person on that path. tablemere login logs the CLI in through the browser (a code shown in the terminal, approved at /app/device) and saves an API key of yours with --save. ## 3. Connect your engine ``` tablemere connect --catalog lake --engine duckdb # or pyiceberg, spark ``` Prints a paste-ready recipe with the catalog's credential inside. For DuckDB it is four statements; the one that matters is ATTACH 'w-' AS lake (TYPE ICEBERG, …) with the bare bucket name: the s3:// form attaches read-only. Engines exchange the credential for catalog tokens (900 s) and per-table storage sessions themselves. All three recipes and their gotchas: Engines (https://tablemere.com/docs/engines/). ## 4. Load and query ``` CREATE TABLE lake.demo.cities AS SELECT * FROM read_parquet('cities.parquet'); SELECT country, count(*) FROM lake.demo.cities GROUP BY 1 ORDER BY 2 DESC; ``` That is an Iceberg table now: versioned, compacted by the catalog's maintenance, readable by any engine pointed at the catalog. To choose identifier fields, required columns or partitioning, create the table first with tablemere table create --column id:long:required:identifier … and then INSERT. Check what landed with tablemere table get --catalog lake --namespace demo --name cities. ## 5. Give agents their own keys ``` tablemere agent create --name reporter --grant :write # Agent reporter created (member of the organisation; grants: lake:write). Its API key, shown once: al_live_… ``` An agent key holds exactly the grants you give it, read or write per catalog (none until granted), is revoked on its own (tablemere agent revoke --agent-id …) and never manages the organisation or its people. Invite colleagues with tablemere org invite --email …; claim your company's email domain with tablemere org domain --domain … so verified colleagues can join. ## For agents Output is JSON whenever stdout is not a terminal; every error is a typed object with a code, a message and usually a remedy; the exit code carries the class (1 fix the input, 2 a real limit or conflict, 3 auth, 4 not found, 5 retry, 6 malformed command). tablemere usage (GET /v1/usage) lists every limit with its headroom so a limit is never met by failing. Every mutation takes --idempotency-key. The whole documentation as one plain-text file: /llms-full.txt (https://tablemere.com/llms-full.txt); the index: /llms.txt (https://tablemere.com/llms.txt). ## Limits on the free plan | what | value | kind | | storage | 5 GB | the one metered thing | | catalogs per project | 3 | guard | | tables per catalog · namespaces per catalog | 50 · 10 | guard | | objects | 50,000 | guard | | catalog operations · commits per month | 1,000,000 · 20,000 | guard | | snapshot retention | 20 snapshots · 7 days | maintenance, not disableable | Storage is measured hourly by maintenance and summed over the organisation; over the limit, creating catalogs or tables and vending write credentials answer 409 quota_exceeded (reading still works) until space is freed. Guards are abuse limits, not prices; the live values are whatever GET /v1/usage returns. Paid plans are to be announced. ## Not available today - A Snowflake data path (the catalog integration connects; reading data needs work on both sides). - Sign in with Google or any social login; accounts are created with your email address (a password set from the signup mail in the browser, a six-digit code in the CLI). A self-hosted deployment may trust its own OpenID Connect issuer instead. - A full web dashboard. There is /app/ (https://tablemere.com/app/), "Your account" (small, for people; agents use the API): log in with email and password (the session stays in the tab), see the organisation's catalogs with your level on each, create one, delete one by typing its name, create tokens for agents with read or write per catalog (the key is shown once), invite and manage members, read usage against the 5 GB, and run SQL with DuckDB compiled to WebAssembly in the browser (the site never sees the data; it talks to the catalog and the object store with your own scoped credentials). It refuses writes as a courtesy, not as a permission; the API and the CLI remain the primary interface. - An SLA; we publish what we measure instead. One site, daily off-host backups, rehearsed restore. - Paid plans (waiting for the company registration). ## Help hello@tablemere.eu. Security reports: security@tablemere.eu (security.txt (https://tablemere.com/.well-known/security.txt)). Legal: Terms (https://tablemere.com/legal/terms/), Privacy (https://tablemere.com/legal/privacy/), Acceptable use (https://tablemere.com/legal/aup/), Data processing (https://tablemere.com/legal/dpa/), Security overview (https://tablemere.com/legal/security/), Legal notice (https://tablemere.com/legal/legal-notice/). # CLI reference One Python file, standard library only, the same REST API a curl call would use. Output is JSON when stdout is not a terminal (or with --json), a table when it is. Anywhere a project_id or a catalog's warehouse_id is accepted, the name works too. The CLI says catalog where the API says warehouse: tablemere catalog … is the command group (tablemere warehouse … still works), and every --catalog flag takes --warehouse as well. ## Install ``` curl -fsSL https://tablemere.com/install.sh | sh # ~/.local/bin/tablemere, SHA-256 verified, no sudo pipx install tablemere # once published on PyPI; the same file export TABLEMERE_URL=https://api.tablemere.eu ``` ## Configuration | what | how | | TABLEMERE_URL / --url | API base URL. The built-in default is https://api.tablemere.eu (the service); set it to your own address for a self-hosted or local stack, e.g. http://localhost:8080. | | TABLEMERE_API_KEY / --api-key | The key. Otherwise read from the credentials file. | | TABLEMERE_CREDENTIALS | Path of the credentials file, default ~/.config/tablemere/credentials.json (0600, keyed by API URL; written by signup --save, recover --save, login --save). | | AGENTLAKE_* | The pre-rename names are still read as fallbacks, and ~/.config/agentlake/credentials.json is read (never written). | ## Exit codes | code | meaning | what to do | | 0 | ok | | | 1 | invalid input | the values you sent are wrong; fix them, never retry unchanged | | 2 | limit or conflict | a real quota or a real 409; stop and change the plan | | 3 | auth | key missing, wrong or revoked | | 4 | not found | no such project, catalog, namespace or table | | 5 | server or unreachable | ours or the network's; safe to retry with backoff | | 6 | usage error | the command line itself is malformed; fix the command, never retry | The server's typed error body goes to stderr verbatim: it names the limit, the current value and the remedy. ## Commands Rendered from the CLI's own --help at build time. ### tablemere ``` usage: tablemere [-h] [--version] [--url URL] [--api-key API_KEY] [--json] [--human] {signup,terms,account,login,recover,api-key,org,agent,whoami,usage,audit,project,catalog,namespace,table,credentials,connect,mcp} ... Tablemere: an open lakehouse you control. positional arguments: {signup,terms,account,login,recover,api-key,org,agent,whoami,usage,audit,project,catalog,namespace,table,credentials,connect,mcp} signup create your account and catalog: email -> code -> API key terms the Terms version in force and whether you accepted it account your account login connect this machine to your catalog: approve a code in the browser; an agent named after this machine is created with the access you approve, and its key is saved here, never printed recover lost API key: a code to your email gives you a new one api-key API keys of the caller org organisations: members, domains, invitations agent agents (tokens): machine identities of the organisation, each with its own key, its own grants and an organisation role whoami who am I, my limits, and what I hold on each catalog usage limits, current usage and headroom; --catalog for one catalog's storage and traffic by day audit who did what: your events, or your organisation's (owner/admin) project projects (placement is immutable) catalog catalogs: one Iceberg catalog per lakehouse, with its own storage and identity (the API names one by warehouse_id, under /v1/warehouses) namespace namespaces (created on demand by table create) table tables credentials short-lived, table-scoped storage credentials connect a paste-ready recipe for your own engine, carrying the identity that matches your level (read holders get the read-only one; snowflake always does) mcp the MCP server for Claude Desktop, Claude Code and other hosts: install its configuration with the saved key, or run it options: -h, --help show this help message and exit --version show program's version number and exit --url URL API base URL (env TABLEMERE_URL, default https://api.tablemere.eu) --api-key API_KEY API key (env TABLEMERE_API_KEY) --json force JSON output --human force human-readable output Output is JSON when piped and a table on a terminal. Anywhere a project_id or a catalog's warehouse_id is accepted, the name works too. Exit codes, one meaning each: 0 ok 1 invalid input the values you sent are wrong; fix them, do not retry 2 limit/conflict a real quota or a real 409; change the plan 3 auth key missing, wrong or revoked 4 not found no such project, catalog, namespace or table 5 server or unreachable (safe to retry) 6 usage error the command line is malformed; fix it, do not retry ``` ### tablemere signup ``` usage: tablemere signup [-h] --email EMAIL [--workspace WORKSPACE] [--full-name FULL_NAME] [--organization ORGANIZATION] [--handle HANDLE] [--code CODE] [--engine {duckdb,pyiceberg,spark}] [--save] [--show-key] [--accept-terms] options: -h, --help show this help message and exit --email EMAIL --workspace WORKSPACE workspace name (default: the part before @) --full-name FULL_NAME your name, for the account --organization ORGANIZATION organisation name; with --handle the organisation is reserved now --handle HANDLE the organisation's handle (tablemere.com/app/; lowercase letters, digits, dashes; fixed after signup) --code CODE the code you received; prompted for if omitted --engine {duckdb,pyiceberg,spark} --save store the API key in ~/.config/tablemere/credentials.json; with it the key is NOT printed --show-key print the API key even when it was saved (default: a saved key stays out of the terminal) --accept-terms accept the Terms of Service and Privacy Policy ``` ### tablemere terms ``` usage: tablemere terms [-h] [--accept] options: -h, --help show this help message and exit --accept ``` ### tablemere account ``` usage: tablemere account [-h] {delete} ... positional arguments: {delete} delete delete your account: keys revoked now, data purged within 30 days options: -h, --help show this help message and exit ``` ### tablemere account delete ``` usage: tablemere account delete [-h] --confirm CONFIRM options: -h, --help show this help message and exit --confirm CONFIRM your email, typed out ``` ### tablemere login ``` usage: tablemere login [-h] [--issuer [ISSUER]] [--label LABEL] [--name NAME] [--level {read,write}] [--catalog CATALOG_ID] [--new] [--engine ENGINE] [--save] [--show-key] [--accept-terms] options: -h, --help show this help message and exit --issuer [ISSUER] log in through an external OpenID Connect issuer instead of the Tablemere login (the first configured one, or the one named); for self-hosted deployments with their own identity provider --label LABEL API key label on the --issuer path (the Tablemere login labels it cli ) --name NAME the agent's name shown on the consent screen and in Connections (default: this machine's hostname) --level {read,write} the access to ask for (default write); the person approving may lower it to read --catalog, --warehouse CATALOG_ID connect to this catalog (repeatable; default: the organisation's default catalog) --new start a new authorisation even when the saved key still works --engine ENGINE --save kept for compatibility: the key is always saved to ~/.config/tablemere/credentials.json --show-key print the API key as well (default: it is saved and never shown; an agent should not need it) --accept-terms accept the Terms of Service on first login (--issuer path) ``` ### tablemere recover ``` usage: tablemere recover [-h] --email EMAIL [--code CODE] [--save] [--show-key] options: -h, --help show this help message and exit --email EMAIL --code CODE --save store the key in ~/.config/tablemere/credentials.json; with it the key is NOT printed --show-key print the key even when it was saved ``` ### tablemere api-key ``` usage: tablemere api-key [-h] {create,list,revoke} ... positional arguments: {create,list,revoke} options: -h, --help show this help message and exit ``` ### tablemere api-key create ``` usage: tablemere api-key create [-h] [--label LABEL] options: -h, --help show this help message and exit --label LABEL ``` ### tablemere api-key list ``` usage: tablemere api-key list [-h] options: -h, --help show this help message and exit ``` ### tablemere api-key revoke ``` usage: tablemere api-key revoke [-h] --key-id KEY_ID options: -h, --help show this help message and exit --key-id KEY_ID ``` ### tablemere org ``` usage: tablemere org [-h] {list,members,invite,accept,domain,set,role} ... positional arguments: {list,members,invite,accept,domain,set,role} domain claim your organisation's email domain (yours must be on it), or --remove it role change a member's role: admin or member (an organisation keeps at least one admin) options: -h, --help show this help message and exit ``` ### tablemere org list ``` usage: tablemere org list [-h] options: -h, --help show this help message and exit ``` ### tablemere org members ``` usage: tablemere org members [-h] [--org ORG] options: -h, --help show this help message and exit --org ORG ``` ### tablemere org invite ``` usage: tablemere org invite [-h] [--org ORG] --email EMAIL [--role {member,admin}] options: -h, --help show this help message and exit --org ORG --email EMAIL --role {member,admin} ``` ### tablemere org accept ``` usage: tablemere org accept [-h] --token TOKEN options: -h, --help show this help message and exit --token TOKEN ``` ### tablemere org domain ``` usage: tablemere org domain [-h] [--org ORG] --domain DOMAIN [--remove] options: -h, --help show this help message and exit --org ORG --domain DOMAIN --remove release the domain instead of claiming it (admin); domain join switches off when no domain remains ``` ### tablemere org set ``` usage: tablemere org set [-h] [--org ORG] [--name NAME] [--domain-join {on,off}] options: -h, --help show this help message and exit --org ORG --name NAME --domain-join {on,off} let verified-domain users join ``` ### tablemere org role ``` usage: tablemere org role [-h] [--org ORG] --principal PRINCIPAL --role {admin,member} options: -h, --help show this help message and exit --org ORG org_id or the organisation's handle (tablemere org list shows both); default: your only organisation --principal PRINCIPAL --role {admin,member} ``` ### tablemere agent ``` usage: tablemere agent [-h] {create,update,list,revoke} ... positional arguments: {create,update,list,revoke} create a new token; it holds exactly the grants given (none until granted otherwise) and the role given (member unless --role admin) update rename a token, replace its grants and/or change its role (admin, or the member who created it, within their own role) revoke revoke a token's key at once (admin, or the member who created it) options: -h, --help show this help message and exit ``` ### tablemere agent create ``` usage: tablemere agent create [-h] [--org ORG] --name NAME [--role {admin,member}] [--grant CATALOG_ID:LEVEL] [--warehouse CATALOG_ID] options: -h, --help show this help message and exit --org ORG org_id or the organisation's handle (tablemere org list shows both); default: your only organisation --name NAME --role {admin,member} organisation role of the token (default member): what it may do to the organisation, its people and its tokens; capped by your own role. Data access is its grants --grant CATALOG_ID:LEVEL : (the catalog's id in the API), repeatable; capped by your own level on each catalog --warehouse CATALOG_ID deprecated: same as --grant :write ``` ### tablemere agent update ``` usage: tablemere agent update [-h] [--org ORG] --agent-id AGENT_ID [--name NAME] [--role {admin,member}] [--grant CATALOG_ID:LEVEL] options: -h, --help show this help message and exit --org ORG org_id or the organisation's handle (tablemere org list shows both); default: your only organisation --agent-id AGENT_ID --name NAME --role {admin,member} new organisation role; capped by your own --grant CATALOG_ID:LEVEL : (the catalog's id in the API), repeatable; the list REPLACES the current grants ``` ### tablemere agent list ``` usage: tablemere agent list [-h] [--org ORG] options: -h, --help show this help message and exit --org ORG org_id or the organisation's handle (tablemere org list shows both); default: your only organisation ``` ### tablemere agent revoke ``` usage: tablemere agent revoke [-h] [--org ORG] --agent-id AGENT_ID options: -h, --help show this help message and exit --org ORG org_id or the organisation's handle (tablemere org list shows both); default: your only organisation --agent-id AGENT_ID ``` ### tablemere whoami ``` usage: tablemere whoami [-h] options: -h, --help show this help message and exit ``` ### tablemere usage ``` usage: tablemere usage [-h] [--catalog CATALOG] [--days DAYS] options: -h, --help show this help message and exit --catalog, --warehouse CATALOG one catalog's view, day by day: stored bytes and objects, S3 requests and bytes in/out; the catalog's warehouse_id (UUID) or its name; list both with: tablemere catalog list --days DAYS length of the daily history, 1..30 (default 7) ``` ### tablemere audit ``` usage: tablemere audit [-h] [--org ORG] [--since SINCE] [--action ACTION] [--limit LIMIT] options: -h, --help show this help message and exit --org ORG org_id: the organisation's events (owners and admins) instead of only yours --since SINCE 24h, 7d, 30m, or an ISO-8601 timestamp --action ACTION one action, e.g. credentials.vend, or a prefix like org.* --limit LIMIT at most this many events (max 1000) ``` ### tablemere project ``` usage: tablemere project [-h] {create,list} ... positional arguments: {create,list} options: -h, --help show this help message and exit ``` ### tablemere project create ``` usage: tablemere project create [-h] --name NAME [--provider PROVIDER] [--region REGION] [--idempotency-key IDEMPOTENCY_KEY] options: -h, --help show this help message and exit --name NAME --provider PROVIDER default: the deployment's own --region REGION default: the deployment's own placement --idempotency-key IDEMPOTENCY_KEY any string; safe to reuse when retrying the same call ``` ### tablemere project list ``` usage: tablemere project list [-h] options: -h, --help show this help message and exit ``` ### tablemere catalog ``` usage: tablemere catalog [-h] {create,list,rotate,grants,grant,revoke,delete,publish,unpublish,public-url} ... positional arguments: {create,list,rotate,grants,grant,revoke,delete,publish,unpublish,public-url} create a new catalog; {name} alone puts it in the organisation's default project list every catalog you can see: name and warehouse_id side by side rotate replace the catalog's credentials (read/write and read-only); the old keys and their tokens are refused at once, vended storage sessions run out within 900s grants who holds read or write on a catalog, and how (admin, membership, creator, explicit) grant grant read or write on a catalog to a member or an agent (admin, or a write holder up to write) revoke revoke a grant (admin, or whoever made it); revoking a member's membership read records level none delete delete a catalog for good (admin): tables, buckets, identities, grants; the name typed out publish make a catalog PUBLIC (admin, the name typed out): every data file and every retained metadata.json becomes readable by anyone without credentials at its public URL; the REST catalog endpoint stays private; its bytes count against the 50 GB public allowance unpublish make a public catalog private again (admin): anonymous reads are refused at once public-url the public URL of a catalog and the no-credentials DuckDB recipe; with --table, that table's current metadata URL (the thing to paste into iceberg_scan) options: -h, --help show this help message and exit ``` ### tablemere catalog create ``` usage: tablemere catalog create [-h] --name NAME [--project PROJECT] [--idempotency-key IDEMPOTENCY_KEY] options: -h, --help show this help message and exit --name NAME --project PROJECT project_id (UUID) or project name; list both with: tablemere project list (default: the organisation's default project) --idempotency-key IDEMPOTENCY_KEY any string; safe to reuse when retrying the same call ``` ### tablemere catalog list ``` usage: tablemere catalog list [-h] [--project PROJECT] options: -h, --help show this help message and exit --project PROJECT project_id (UUID) or project name; list both with: tablemere project list (default: every project you can see) ``` ### tablemere catalog rotate ``` usage: tablemere catalog rotate [-h] --catalog CATALOG options: -h, --help show this help message and exit --catalog, --warehouse CATALOG the catalog's warehouse_id (UUID) or its name; list both with: tablemere catalog list ``` ### tablemere catalog grants ``` usage: tablemere catalog grants [-h] --catalog CATALOG options: -h, --help show this help message and exit --catalog, --warehouse CATALOG the catalog's warehouse_id (UUID) or its name; list both with: tablemere catalog list ``` ### tablemere catalog grant ``` usage: tablemere catalog grant [-h] --catalog CATALOG --principal PRINCIPAL --level {read,write} options: -h, --help show this help message and exit --catalog, --warehouse CATALOG the catalog's warehouse_id (UUID) or its name; list both with: tablemere catalog list --principal PRINCIPAL principal_id of the member or agent (tablemere org members / agent list) --level {read,write} ``` ### tablemere catalog revoke ``` usage: tablemere catalog revoke [-h] --catalog CATALOG --principal PRINCIPAL options: -h, --help show this help message and exit --catalog, --warehouse CATALOG the catalog's warehouse_id (UUID) or its name; list both with: tablemere catalog list --principal PRINCIPAL ``` ### tablemere catalog delete ``` usage: tablemere catalog delete [-h] --catalog CATALOG --confirm NAME options: -h, --help show this help message and exit --catalog, --warehouse CATALOG the catalog's warehouse_id (UUID) or its name; list both with: tablemere catalog list --confirm NAME the catalog's name, exactly; this cannot be undone ``` ### tablemere catalog publish ``` usage: tablemere catalog publish [-h] --catalog CATALOG --confirm NAME Puts one plain S3 bucket policy on the catalog's bucket (anyone may GET and LIST its objects, unsigned; writes and deletes stay refused). Public means the whole catalog: every table, every data file, every retained metadata.json including the table's history, and the list of object names. The Iceberg REST endpoint is NOT opened: readers use the metadata file's URL (tablemere catalog public-url --table .) with DuckDB's iceberg_scan and no credentials. Undo with: tablemere catalog unpublish. options: -h, --help show this help message and exit --catalog, --warehouse CATALOG the catalog's warehouse_id (UUID) or its name; list both with: tablemere catalog list --confirm NAME the catalog's name, exactly ``` ### tablemere catalog unpublish ``` usage: tablemere catalog unpublish [-h] --catalog CATALOG options: -h, --help show this help message and exit --catalog, --warehouse CATALOG the catalog's warehouse_id (UUID) or its name; list both with: tablemere catalog list ``` ### tablemere catalog public-url ``` usage: tablemere catalog public-url [-h] --catalog CATALOG [--table TABLE] options: -h, --help show this help message and exit --catalog, --warehouse CATALOG the catalog's warehouse_id (UUID) or its name; list both with: tablemere catalog list --table TABLE .: also print its current metadata_url and version_hint_url ``` ### tablemere namespace ``` usage: tablemere namespace [-h] {list} ... positional arguments: {list} options: -h, --help show this help message and exit ``` ### tablemere namespace list ``` usage: tablemere namespace list [-h] --catalog CATALOG options: -h, --help show this help message and exit --catalog, --warehouse CATALOG the catalog's warehouse_id (UUID) or its name; list both with: tablemere catalog list ``` ### tablemere table ``` usage: tablemere table [-h] {list,get,create} ... positional arguments: {list,get,create} list every table in a catalog, or in one namespace get schema, real format-version, snapshot and row count options: -h, --help show this help message and exit ``` ### tablemere table list ``` usage: tablemere table list [-h] --catalog CATALOG [--namespace NAMESPACE] options: -h, --help show this help message and exit --catalog, --warehouse CATALOG the catalog's warehouse_id (UUID) or its name; list both with: tablemere catalog list --namespace NAMESPACE restrict to one namespace ``` ### tablemere table get ``` usage: tablemere table get [-h] --catalog CATALOG --namespace NAMESPACE --name NAME options: -h, --help show this help message and exit --catalog, --warehouse CATALOG the catalog's warehouse_id (UUID) or its name; list both with: tablemere catalog list --namespace NAMESPACE --name NAME ``` ### tablemere table create ``` usage: tablemere table create [-h] --catalog CATALOG --namespace NAMESPACE --name NAME [--column COLUMN] [--schema-file SCHEMA_FILE] [--format-version {2,3}] [--idempotency-key IDEMPOTENCY_KEY] options: -h, --help show this help message and exit --catalog, --warehouse CATALOG the catalog's warehouse_id (UUID) or its name; list both with: tablemere catalog list --namespace NAMESPACE --name NAME --column COLUMN name:type[:required][:identifier], repeatable --schema-file SCHEMA_FILE a full Iceberg schema as JSON, e.g. {"type":"struct","schema-id":0,"identifier-field-ids": [1],"fields":[{"id":1,"name":"id","required":true,"typ e":"long"}]} --format-version {2,3} 2 (default, writable by DuckDB and PyIceberg) or 3 (only Spark 3.5 + Iceberg 1.11 can write it) --idempotency-key IDEMPOTENCY_KEY any string; safe to reuse when retrying the same call ``` ### tablemere credentials ``` usage: tablemere credentials [-h] --catalog CATALOG --namespace NAMESPACE --table TABLE options: -h, --help show this help message and exit --catalog, --warehouse CATALOG the catalog's warehouse_id (UUID) or its name; list both with: tablemere catalog list --namespace NAMESPACE --table TABLE ``` ### tablemere connect ``` usage: tablemere connect [-h] --catalog CATALOG [--engine {duckdb,pyiceberg,spark,snowflake}] options: -h, --help show this help message and exit --catalog, --warehouse CATALOG the catalog's warehouse_id (UUID) or its name; list both with: tablemere catalog list --engine {duckdb,pyiceberg,spark,snowflake} ``` ### tablemere mcp ``` usage: tablemere mcp [-h] {install,run} ... positional arguments: {install,run} install write the mcpServers entry into a host's configuration; the key comes from --api-key, TABLEMERE_API_KEY or the credentials file and is never printed run start the MCP server over stdio with the saved key in its environment (for a host configured by hand: command tablemere, args mcp run) options: -h, --help show this help message and exit ``` ### tablemere mcp install ``` usage: tablemere mcp install [-h] [--client {claude-code,claude-desktop}] [--url URL] [--from SOURCE] [--name NAME] [--scope {local,user,project}] [--print] Writes {"tablemere": {"command": "uvx", "args": ["--from", SOURCE, "tablemere- mcp"], "env": {TABLEMERE_URL, TABLEMERE_API_KEY}}} into the host's mcpServers, with the key read from the credentials file (or --api-key / TABLEMERE_API_KEY). Claude Code: `claude mcp add-json` when `claude` is on PATH, else the entry is printed with the key masked and the command to run. Claude Desktop: its claude_desktop_config.json is merged into (other servers kept), after a .bak- copy; the file is written 0600. The saved key is a person's (tablemere signup --save / login --save), which today is what the account tools need; pass --api-key with a token's key to confine the server to its grants instead (docs/mcp.md). options: -h, --help show this help message and exit --client {claude-code,claude-desktop} which host's configuration to write (default: claude- code) --url URL API base URL the server will talk to and whose saved key is used (default: the global --url / TABLEMERE_URL, i.e. https://api.tablemere.eu) --from SOURCE what uvx installs the server from: the platform/mcp directory of a checkout, or the package name tablemere-mcp (once published); default: the checkout this CLI runs from, else tablemere-mcp --name NAME the server's name in the host (default: tablemere) --scope {local,user,project} Claude Code only: user (default; every project on this machine), local (this project, this machine), or project (.mcp.json, shared through version control: the key would be too) --print print the mcpServers snippet with the key MASKED instead of writing anything (for another host's configuration; put the real key in from the credentials file) ``` ### tablemere mcp run ``` usage: tablemere mcp run [-h] [--url URL] [--from SOURCE] ... positional arguments: -- SERVER_ARGS passed to the server, e.g. -- --list-tools options: -h, --help show this help message and exit --url URL API base URL (default: the global --url / TABLEMERE_URL, i.e. https://api.tablemere.eu) --from SOURCE what uvx installs the server from: the platform/mcp directory of a checkout, or the package name tablemere-mcp (once published); default: the checkout this CLI runs from, else tablemere-mcp ``` # API reference Base URL https://api.tablemere.eu. JSON in, JSON out. Every failure is {"error": {"code", "message", …}} with, where it helps, remedy, field, limit, current, available, retry_after_seconds or attempts_left, and always a request_id to quote when writing to us; unexpected failures are 500 with the same id. Request bodies are limited to 1 MB. ## Authentication Send Authorization: Bearer al_live_… with the API key itself, or a token from POST /v1/tokens (RS256, 900 s), or a token from an identity provider the deployment trusts. There are three kinds of principal: humans (created by signup), agents (created by an organisation admin, optionally confined to warehouses) and the operator. Keys look like al_live__; only the hash is stored; ten wrong secrets for one key id from one address within ten minutes answer 429 with Retry-After. Words: a catalog in the account page, the CLI and these docs is a warehouse in the API (/v1/warehouses, warehouse_id, my_level); the API keeps that name until a versioned rename adds /v1/catalogs aliases. The Iceberg catalog at https://catalog.tablemere.eu and the object store at https://s3.tablemere.eu are separate surfaces with their own credential, the catalog's catalog_credential from GET /v1/connection. They never accept Tablemere API keys. ## Idempotency POST /v1/projects, /v1/warehouses and /v1/tables take an Idempotency-Key header (any string). A replay returns the original response; the same key on a different endpoint is 409 idempotency_key_reused. ## Identifiers Wherever a project_id or warehouse_id is expected, the name you chose is accepted too; an unknown value is a 404 that names both accepted forms and the command that lists them. Two warehouses with one name in different projects make the name ambiguous (409 ambiguous_name with candidates). ## Health and identity ### GET /.well-known/openid-configuration → 200 OpenID metadata for the tokens this API issues (client_credentials, RS256). Auth: none Response: issuer, jwks_uri, token_endpoint, grant_types_supported ### GET /jwks.json → 200 The RS256 public key that verifies tokens from POST /v1/tokens. Auth: none Response: keys[{kty, use, alg, kid, n, e}] ### POST /v1/tokens → 200 Exchange an API key for a short-lived bearer token for THIS API. Optional: every authenticated route also accepts the API key itself as the bearer. The Iceberg catalog never accepts either; it takes the warehouse's catalog_credential. Auth: none Request: {"api_key": "al_live_…"} Response: access_token (JWT), token_type: Bearer, expires_in: 900, principal_id Errors: 401 invalid_api_key, 401 api_key_revoked, 403 principal_suspended, 429 rate_limited after 10 wrong secrets for one key id within 600 s (Retry-After header) ### GET /healthz → 200 Liveness: the state database answers and the catalog answers /v1/config. Auth: none Response: ok, catalog_ok, issuer, kid ## Signup and recovery ### POST /v1/signup/start → 202 Step 1 of 2 of the email-code signup: a six-digit code is mailed to the address; nothing is created yet. The signup FORM sends full_name, organization_name and handle too: the organisation is created pending (its handle reserved) and verify activates it. An address on a claimed, open domain joins that organisation instead (will_join). Auth: none Request: {"email": "you@example.com", "workspace"?: "acme", "full_name"?: "Ana Lopez", "organization_name"?: "Acme Labs", "handle"?: "acme-labs"} Response: email, workspace, delivery (email|log), organization{org_id, name, handle, status: pending} (form), will_join{org_id, name, handle} (claimed domain), terms{version, url, privacy_url, note}, next, note, expires_in (600 s), code_length (6) Errors: 400 invalid_value (email; handle: reason invalid|reserved, suggestion), 409 handle_taken (suggestion), 409 already_exists (the address has an account; recover the key instead), 429 rate_limited (5 starts per hour per address; or a code less than 60 s old, with retry_after_seconds), 502 mail_error CLI: tablemere signup --email … ### POST /v1/signup/verify → 201 Step 2 of 2: the right code creates the account, its API key, an organisation (owned by you unless an invitation or an open verified domain places you elsewhere), a project named after the workspace, a warehouse named `lake`, and returns the connection recipe. Auth: none Request: {"email": …, "code": "123456", "accept_terms": true, "engine": "duckdb"} (engine: duckdb|pyiceberg|spark, default duckdb) Response: api_key (shown once), api_key_note, principal_id, project_id, warehouse_id, organization{org_id, role, joined_via}, connection{as GET /v1/connection}, next[], terms{version, accepted, url} Errors: 404 no_pending_signup, 410 code_expired, 401 invalid_code (attempts_left; five attempts, then a new code is needed), 400 terms_required (accept_terms must be exactly true), 409 already_exists CLI: tablemere signup --email … --code … --accept-terms --save ### POST /v1/signup → 201 The one-call, UNVERIFIED signup for local development stacks. The managed service answers 403 verification_required: use /v1/signup/start + /verify. Auth: none Request: {"email", "workspace", "engine", "accept_terms"} Response: as /v1/signup/verify Errors: 403 verification_required on the managed service ### GET /v1/handles/{handle} → 200 Is this organisation handle free? For the signup form as you type (per-address rate limit). Handles: 2-40 lowercase letters, digits and dashes, starting and ending with a letter or digit; app, api, docs, legal, admin, tablemere, www, s3, catalog and auth are reserved. Auth: none Response: handle, valid, available, reason (invalid|reserved|taken|null), suggestion (a free handle near it), pattern Errors: 429 rate_limited ### POST /v1/recover/start → 202 Lost API key: a code to the account's email. The answer is identical whether or not the address has an account, so accounts cannot be enumerated here. Auth: none Request: {"email": …} Response: email, delivery (email when a code was mailed; smtp when the address has no account and nothing was sent; log on a development stack), expires_in (600 s), next Errors: 429 rate_limited (same 5/hour/address budget as signup; retry_after_seconds when a code is under 60 s old) CLI: tablemere recover --email … ### POST /v1/recover/verify → 200 The right code mints a NEW API key. Earlier keys keep working until revoked. Auth: none Request: {"email": …, "code": "123456"} Response: api_key (shown once), key_id, principal_id, note Errors: 404 no_pending_recovery, 410 code_expired, 401 invalid_code (attempts_left) CLI: tablemere recover --email … --code … --save ## Login and sessions ### POST /v1/auth/signup → 202 The signup form: a pending account and a pending organisation (handle reserved), and a set-password mail with a link to /app/welcome?token=… valid 3 days. Nothing is usable until the link sets a password. The code path for agents and the CLI stays at POST /v1/signup/start. Auth: none Request: {"email": …, "full_name": …, "organization_name": …, "handle": …, "return_to": "/app/…"} (return_to optional: a path under /app/ on this site the welcome page continues to after the password is set, e.g. the device consent screen) Response: 202: email, delivery (email | log), expires_in (259200), organization{org_id, name, handle, status: pending} or will_join{org_id, name, handle}, terms, next, return_to Errors: 400 invalid_value (handle grammar or a reserved word; suggestion carries a free one), 409 handle_taken, 409 already_exists (the address has finished signing up: log in), 429 rate_limited (5 per hour per address; one re-send per minute), 502 mail_error ### POST /v1/auth/password/set → 200 The set-password link: verifies the address, stores the argon2id hash, activates the pending organisation with the person as admin (or joins the open-domain one), records the Terms version the form showed, and returns a session. Auth: none Request: {"token": …, "password": …} (12 to 256 characters, not containing the address or the organisation name) Response: access_token (RS256, 15 min, claims sub/email/kind/sid), token_type, expires_in, refresh_token (30 days sliding), refresh_expires_in, session_id, principal_id, principal, organization{…, role, joined_via}, terms, next Errors: 400 password_too_short | password_too_long | password_contains_identity (the link is not spent), 401 invalid_login_token (unknown or already used), 410 token_expired ### POST /v1/auth/login → 200 Email and password to a session. The same 401 for an unknown address, a wrong password and an account without a password (a constant-time compare against a dummy hash in every case). Auth: none Request: {"email": …, "password": …} Response: the session shape of POST /v1/auth/password/set Errors: 401 invalid_login, 403 principal_suspended, 429 rate_limited (10 failures per address per hour, retry_after_seconds; 120 attempts per caller address per hour) ### POST /v1/auth/refresh → 200 A new access token and a rotated refresh token; the session's expiry slides 30 days. Presenting the refresh token that was just rotated out revokes the whole session (theft signal). Auth: none Request: {"refresh_token": …} Response: the session shape, with the new refresh_token Errors: 401 invalid_refresh_token | refresh_reused | session_revoked | session_expired ### POST /v1/auth/logout → 200 Revokes the session, by its refresh token or by the access token in Authorization (sid claim). The access token itself expires within 15 minutes. Auth: none Request: {"refresh_token": …} or Authorization: Bearer Response: revoked, session_id ### GET /v1/auth/sessions → 200 The caller's live sessions: where and when they logged in; `current` marks the one making the call. Auth: Authorization: Bearer Response: sessions[{session_id, created_at, last_used_at, expires_at, user_agent, ip, current}], count ### DELETE /v1/auth/sessions/{session_id} → 200 Revokes one of the caller's sessions. Auth: Authorization: Bearer Response: session_id, revoked: true Errors: 404 session_not_found (also for another account's session) ### POST /v1/auth/password/reset/start → 202 A 30-minute reset link to /app/reset?token=… by mail. 202 with the same body whether or not the address has an account. An account created through an identity provider that has no password yet gets the same link, which sets its first password. Auth: none Request: {"email": …} Response: email, delivery, expires_in (1800), next Errors: 429 rate_limited (5 per hour per caller address, shared with signup) ### POST /v1/auth/password/reset → 200 The reset link: a new password, every session revoked, a notice mailed; returns a fresh session. Auth: none Request: {"token": …, "password": …} Response: the session shape plus sessions_revoked, first_password Errors: 400 password_* (the link is not spent), 401 invalid_login_token, 410 token_expired ### POST /v1/auth/password/change → 200 A logged-in human changes their password; every OTHER session is revoked; a notice is mailed. Auth: Authorization: Bearer Request: {"current_password": …, "new_password": …} Response: changed: true, other_sessions_revoked, password_changed_at Errors: 401 invalid_login (wrong current password), 400 password_*, 409 no_password_set (identity-provider account: use the reset link), 403 forbidden (a token has no password) ### POST /v1/auth/device → 200 RFC 8628 device authorisation for the CLI, asking for a CONNECTION (docs/agent-setup.md): a device code to poll with and an 8-character user code (no ambiguous glyphs) for the human to approve at /app/device. client_name is the agent the approval creates (default the hostname), requested_level what it asks for (default write), warehouse_ids which warehouses (default the organisation's default warehouse). Auth: none Request: {"client": "cli", "hostname": …, "client_name": …, "requested_level": "read|write", "warehouse_ids": […]} (all optional) Response: device_code, user_code (XXXX-XXXX), verification_uri, verification_uri_complete, expires_in (900), interval (5), client_name, requested_level, warehouse_ids Errors: 429 rate_limited (60 per hour per caller address), 400 invalid_value CLI: tablemere login ### GET /v1/auth/device/pending → 200 The consent screen's content for a pending code (humans only; nothing is decided by reading it): the agent's name, the warehouse(s) it would be connected to, the requested level and the level it would hold, capped by the caller's own on each warehouse (capped: true when lowered). Auth: Authorization: Bearer Request: ?user_code=XXXX-XXXX Response: user_code, client, hostname, client_name, requested_level, level, organization, warehouses[{warehouse_id, name, level, my_level, my_via, capped, pending}], expires_in Errors: 404 device_code_not_found (mistyped, expired or already decided), 403 forbidden (a token), 409 org_required ### POST /v1/auth/device/approve → 200 A logged-in human connects the CLI as an AGENT of the organisation -- a token named after the machine, member role, holding exactly the approved grants, capped by the approver's own level -- or refuses it with deny: true. level lowers the requested level (never raises it); warehouse_ids picks the warehouses. An organisation without a warehouse gets "My catalog" first. Only humans; a token approving would hand out a key of the token. Auth: Authorization: Bearer Request: {"user_code": …, "level": "read|write", "warehouse_ids": […], "deny": false} Response: approved, user_code, client, hostname, client_name, label, requested_level, connection{name, level, org_id, warehouses[{warehouse_id, name, level}]}, note Errors: 404 device_code_not_found (mistyped, expired or already decided), 403 forbidden (a token; or a warehouse the approver holds nothing on), 404 warehouse_not_found, 409 org_required ### POST /v1/auth/device/token → 200 The CLI polls until the human decides. The RFC's error names travel as error.code in the usual envelope: authorization_pending (keep polling), slow_down (interval grows by 5 s), expired_token, access_denied. Once approved: the key of the NEW AGENT (never the person's), labelled "cli ", shown once, with the connection it holds. Revoking the agent revokes the connection. Auth: none Request: {"device_code": …} Response: api_key, key_id, label, principal_id (the agent's), kind: agent, token_type: api_key, connection{agent_id, name, org_id, role, warehouses[{warehouse_id, name, level}], created_by, grants}, note Errors: 400 authorization_pending | slow_down | expired_token | access_denied | invalid_grant CLI: tablemere login ## Projects and catalogs (warehouses in the API) ### POST /v1/projects → 201 A project is the placement boundary: provider and region are fixed at creation and never change. Today there is one placement: hetzner, the deployment's own region. Auth: Authorization: Bearer Request: {"name": …, "provider"?, "region"?, "org_id"?} Header Idempotency-Key: any string Response: project_id, name, provider, region, placement_immutable: true, org_id Errors: 409 already_exists (name in use by you; project_id in the body), 400 unsupported_placement (available[]), 400 org_required (you belong to several organisations), 404 org_not_found, 409 idempotency_key_reused CLI: tablemere project create --name … ### GET /v1/projects → 200 Projects you own or reach through an organisation you belong to. Auth: Authorization: Bearer Response: projects[{project_id, name, provider, region, org_id, created_at}] CLI: tablemere project list ### POST /v1/warehouses → 201 A warehouse is one Iceberg catalog: one table bucket (w-) plus one storage identity confined to it by the catalog. Also a blob bucket (b-) for non-Iceberg files. Auth: Authorization: Bearer Request: {"project_id": , "name": …} Header Idempotency-Key Response: warehouse_id, name, project_id, project, provider, region, catalog_uri, warehouse (s3://w-/), warehouse_name (w-), maintenance{compaction: true, target_file_size_mb: 128, snapshot_retention_days: 7, max_snapshots: 20, disableable: false} Errors: 409 already_exists, 409 limit_exceeded (warehouses per project; limit_value, current), 409 quota_exceeded (the organisation is over its storage limit; current, limit_value, scope), 404 project_not_found, 502 storage_error (safe to retry with the same idempotency key) CLI: tablemere catalog create --project … --name … ### POST /v1/warehouses/{warehouse_id}/rotate → 200 Replace both of the warehouse's catalog credentials (the read/write identity t- and the read-only t--ro). Each new key is issued before the old one is deleted, so there is never a moment without a working key. The old keys and every catalog token minted from them are refused at once; storage sessions already vended run out on their own within 900 s. Auth: Authorization: Bearer Response: warehouse_id, warehouse_name, catalog_credential{client_id, client_secret}, catalog_credential_ro{client_id, client_secret}, previous_client_id, previous_revoked, previous_client_id_ro, previous_revoked_ro, rotated_at, note, warning (only if an old key could not be deleted) Errors: 404 warehouse_not_found, 409 ambiguous_name (two warehouses share the name; candidates[]) CLI: tablemere catalog rotate --catalog … ### DELETE /v1/warehouses/{warehouse_id} → 200 Delete a warehouse for good, with its name typed in the body. Immediate and irreversible: tables dropped, both buckets deleted, both identities and their policies removed, grants and usage samples removed. Deleted now from the live system; the nightly backup copy ages out within its retention window (14 days). Auth: Authorization: Bearer — admin of the organisation Request: {"confirm": ""} Response: deleted: true, warehouse_id, name, project_id, tables_dropped, bytes_at_deletion, measured_at, grants_removed, usage_samples_removed, storage{step: ok|HTTP n}, storage_complete, note, warning (only when something is left for the operator) Errors: 400 confirmation_required (confirm_with names the expected value), 403 forbidden (not an admin), 404 warehouse_not_found, 409 delete_in_progress (Retry-After) CLI: tablemere catalog delete --catalog … --confirm ### POST /v1/warehouses/{warehouse_id}/public → 200 Make a warehouse PUBLIC at the storage level: admin only, the warehouse name typed in the body. One plain bucket policy lets anyone read every object (data files and all retained metadata) with no credentials; writes stay refused and the catalog stays private. Its bytes move to the 50 GB public tier; 409 quota_exceeded (tier public) when that would overflow.the read-only t--ro). Each new key is issued before the old one is deleted, so there is never a moment without a working key. The old keys and every catalog token minted from them are refused at once; storage sessions already vended run out on their own within 900 s. Auth: Authorization: Bearer Response: warehouse_id, warehouse_name, catalog_credential{client_id, client_secret}, catalog_credential_ro{client_id, client_secret}, previous_client_id, previous_revoked, previous_client_id_ro, previous_revoked_ro, rotated_at, note, warning (only if an old key could not be deleted) Errors: 404 warehouse_not_found, 409 ambiguous_name (two warehouses share the name; candidates[]) CLI: tablemere catalog publish --catalog … --confirm ### DELETE /v1/warehouses/{warehouse_id}/public → 200 Make a public warehouse private again: admin only. The bucket policy is removed and anonymous reads are refused at once; bytes return to the private tier.the read-only t--ro). Each new key is issued before the old one is deleted, so there is never a moment without a working key. The old keys and every catalog token minted from them are refused at once; storage sessions already vended run out on their own within 900 s. Auth: Authorization: Bearer Response: warehouse_id, warehouse_name, catalog_credential{client_id, client_secret}, catalog_credential_ro{client_id, client_secret}, previous_client_id, previous_revoked, previous_client_id_ro, previous_revoked_ro, rotated_at, note, warning (only if an old key could not be deleted) Errors: 404 warehouse_not_found, 409 ambiguous_name (two warehouses share the name; candidates[]) CLI: tablemere catalog unpublish --catalog … ### GET /v1/warehouses/{warehouse_id}/grants → 200 Who holds what on the warehouse: explicit grants and the implicit ones (admins write via admin, members read via membership, the creator write via creator, a legacy unconfined agent write via agent). A level of none is a membership read an admin revoked for this warehouse. Auth: Authorization: Bearer — any member of the organisation Response: [{principal_id, kind, display, level (read|write|none), via, granted_by, granted_at}] Errors: 404 warehouse_not_found CLI: tablemere catalog grants --catalog … ### PUT /v1/warehouses/{warehouse_id}/grants/{principal_id} → 200 Grant read or write on a warehouse to a member or an agent of the organisation. A read grant yields read-only catalog and storage credentials (t--ro); a write grant the read/write ones. Auth: Authorization: Bearer — admin, or a human holding write (granting at most their own level) Request: {"level": "read"|"write"} Response: warehouse_id, principal_id, kind, display, level, via, granted_by Errors: 400 invalid_value, 403 forbidden (level, can_grant[]), 404 principal_not_found (not in this organisation) CLI: tablemere catalog grant --catalog … --principal … --level read|write ### DELETE /v1/warehouses/{warehouse_id}/grants/{principal_id} → 200 Revoke a grant. Revoking a member's implicit membership read records level none for this warehouse; an admin's implicit write cannot be revoked (change their role). Auth: Authorization: Bearer — admin, or the person who made the grant Response: warehouse_id, principal_id, kind, display, revoked{level, via}, level, via Errors: 403 forbidden, 404 grant_not_found, 409 implicit_grant CLI: tablemere catalog revoke --catalog … --principal … ### GET /v1/warehouses → 200 The warehouses of one project, ids and names side by side. Auth: Authorization: Bearer Request: ?project= Response: project, project_id, catalog_uri, warehouses[{name, warehouse_id, warehouse, created_at, my_level (read|write|none), my_via}], limit, headroom CLI: tablemere catalog list --project … ## Tables, credentials and connection recipes ### POST /v1/tables → 201 Create an Iceberg table through the catalog, as the warehouse's own identity. The namespace is created on demand. Engines can also create tables directly (DuckDB CREATE TABLE, PyIceberg create_table); this route exists for a schema with field ids, identifier fields and a chosen format version. Auth: Authorization: Bearer Request: {"warehouse_id": , "namespace": …, "name": …, "schema": , "format_version": 2} (2 default; 3 is writable only by Spark 3.5 + Iceberg 1.11) Header Idempotency-Key Response: table (ns.name), warehouse_id, warehouse, namespace, name, table_uuid, location, format_version, format_version_requested, format_version_honoured; for v3 also warnings[], writable_by[], not_writable_by[] Errors: 400 missing_field, 400 invalid_value (format_version), 404 warehouse_not_found, 409 quota_exceeded (over the storage limit), 4xx/502 catalog_error (catalog_status, catalog_type, catalog_message) CLI: tablemere table create --catalog … --namespace … --name … --column name:type … ### GET /v1/namespaces → 200 Namespaces in a warehouse, read from the catalog. Auth: Authorization: Bearer Request: ?warehouse= Response: warehouse, warehouse_id, namespaces[{namespace}] CLI: tablemere namespace list --catalog … ### GET /v1/tables → 200 Every table in a warehouse, or in one namespace. Auth: Authorization: Bearer Request: ?warehouse=&namespace= Response: warehouse, warehouse_id, namespaces_searched[], tables[{namespace, name, table}] Errors: 404 namespace_not_found (available[]) CLI: tablemere table list --catalog … [--namespace …] ### GET /v1/table → 200 Schema, the real format version and counts, read from the table's current metadata. Rows are counted from the snapshot summary when the writer left one, else from the manifest list (rows_source says which). Auth: Authorization: Bearer Request: ?warehouse=&namespace=…&name=… Response: table, namespace, name, warehouse, warehouse_id, table_uuid, location, format_version, schema[{id, name, type, required}], identifier_field_ids[], partition_spec, snapshots, current_snapshot_id, rows, data_files, delete_files, rows_source; warnings[] for v3 Errors: 404 namespace_not_found, 404 table_not_found (available[]) CLI: tablemere table get --catalog … --namespace … --name … ### POST /v1/credentials → 200 Short-lived storage credentials scoped to ONE table's object prefix, vended by the catalog to the warehouse's read/write identity. For direct S3 access from a script; engines that load tables through the catalog obtain these themselves. Write holders only: a vended session is read/write whoever asks for it (measured, docs/security.md), so a read holder is refused and pointed at the static read-only credential in GET /v1/connection. Auth: Authorization: Bearer Request: {"warehouse_id": , "namespace": …, "name": …} Response: table, location, scope, level (write), identity (t-), storage{s3.access-key-id, s3.secret-access-key, s3.session-token, s3.endpoint, s3.session-token-expires-at-ms and the other s3.* keys the catalog vends}, expires_at, notes[] (only when the endpoint had to be rewritten for your network) Errors: 403 forbidden (level read or none; remedy names the grant command or /v1/connection), 404 namespace_not_found, 404 table_not_found, 409 quota_exceeded (over the storage limit: no new write credential; reading through the catalog credential still works), 502 catalog_error CLI: tablemere credentials --catalog … --namespace … --table … ### GET /v1/connection → 200 A paste-ready recipe for your engine, with the catalog credential of the identity matching your level inside: write holders get t- (reads and writes), read holders get t--ro (the catalog refuses its commits, the store its PUT/DELETE). The snowflake recipe is always the read-only identity (static keys, ALLOW_WRITES = FALSE). Tablemere is never in the query path: the engine talks to the catalog and the object store directly. Auth: Authorization: Bearer Request: ?warehouse_id=&engine=duckdb (duckdb default, pyiceberg, spark, snowflake) Response: engine, catalog_uri, warehouse (s3://w-/), warehouse_name (w-), level (read|write), level_via, identity (t- | t--ro), catalog_credential{client_id, client_secret, oauth2_server_uri, grant: client_credentials, note}, storage_endpoint, blob_bucket (b-), blob_note, blob_example_python, token_endpoint, token_exchange{…}, warehouse_id, project_id, notes[] (the first says the level), and per engine: sql (duckdb, snowflake) | python (pyiceberg) | conf{spark.sql.catalog.lake.*} (spark) Errors: 400 unsupported_engine (available[]), 403 forbidden (no grant on the warehouse; can_grant[] names who can), 404 warehouse_not_found CLI: tablemere connect --catalog … --engine duckdb|pyiceberg|spark|snowflake ## Organisations ### GET /v1/orgs → 200 The organisations you belong to and your role in each; for a token, the organisation that owns it and the token's role. Auth: Authorization: Bearer Response: organizations[{org_id, name, handle, status, role (admin|member), domain_join, domains[{domain, verified_via}], members, created_at}] CLI: tablemere org list ### PATCH /v1/orgs/{org_id} → 200 Rename the organisation, or let people whose verified email is on a claimed domain join as members. Auth: Authorization: Bearer — admin of the organisation Request: {"name"?, "domain_join"?: true|false} Response: the organisation as in GET /v1/orgs Errors: 403 insufficient_role, 400 no_domain (claim a domain first) CLI: tablemere org set [--name …] [--domain-join on|off] ### POST /v1/orgs/{org_id}/domains → 201 Claim an email domain. The proof is your own verified address on that domain; public mail domains (gmail.com, outlook.com, …) are never claimable; a domain belongs to one organisation. Auth: Authorization: Bearer — admin Request: {"domain": "yourcompany.com"} Response: the organisation Errors: 400 public_mail_domain, 403 domain_not_yours, 409 domain_claimed CLI: tablemere org domain --domain … ### DELETE /v1/orgs/{org_id}/domains/{domain} → 200 Release a claimed domain. Members who joined through it stay; nobody new joins through it, and domain join switches itself off when no domain is left. Auth: Authorization: Bearer — admin Response: the organisation, plus removed: Errors: 404 domain_not_found ### GET /v1/orgs/{org_id}/members → 200 Who is in the organisation. Auth: Authorization: Bearer — any member Response: org_id, members[{principal_id, role, created_at, display_name, email, kind}] CLI: tablemere org members ### PATCH /v1/orgs/{org_id}/members/{member_id} → 200 Change a member's role. Two roles, admin and member ("owner" is accepted as an alias for admin for one version and never returned). An organisation always keeps at least one admin. Auth: Authorization: Bearer — admin Request: {"role": "admin"|"member"} Response: org_id, principal_id, role Errors: 404 member_not_found, 409 last_admin CLI: tablemere org role --principal … --role admin|member ### DELETE /v1/orgs/{org_id}/members/{member_id} → 200 Remove a member (admin), or leave the organisation (your own principal_id). The last admin can do neither. Auth: Authorization: Bearer — admin, or any member removing themselves Response: org_id, principal_id, removed: true, left Errors: 404 member_not_found, 403 insufficient_role, 409 last_admin ### POST /v1/orgs/{org_id}/invitations → 201 Invite an address. The token is returned once (and mailed when the deployment has a mail sender). If the address signs up later, the invitation is applied automatically. Auth: Authorization: Bearer — admin Request: {"email": …, "role": "member"|"admin"} Response: org_id, email, role, token (shown once), mailed, expires_in (7 days), accept_with, note CLI: tablemere org invite --email … --role member|admin ### POST /v1/orgs/invitations/accept → 200 Redeem an invitation. The caller's email must be the invited one. Auth: Authorization: Bearer Request: {"token": …} Response: the organisation Errors: 404 invitation_not_found, 410 invitation_expired, 403 invitation_for_someone_else CLI: tablemere org accept --token … ## Agents ### POST /v1/orgs/{org_id}/agents → 201 A token (shown as "agent"): a machine identity of the organisation with its own API key, revocable on its own, holding exactly the grants given here and an organisation role (admin or member, default member) the organisation routes evaluate exactly as a human member's -- so an agent can do everything a human can. An empty or omitted grants list means no warehouse until granted (a warehouse a member token was not granted is 404 for it; an admin token sees every warehouse with its level, and may grant itself). Admins give any grants and either role; members at most their own level on each warehouse and the member role only. Role governs the organisation, grants govern data. Tokens never count for last-admin protection. The pre-contract `warehouses`/`warehouse_ids` list is read as write grants for one more version and answered with a `deprecation` note. Auth: Authorization: Bearer — admin, or a member within their own grants and role (a token is the member or admin it was made) Request: {"name": …, "role"?: "admin"|"member", "grants"?: [{"warehouse_id": …, "level": "read"|"write"}, …]} Response: agent_id, name, org_id, role, grants[{warehouse_id, name, level}], created_by, created_by_display, created_at, last_used_at, suspended, warehouses (legacy), api_key (shown once), key_id, note Errors: 400 invalid_value (grants, role), 403 forbidden (a level above your own: your_level, requested), 403 insufficient_role (an admin token from a member), 404 warehouse_not_found (not in this organisation), 409 already_exists (name) CLI: tablemere agent create --name … [--role admin|member] [--grant :read|write …] ### GET /v1/orgs/{org_id}/agents → 200 The organisation's tokens with their role and grants, who created each and when its key was last used. Auth: Authorization: Bearer — any member Response: org_id, agents[{agent_id, name, role, grants[{warehouse_id, name, level}], created_by, created_by_display, created_at, last_used_at, suspended, warehouses (legacy), note (legacy unconfined agents)}] CLI: tablemere agent list ### PATCH /v1/orgs/{org_id}/agents/{agent_id} → 200 Edit a token's name, grants and/or role; the grants list replaces the current one and is capped by the editor's own grants, the role by the editor's own role (an admin is uncapped; a token never edits itself unless it is an admin). Editing a legacy unconfined agent confines it. Audited with the previous role. Auth: Authorization: Bearer — admin, or the member who created it Request: {"name"?: …, "role"?: "admin"|"member", "grants"?: [{"warehouse_id": …, "level": …}]} Response: the token as in GET Errors: 400 missing_field, 403 forbidden, 403 insufficient_role, 404 agent_not_found, 409 already_exists CLI: tablemere agent update --agent-id … [--name …] [--role admin|member] [--grant :read|write …] ### DELETE /v1/orgs/{org_id}/agents/{agent_id} → 200 Every key of the agent is revoked and the agent suspended: it stops at once. Auth: Authorization: Bearer — admin, or the member who created it Response: agent_id, revoked: true Errors: 404 agent_not_found CLI: tablemere agent revoke --agent-id … ## API keys, terms and account ### GET /v1/auth/config → 200 What a browser or a CLI needs to log a human in: whether the deployment serves the email + password login (password_login; the form at /app/, docs/login.md) and the external OpenID Connect issuers a self-hosted deployment trusts (AL_OIDC_ISSUERS; device-code endpoints). The account page shows the form, the "Log in with " button, or both, from this answer. Auth: none Response: issuer, password_login (true when the deployment serves POST /v1/auth/login), external_issuers[{issuer, client_id, authorization_endpoint, token_endpoint, device_authorization_endpoint, jwks_uri}], api_key_exchange ### POST /v1/api-keys → 201 Another API key for the caller (for a second machine, a CI job). Shown once. Auth: Authorization: Bearer Request: {"label": "ci"} Response: api_key, key_id, label, principal_id, note CLI: tablemere api-key create --label … ### GET /v1/api-keys → 200 The caller's keys, by id: never the secret. Auth: Authorization: Bearer Response: api_keys[{key_id, label, created_at, last_used_at, revoked_at}] CLI: tablemere api-key list ### DELETE /v1/api-keys/{key_id} → 200 Revoke one of your keys. The key id is the part between al_live_ and the second underscore. Auth: Authorization: Bearer Response: key_id, revoked: true Errors: 404 api_key_not_found CLI: tablemere api-key revoke --key-id … ### POST /v1/onboard → 200 For a caller who arrived with a key or an identity-provider token: make sure an organisation, a project and a warehouse named `lake` exist and hand back the recipe. First login without a signup: organization_name + handle name the organisation (a handle is derived from the name when absent); an address on a claimed, open domain joins that organisation instead and the response says so. Idempotent: an existing organisation and warehouse are returned, not duplicated. Auth: Authorization: Bearer Request: {"engine"?: "duckdb", "accept_terms"?: true, "organization_name"?, "handle"?, "org_id"?} Response: created (bool), warehouse_id, connection, project_id (when created), organization{org_id, name, handle, status, role, joined_via (created|domain|invitation|signup-form|member)}, terms{version, accepted, url}, note Errors: 400 terms_required (a human who has not accepted the current Terms), 400 invalid_value (handle), 409 handle_taken (suggestion) ### GET /v1/terms → 200 The Terms version in force and whether you accepted it. Auth: Authorization: Bearer Response: current_version, terms_url, privacy_url, accepted_current, history[{version, accepted_at, via}] CLI: tablemere terms ### POST /v1/terms/accept → 200 Record acceptance of the current Terms version. Auth: Authorization: Bearer Request: {"version"?: } Response: accepted: true, version Errors: 409 terms_version_mismatch CLI: tablemere terms --accept ### DELETE /v1/me → 200 Delete your account. Immediate: every API key revoked, identities unlinked, memberships removed, agents of organisations left empty stopped. Data of organisations left without members is erased within 30 days and leaves backups within 14 more (Terms). Auth: Authorization: Bearer Request: {"confirm": ""} Response: deleted: true, principal_id, keys_revoked, organisations_left_empty[], warehouses_to_purge[], note Errors: 400 confirmation_required, 409 last_admin (make someone else an admin first) CLI: tablemere account delete --confirm ## Usage and limits ### GET /v1/usage → 200 Every limit, the current value and the headroom. Storage is metered hourly by maintenance (the latest sample of every warehouse in the scope, summed; 'unmetered' until the first sample); the storage limit applies to the organisation, or to the project when it has none. Over the limit, warehouse and table creation and credential vending answer 409 quota_exceeded and the buckets go read-only at the next enforcement pass. Read this before provisioning; an agent should never meet a limit by failing. Per warehouse it also shows usage that is NOT billed today: the latest footprint split by bucket, one row per UTC day for the last `days` days (the day's last footprint, and the S3 requests and bytes in/out counted that day from SeaweedFS's per-bucket counters), the footprint trend over the window and the window's traffic totals. `traffic` is null with a `traffic_note` until two consecutive hourly samples carry counters; no object keys and no query contents are collected. Auth: Authorization: Bearer Request: ?project_id=&days=<1..30, default 7: the per-catalog history length> Response: principal_id, limits{storage_bytes, objects, tables, namespaces, warehouses, catalog_operations_per_month, commits_per_month, min_seconds_between_commits, snapshot_retention_days, max_snapshots_per_table}, history_days, projects[{project_id, name, org_id, warehouses{current, limit, headroom}, warehouse_ids[], storage_bytes{current, limit, headroom, unmetered, measured_at, complete, scope, scope_id, warehouses_metered, warehouses, over_limit}, public_storage_bytes{…}, warehouse_storage[{warehouse_id, name, tier, public, bytes, objects, measured_at, unmetered, buckets{table, blob}, table_bytes, blob_bytes, blob_objects, complete, history[{day, bytes, objects, blob_bytes, blob_objects, complete, measured_at, samples, requests, requests_failed, bytes_in, bytes_out, intervals}], trend{days, from, to, bytes_from, bytes_to, bytes_delta, objects_delta, days_measured} | null, traffic{days, requests, requests_failed, bytes_in, bytes_out, intervals, resets, since, to, source} | null, traffic_note}]}], organizations[{org_id, storage_bytes{…}, public_storage_bytes{…}, role (the caller's: a member's or a token's)}], note CLI: tablemere usage [--catalog ] [--days N] (also: tablemere whoami) ### GET /v1/audit → 200 Who did what. Without org_id: your own events. With org_id: an admin sees the organisation's events plus their own; a member only their own within it. Actions include signup.start/verify/complete, recovery.start/verify, api_key.create/revoke, project.create, warehouse.create/rotate, table.create, credentials.vend, org.create/update/domain_claim/invite/invitation_accept/member_join/member_role/member_remove, org.reserve/activate, agent.create/update/revoke, warehouse.grant/grant_revoke/delete, terms.accept, account.delete, quota.reject. Auth: Authorization: Bearer Request: ?org_id=&since=<24h|7d|30m|ISO-8601>&action=&limit=<1..1000, default 100> Response: events[{id, ts, principal_id, org_id, action, target_type, target_id, ip, request_id, detail{}}], count, limit, scope (own|organization), org_id, since, action, retention_days (400) Errors: 404 org_not_found CLI: tablemere audit [--org …] [--since 24h|7d|30m|] [--action credentials.vend|org.*] [--limit N] ## Internal (not public) These exist in the control plane for the operator and for development stacks. They require the deployment's bootstrap secret and the public gateway answers 404 for every /internal/* path. Listed so the surface is complete. ### POST /internal/principals → 200 Operator: create a principal and its first key. Auth: the deployment's bootstrap secret (X-Bootstrap-Secret). Not public: api.tablemere.eu answers 404 for every /internal/* path ### POST /internal/principals/{principal_id}/suspend → 200 Operator: suspend or unsuspend a principal (the enforcement point). Auth: the deployment's bootstrap secret (X-Bootstrap-Secret). Not public: api.tablemere.eu answers 404 for every /internal/* path ### POST /internal/api-keys/{key_id}/revoke → 200 Operator: revoke any key. Auth: the deployment's bootstrap secret (X-Bootstrap-Secret). Not public: api.tablemere.eu answers 404 for every /internal/* path ### POST /internal/selftest/error → 200 Operator tool: raise one unhandled error on purpose to verify the error path end to end. Auth: the deployment's bootstrap secret (X-Bootstrap-Secret). Not public: api.tablemere.eu answers 404 for every /internal/* path ### GET /internal/signup-codes/{email} → 200 Development stacks without a mail server: read a pending code. Auth: the deployment's bootstrap secret (X-Bootstrap-Secret). Not public: api.tablemere.eu answers 404 for every /internal/* path ### GET /internal/tls-ask → 200 Caddy's on-demand TLS gate for virtual-hosted S3 names (.s3., the shape Snowflake's S3-compatible volumes need): 200 only for the bucket of an existing warehouse under the configured S3 host, 404 otherwise, so nobody can make the gateway mint certificates for arbitrary names. Called on the docker network; never served publicly. Auth: the deployment's bootstrap secret (X-Bootstrap-Secret). Not public: api.tablemere.eu answers 404 for every /internal/* path ### GET /internal/login-tokens/{email} → 200 Development stacks without a mail server: read a pending set-password or reset link token (purpose=set_password | reset_password). The pattern of /internal/signup-codes. Auth: the deployment's bootstrap secret (X-Bootstrap-Secret). Not public: api.tablemere.eu answers 404 for every /internal/* path ### POST /internal/usage → 200 The host's hourly maintenance run reports one warehouse's footprint (bytes, objects, blob bytes and objects); recording it recomputes the scope's storage quota. Auth: the deployment's bootstrap secret (X-Bootstrap-Secret). Not public: api.tablemere.eu answers 404 for every /internal/* path # Engines Tablemere is an Apache Iceberg REST catalog plus S3-compatible storage. Your engine talks to both directly; the recipes below are what tablemere connect --catalog lake --engine … prints, with the credential and the bucket filled in. Catalog: https://catalog.tablemere.eu. Storage: https://s3.tablemere.eu. ## DuckDB (1.5.5 or newer) — read and write ``` INSTALL iceberg; LOAD iceberg; INSTALL httpfs; LOAD httpfs; CREATE SECRET tablemere (TYPE ICEBERG, CLIENT_ID '', CLIENT_SECRET '', OAUTH2_SERVER_URI 'https://catalog.tablemere.eu/v1/oauth/tokens'); CREATE SECRET tablemere_s3 (TYPE S3, KEY_ID '', SECRET '', ENDPOINT 's3.tablemere.eu', URL_STYLE 'path', USE_SSL true, REGION 'us-east-1'); ATTACH 'w-' AS lake (TYPE ICEBERG, ENDPOINT 'https://catalog.tablemere.eu', SECRET tablemere); SELECT * FROM lake..
LIMIT 10; ``` The tablemere_s3 secret is not needed to read or write tables through the catalog (vended credentials cover that); it lets read_blob('s3://w-…/**') and friends inspect the bucket. ### Importing Parquet ``` -- a new table, from a local file or a URL CREATE TABLE lake.demo.cities AS SELECT * FROM read_parquet('cities.parquet'); -- into a table that exists (created by the API or an engine) INSERT INTO lake.demo.cities SELECT * FROM read_parquet('more-cities.parquet'); COPY lake.demo.cities FROM 'more-cities.parquet' (FORMAT PARQUET); -- namespaces are schemas CREATE SCHEMA lake.staging; ``` Measured against the service: a million rows (18 MB of Parquet) in about 2.3 s from a laptop, three data files, one commit; format-version 2. ## PyIceberg (0.12) — read and write ``` from pyiceberg.catalog.rest import RestCatalog catalog = RestCatalog(name='tablemere', uri='https://catalog.tablemere.eu', warehouse='s3://w-/', credential=':', **{'s3.endpoint': 'https://s3.tablemere.eu', 's3.path-style-access': 'true'}) table = catalog.load_table(('', '
')) # writing: create from an Arrow schema, then append import pyarrow.parquet as pq arrow = pq.read_table('cities.parquet') tbl = catalog.create_table(('demo', 'cities'), schema=arrow.schema) tbl.append(arrow) ``` PyIceberg writes one data file per append and a complete snapshot summary; DuckDB splits large writes into several files. Both are read identically by either engine. ## Spark (3.5 + Iceberg 1.11) — read and write, including format-version 3 ``` spark.sql.catalog.lake=org.apache.iceberg.spark.SparkCatalog spark.sql.catalog.lake.type=rest spark.sql.catalog.lake.uri=https://catalog.tablemere.eu spark.sql.catalog.lake.warehouse=s3://w-/ spark.sql.catalog.lake.credential=: spark.sql.catalog.lake.io-impl=org.apache.iceberg.aws.s3.S3FileIO spark.sql.catalog.lake.s3.endpoint=https://s3.tablemere.eu spark.sql.catalog.lake.s3.path-style-access=true spark.sql.catalog.lake.header.X-Iceberg-Access-Delegation=vended-credentials ``` Pass these as --conf flags or in spark-defaults.conf; the catalog is then lake in SQL (SELECT * FROM lake.demo.cities). ## Snowflake The catalog integration connects (catalog only). The data path is not available yet; it needs work on both sides and is on the roadmap. ## Gotchas, all of them measured - DuckDB: ATTACH the bare bucket name. ATTACH 'w-' is read-write. ATTACH 's3://w-/' attaches READ-ONLY and every INSERT fails with "attached in read-only mode". PyIceberg and Spark want the s3://w-/ form for warehouse, so copying one recipe's identifier into the other engine produces a false "DuckDB cannot write Iceberg". - DuckDB 1.5.5 or newer. 1.5.0 writes manifest lists the catalog's maintenance cannot read, so tables it wrote were neither compacted nor expired by the scheduler. 1.5.5 writes the spec-compliant schema. - Format version 2, unless you have Spark. Format-version 3 tables are read-only for DuckDB ("Not implemented Error: Insert into Iceberg V3 tables") and PyIceberg 0.12. Spark 3.5 + Iceberg 1.11 is the only writer verified. tablemere table create defaults to 2 and warns when it hands you a 3. - The catalog only accepts its own tokens. Engines mint them from the catalog credential (catalog_credential in the recipe) at https://catalog.tablemere.eu/v1/oauth/tokens (client_credentials; client_id = access key, client_secret = secret key) and refresh them themselves; they expire after 900 s. Tablemere API keys (al_live_…) and tokens from POST /v1/tokens are 401 at the catalog by design. - PyIceberg asks for vended credentials on every request. That is the intended path: the catalog returns per-table storage credentials which override any static s3.* key you pass. If a cross-bucket copy fails with ACCESS_DENIED, pass "header.X-Iceberg-Access-Delegation": "none" to use your own keys instead. - Table buckets accept only Iceberg files. Data files must be .parquet/.orc/.avro/.lance and metadata must look like Iceberg metadata, under /
/(data|metadata)/; anything else is refused on write. Put photos, documents and other blobs in the warehouse's blob bucket b- (same credential, S3 path-style, region us-east-1; tablemere connect prints a boto3 example) and index them in a table. - CTAS makes every column optional. CREATE TABLE … AS SELECT from DuckDB cannot express identifier fields, required columns or partitioning. When you need them, tablemere table create --column name:type[:required][:identifier] first, then INSERT. - Maintenance is on and not disableable. Compaction (128 MB target files), snapshot expiry (20 snapshots, 7 days) and orphan cleanup run on every catalog. A client that hard-codes vN.metadata.json file names will 404 after a maintenance commit; list the prefix or go through the catalog. - Rotation. tablemere catalog rotate replaces the catalog credential: the old key and every catalog token from it are refused at once; storage sessions already vended run out within 900 s. Fetch a fresh recipe afterwards. ## Direct S3 access from a script tablemere credentials --catalog lake --namespace demo --table cities (POST /v1/credentials) returns storage credentials scoped to that table's object prefix, with their expiry, for a boto3 or aws-cli session that needs the files themselves rather than the table. ## MCP server # Agent setup Written for the agent, not the person: paste https://tablemere.com/docs/agent-setup/ into your agent, or use the prompt on the agents page (https://tablemere.com/agents). The person's part is to approve the connection once in the browser. Source: the repository note docs/agent-setup.md. You are an agent setting up Tablemere for a person. Follow these steps in order. The person authorises access once, in their browser; you do the rest. Never print, paste or store a credential in the conversation: the CLI saves the key it receives to ~/.config/tablemere/credentials.json and every later command reads it from there. Tablemere is a managed Apache Iceberg lakehouse operated in Europe: one catalog per organisation to start (an Iceberg REST catalog with its own storage; the hierarchy is organisation -> catalog -> namespace -> table), and DuckDB as the engine you run locally. The API calls a catalog a warehouse (/v1/warehouses, warehouse_id); the CLI says catalog and accepts warehouse too. API: https://api.tablemere.eu. Site and docs: https://tablemere.com. ## 1. Install the CLI ``` curl -fsSL https://tablemere.com/install.sh | sh # a single Python 3 file, no dependencies tablemere --version ``` Or, if the person prefers a package manager: pipx install tablemere or pip install --user tablemere. The CLI needs Python 3.10 or newer, which macOS and every current Linux ship. ## 2. Connect this machine ``` tablemere login ``` What happens: - The CLI prints a link (https://tablemere.com/app/device?user_code=XXXX-XXXX) and the code, opens the browser when there is one, and waits (up to 15 minutes). - Tell the person: "Open this link and approve the connection in your browser" (repeat the code in case the link does not open). If they have no account yet, the same page offers Create your account: they fill in their name, email and organisation, click the link in the mail, choose a password, and land back on the approval with the code intact. Nothing else is needed from them. - The person sees a consent screen: this machine's name, the catalog, and the access asked for (read and write by default; they may lower it to read only). They approve. - The CLI receives the key of a new agent of their organisation, named after this machine and holding exactly the approved access, saves it, and prints: Connected to My catalog (read and write) as . Saved to ~/.config/tablemere/credentials.json. The key is never shown. Do not ask for it, do not look for it, do not cat the credentials file. Every tablemere command and the connection recipe below read it themselves. If the person wants to see or remove the connection later: their account page, Connections & tokens; revoking it stops this machine at once. Running tablemere login again on a machine whose saved key still works answers Still connected … and starts nothing new. --level read asks for read only; --name names the agent; --new forces a fresh authorisation. If the code expired before the person approved (15 minutes), run tablemere login again and send the new link. ## 3. Get the DuckDB recipe ``` tablemere whoami # kind: agent, my_level on the catalog tablemere connect --engine duckdb # a paste-ready DuckDB recipe for the connected catalog ``` connect prints the SQL to attach the catalog: its REST endpoint, the storage endpoint, and a short-lived credential it fetched for you. Run that SQL in DuckDB (pip install duckdb, or the duckdb binary). Do not copy the credential anywhere else; re-run tablemere connect when it expires. ## 4. Check that DuckDB can read and write Attach with the bare bucket name the recipe gives (ATTACH 'w-' AS lake (TYPE iceberg, …)), never s3://w-/ -- the s3:// form attaches read-only in DuckDB and every INSERT then fails with "attached in read-only mode". Then: ``` SHOW ALL TABLES; -- one SELECT against the catalog CREATE SCHEMA IF NOT EXISTS lake.scratch; CREATE TABLE lake.scratch.setup_check AS SELECT 1 AS ok, now() AS at; SELECT * FROM lake.scratch.setup_check; -- one INSERT read back DROP TABLE lake.scratch.setup_check; ``` If the CREATE fails with a 403 or AccessDenied, the connection is read only (the person lowered it, or holds read themselves). Say so; a person with write can grant it from the account page. ## 5. The first task Do what the person asked. The three common starts: - Use their files: CREATE TABLE lake.. AS SELECT * FROM read_csv('path') (or read_parquet). One statement per file; Iceberg tables, partitioning optional. Then answer their question with SQL. - Try an example dataset: DuckDB reads public Parquet over HTTPS; load one into a table the same way and show a few queries. - Continue an existing project: SHOW ALL TABLES, describe what is there, and pick up from it. tablemere table list --catalog and tablemere table get show tables, schemas and row counts from the API side; tablemere usage shows the free tier (5 GB) and headroom. ## Agent environment: getting started | Your environment | Start with | | You can execute commands and run DuckDB (Claude Code, Codex, a shell tool) | Steps 1–5 above: the CLI connection, then local analysis with DuckDB. Everything the person needs to do is approve once in the browser. | | MCP only, no shell (Claude Desktop with the Tablemere MCP server, no code execution) | Ask the person to run tablemere login and tablemere mcp install --client claude-desktop once in a terminal; the server then exposes the account as tools: list catalogs, connection recipes and short-lived table credentials, grants, tokens, members, usage, audit. Missing for analysis: the MCP server runs no queries -- there is no query tool -- so loading files or answering questions over the data needs an engine you can run. Say so, and offer the CLI path. docs/mcp.md has the tool list. | ## Rules - Credentials stay out of the conversation. The CLI saves them; --show-key exists for a person who asks, not for you. - One connection per machine; revoke from the account page, never by deleting the file (the agent would stay valid). - Everything is in the open formats it says: Iceberg tables in an S3 bucket the person can take elsewhere. # The Tablemere MCP server The MCP server is published from the repository note docs/mcp.md, as written on 2026-09-20. It is installable from a checkout (uvx --from /platform/mcp tablemere-mcp); the package name tablemere-mcp is reserved and not yet on PyPI. tablemere mcp install (CLI reference (https://tablemere.com/docs/cli/#tablemere-mcp-install)) writes the host configuration below for you, with the saved key and without showing it. tablemere-mcp gives an MCP host (Claude Desktop, Claude Code, Cursor, anything that speaks the protocol) the Tablemere account as tools: catalogs (a catalog is a warehouse in the API and in the tool names), connection recipes and short-lived table credentials, grants, tokens, members, the organisation, usage, the audit log, Terms. It is the third face of the same REST API as the CLI and the account page (docs/permissions-and-access.md), and it deliberately does not query data: the agent gets a connection recipe and runs DuckDB itself. Source: platform/mcp/tablemere_mcp.py (one file). Transport: stdio. SDK: the official mcp Python package, pinned. ## Install The shortest path is the CLI, which already holds your key (tablemere signup --save or tablemere login --save wrote it to ~/.config/tablemere/credentials.json): ``` tablemere mcp install # Claude Code: `claude mcp add-json`, user scope tablemere mcp install --client claude-desktop # Claude Desktop: merges into claude_desktop_config.json (backup first) tablemere mcp install --print # the snippet for any other host, key masked tablemere mcp run -- --list-tools # start the server yourself, key in its environment ``` The key travels from the credentials file into the host's configuration and is never printed; the output shows it masked (al_live__********). --api-key installs a different key, for instance a token's; --from says what uvx installs (a checkout's platform/mcp, the default when the CLI runs from one, or the package name once it is published); --url picks the API and the saved key for it. tablemere mcp install --help has the rest. The server itself is a Python package in the repo; there is no PyPI release yet. By hand, any of: ``` # run without installing (uv), from a checkout uvx --from /path/to/tablemere/platform/mcp tablemere-mcp --list-tools # install as a command (pipx) pipx install /path/to/tablemere/platform/mcp tablemere-mcp --version # or build a wheel once and install that anywhere cd platform/mcp && uv build && pipx install dist/tablemere_mcp-0.1.0-py3-none-any.whl ``` Python 3.10 or newer. --list-tools prints every tool with its JSON schema and exits; without it the server speaks MCP on stdin/stdout and is meant to be started by the host, not by hand. ## Configure the host Two environment variables, nothing else: | variable | meaning | default | | TABLEMERE_API_KEY | a token's API key (al_live_…). Create one in the account page (Tokens) or with tablemere agent create --name claude --grant :write; the key is shown once | none; every tool then answers missing_credential | | TABLEMERE_URL | the API | https://api.tablemere.eu | Claude Desktop (claude_desktop_config.json) and Claude Code (.mcp.json in a project, or claude mcp add-json tablemere '') take the same mcpServers shape: ``` { "mcpServers": { "tablemere": { "command": "uvx", "args": ["--from", "/path/to/tablemere/platform/mcp", "tablemere-mcp"], "env": { "TABLEMERE_API_KEY": "al_live_...", "TABLEMERE_URL": "https://api.tablemere.eu" } } } } ``` With a pipx install, "command": "tablemere-mcp" and no args. tablemere mcp install writes exactly this object (with uvx by absolute path, because hosts start servers with a short PATH). Give the server a token created for it, not a human's key, once tokens can hold organisation roles (below): a token can be confined to the catalogs it needs and revoked on its own, and its actions appear under its own name in the audit log. Today the account-management tools need a human member's key, which is what tablemere mcp install uses by default. ## Tools Every tool's arguments are typed and described in its schema (tablemere-mcp --list-tools). Where the API wants an id, the tools also accept the name (catalog, project, organisation) and resolve it; an ambiguous name is an error listing the candidates, never a guess. When the token belongs to one organisation or sees one project, org / project can be omitted. | tool | what it does | API | | whoami | principal id, organisations and role in each, projects, Terms status | GET /v1/usage, /v1/orgs, /v1/terms | | usage | every limit, current value and headroom; storage bytes per organisation and catalog | GET /v1/usage | | list_warehouses | the catalogs the token can see, grouped by project, with locations and catalog URI | GET /v1/projects + GET /v1/warehouses | | create_warehouse | a new catalog, in the organisation's default project unless one is named; takes an idempotency key (one is generated if omitted) | POST /v1/warehouses | | delete_warehouse | irreversible: drops every table, both buckets, both identities, grants and usage; needs the catalog's exact name as confirm, checked before anything is sent; admins only | DELETE /v1/warehouses/{id} | | connection | the paste-ready DuckDB / PyIceberg / Spark recipe for a catalog | GET /v1/connection | | table_credentials | short-lived S3 credentials scoped to one table's prefix (about an hour) | POST /v1/credentials | | grants_list | who holds read or write on a catalog and via what (membership, creator, admin, grant) | GET /v1/warehouses/{id}/grants | | grants_set | give a member or token read or write on a catalog | PUT /v1/warehouses/{id}/grants/{principal} | | grants_revoke | remove a grant | DELETE /v1/warehouses/{id}/grants/{principal} | | tokens_list | the organisation's tokens (agents): id, name, grants, creator, last used | GET /v1/orgs/{org}/agents | | tokens_create | a new token with grants [{warehouse, level}]; its key is returned once; at least one grant | POST /v1/orgs/{org}/agents | | tokens_update | rename a token and/or replace its grants | PATCH /v1/orgs/{org}/agents/{id} | | tokens_revoke | revoke a token: every key stops at once | DELETE /v1/orgs/{org}/agents/{id} | | members_list | members with role and join date | GET /v1/orgs/{org}/members | | members_invite | invite an address as admin or member; the invitation token is returned once | POST /v1/orgs/{org}/invitations | | members_set_role | admin or member; the last admin cannot be demoted | PATCH /v1/orgs/{org}/members/{id} | | members_remove | remove a member | DELETE /v1/orgs/{org}/members/{id} | | org_get | the organisation(s): name, your role, domains, member count | GET /v1/orgs | | org_rename | rename the organisation (admins) | PATCH /v1/orgs/{org} | | audit | who did what: own events, or the organisation's for admins; since, action, limit | GET /v1/audit | | terms_status | the Terms version in force and whether it is accepted | GET /v1/terms | | terms_accept | record acceptance, after the human has read them | POST /v1/terms/accept | Tools carry the MCP annotations hosts use to decide when to ask the user: readOnlyHint on every read, destructiveHint on delete_warehouse, grants_revoke, tokens_revoke, members_remove. ### Errors A failed call is an isError result whose content is the API's error envelope, unchanged, plus the HTTP status: ``` {"error": {"code": "quota_exceeded", "message": "3 of 3 warehouses.", "limit": 3, "current": 3, "remedy": "Delete one, or ask for a higher limit.", "request_id": "…"}, "http_status": 409} ``` The code says whether to change the request or the plan; the remedy says how. Three envelopes are the server's own: missing_credential (no key configured), unreachable (no answer from TABLEMERE_URL; safe to retry), and route_missing — the deployed control plane predates a route of the 2026-09-20 contract (the grants routes, tokens_update, delete_warehouse). Nothing was changed in that case; the envelope names the method and path. ### Today's control plane (measured 2026-09-20 against the local stack) The server is written against the 2026-09-20 contract; the control plane is catching up. Driven with a human member's key (the signup principal), every tool answered, and grants_list, tokens_update and delete_warehouse came back route_missing (their routes do not exist yet; nothing was changed). Driven with an agent token's key created by that human: | tool | as an agent token, today | | whoami, usage, list_warehouses, connection, table_credentials, audit (own), terms_status | work | | org_get | answers, with an empty list: the token is not a member of the organisation that created it | | tokens_*, members_*, org_rename, audit with org | no_organization (this server's envelope), for the same reason | | create_warehouse | 403 forbidden from the API: only an owner or admin may create a catalog | | list_warehouses | lists every catalog of the project, including those the token is confined out of; the API answers 404 on use, as it does for a stranger | The measured limitation, in one sentence: on today's control plane a token (principal kind agent) cannot call the organisation routes (/v1/orgs/…: tokens, members, the organisation itself, the organisation's audit log), because a token is not a member of the organisation that created it and the routes check membership. Nothing in this server can change that; it is a contract follow-up: docs/permissions-and-access.md already writes the MCP server as "authenticated with a token's API key", so either tokens gain organisation roles or the organisation routes accept a token as acting for its organisation within its grants, and the control plane has to implement it before that sentence is true for the account tools. So until that lands, the account-management tools want a human member's API key: tablemere login --save (or tablemere signup --save) writes one to the credentials file, and tablemere mcp install uses it by default; tablemere api-key create --label mcp mints a separate key for the server, shown once, revocable on its own (tablemere api-key revoke). A confined agent token serves the data-side tools (connection, table_credentials, usage, own audit). The no_organization remedy says this to the agent. ## Security model - The token's grants bound every tool. The server adds no permission of its own and holds no second credential; a read-only token gets read-only recipes and credentials, and a 403 from the API is returned as such. What an admin can do through the tools is exactly what that admin's token can do with curl. - The key is configuration, not conversation. It reaches the process through the environment and leaves it only as the Authorization header to TABLEMERE_URL. It is never logged, never part of a result, never in a traceback (Config.__repr__ prints set/unset). Keys the API creates on request (tokens_create, members_invite's invitation token) are returned once because that is what was asked for; store them where they belong, not in the chat. - Nothing is cached. Every tool call is one or a few HTTP requests made now; there is no local state, no credentials file, no memory between calls. Revoking the token ends the server's access at the next call. - No telemetry. The server talks to TABLEMERE_URL and to nothing else; the only thing it sends beyond the request is a User-Agent: tablemere-mcp/. - Destructive actions are refused client-side when the confirmation is wrong (delete_warehouse compares confirm with the resolved name before sending) and the API refuses them again server-side. - Use HTTPS. TABLEMERE_URL defaults to https://api.tablemere.eu; a plain http:// URL is for a local stack only. ## What is not there, on purpose - No query tool, no peek, no natural-language-to-SQL. Rows in a tool response are tokens in a context window and compute on our side; both are the wrong place for them (research/13 §1). The agent calls connection and runs the recipe in its own DuckDB: ``` INSTALL iceberg; LOAD iceberg; INSTALL httpfs; LOAD httpfs; -- then the CREATE SECRET / ATTACH statements exactly as `connection` returned them SELECT count(*) FROM lake..
; ``` - No table creation or schema tools. DuckDB does CREATE TABLE … AS SELECT * FROM read_parquet() against the attached catalog (platform/docs/import-parquet.md); a tool would only add a hop. - No signup, login, key recovery or account deletion. Those are a human's actions in the account page or the CLI (tablemere signup|login|recover|account), not something an agent should do with someone else's key. - No metadata plane yet (row counts, partitions, freshness, plan_query from research/13 §2): it needs API routes first; the MCP server only ever projects what the API offers. ## Tests ``` /tmp/mcp-venv/bin/python -m pytest platform/mcp/tests -q # 34 tests, no network TABLEMERE_URL=http://localhost:8080 TABLEMERE_API_KEY=al_live_... \ /tmp/mcp-venv/bin/python platform/mcp/tests/live_smoke.py # skipped when the key is unset python3 -m pytest platform/cli/tests -q # tablemere mcp install|run: 17 tests, temp HOME ``` The unit tests drive every tool through the MCP layer against an httpx.MockTransport that plays the API, and assert the request shape (method, path, query, JSON body, Authorization, Idempotency-Key) and the error paths. The live smoke creates one catalog, one token and one invitation under the configured principal, then revokes and deletes what the deployed API lets it; run it under a throwaway principal. ## Self-hosting # Self-hosting Tablemere This is the engineering note from the repository, published as written on 2026-09-19. Paths (stack/, deploy/, scripts/) are relative to the platform directory of the source tree, which is not yet published: the licence for the control plane and the CLI is an open decision, as the note says. The managed service at tablemere.com is this shape, run by us. Ask hello@tablemere.eu if you want to run it. Written 2026-09-19 to answer "are we blocking ourselves for a self-hosted version?" The answer today: no, because the managed service is the self-hosted shape run by us. Nothing in the stack knows it is ours. Revised 2026-09-20: the identity provider left the stack; login is the control plane's own, and an external OpenID Connect issuer is the self-hosting option (below). ## What a self-hoster runs The same three containers the laptop and al-lake1 run, from stack/docker-compose.yaml plus the production override: | container | image | role | | weed-init → weed | chrislusf/seaweedfs:4.47 (Apache-2.0) | object store, Iceberg REST catalog, IAM/STS, vending, maintenance | | control-plane | built from control_plane/ (FastAPI, SQLite) | tenants, recipes, quotas, organisations, signup, login (email + password, sessions, the CLI's device flow, our mails) | | gateway | caddy:2.11-alpine | TLS on api., catalog., s3. and the website | State is one directory (/data), which is also the backup set (deploy/README.md). Identity is pluggable by configuration (AL_OIDC_ISSUERS, research/18): a company with Okta, Entra, Keycloak or Authentik names its issuer and gets SSO with it, beside or instead of the form login (docs/login.md). Mail for the signup, set-password and reset links is any SMTP sender (AL_SMTP_*). Placement is whatever they set (AL_PROVIDER, AL_REGION). ## The path, as it exists - A host with Docker and a data disk mounted at /data (deploy/host_setup.sh does it for a Hetzner Volume; any disk works). - deploy/render_env.sh writes the server .env; AL_DOMAIN=lake.example.com gives real hostnames, three A records at their DNS (plus the website's, if the gateway serves it). - deploy/push.sh (rsync, ./al up, ./al bootstrap, TLS health) — or, without our laptop scripts, cp .env.example .env, edit, ./al up && ./al bootstrap on the host itself. - scripts/test.sh phase1 against their hostnames is the acceptance test they can run. ## Bringing your own identity provider (AL_OIDC_ISSUERS) The control plane trusts the issuers named in AL_OIDC_ISSUERS, a JSON list (single-quoted in .env, or compose strips the quotes) of ``` {"issuer": "", "client_id": "", "audience": "", "discovery_url": "", "trust_email": } ``` A verified token from a listed issuer is accepted wherever ours is: the first appearance of an address creates the principal (or activates the pending organisation a signup form reserved for it), /v1/onboard names the organisation, and POST /v1/api-keys mints keys for the person's agents. GET /v1/auth/config lists the issuers, so the account page shows a "Log in with" button for each beside the form (or instead of it, with AL_PASSWORD_LOGIN=false), and tablemere login --issuer runs the OpenID Connect device flow against it instead of ours. What the IdP side needs, whatever the product: a public client (no secret; PKCE S256), the grants authorization_code (the account page), urn:ietf:params:oauth:grant-type:device_code (the CLI) and refresh_token; redirect_uris with /app/ and the loopback prefix http://127.0.0.1:* for the CLI; as an allowed origin (the page calls the token endpoint with fetch); RS256 tokens that carry email and email_verified (or trust_email: true here). Worked example, Rauthy 0.36 (what the managed service ran until 2026-09-20; the scripts that provisioned it, scripts/rauthy_setup.sh and deploy/render_rauthy_env.sh, are in git history before that date). Rauthy behind the gateway on https://auth., PROXY_MODE=true, PUB_URL the public hostname; a client tablemere created over its admin API with the flows above and access_token_alg: RS256. The control plane's line, discovery fetched in-cluster because the public name does not resolve inside the compose network: ``` AL_OIDC_ISSUERS='[{"issuer":"https://auth.example.com/auth/v1","discovery_url":"http://rauthy:8080/auth/v1","client_id":"tablemere","audience":"tablemere"}]' ``` Two things learnt running it: Rauthy emits its issuer with a trailing slash in some places (the control plane matches modulo that slash), and probing paths under /docs/ on it blacklists the source IP for 24 hours. Its registration page and mails cannot be made yours (colours yes, structure and hostname no), which is why the managed service moved the login into the control plane. ## What would block a self-hoster today, honestly - Our deploy scripts assume Hetzner for provisioning (provision.sh) and for the backup tier (HETZNER_S3_*, master.toml). The compose stack does not; the backup upload does (boto3 to any S3-compatible endpoint, so a one-line change). - The Hetzner Volume assumption in docker-compose.prod.yaml is just bind mounts under /data. - weed mini is single-node. HA (PLAN §8) is the multi-process SeaweedFS topology; when we do it for ourselves the self-hosted shape follows. - No packaging: no Helm chart, no single installer. Not needed until someone asks. - License: the control plane and CLI have no licence file yet; that is a decision for the human (the platform's value is the operated service, so a permissive licence on the code costs little). ## Operating a single host: what the deploy scripts now guarantee (2026-09-20) - Every long-running container restarts after a reboot or an OOM kill (restart: unless-stopped); the control plane and the gateway run capability-less on a read-only rootfs (docker-compose.yaml). - The daily backup (deploy/on_host_backup.sh) checks free space before it stops anything, restarts weed from an EXIT trap whatever fails in between, prunes its own archives by count and size, and drops old fs.meta dumps. A restore (deploy/restore.sh) hardens the host, installs the backup, maintenance and self-update timers, and prints a checklist of what it did not do. An archive from before 2026-09-20 carries the retired identity provider's /data/rauthy; it is restored as inert data and nothing is started for it. - Logs our timers write under /data rotate weekly (deploy/on_host_logrotate.sh). - Destroying cloud resources keeps the data Volume unless AL_DELETE_VOLUMES=yes (scripts/cloud_nuke.sh); ./al nuke asks first. ## Keep it that way Rules that keep self-hosting cheap: no hard-coded hostnames (everything through .env), no cloud API in the data path, no dependency on a Hetzner-only service inside the containers, identity through standard OIDC only, and the acceptance suite runnable against any hostname.