API reference
Base URL https://api.lakehousebox.com. 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. When the caller is an lhbox older than the one published (it says so in User-Agent: lhbox/<version> …), a refusal (a 4xx: typed errors, and the router's own 404/405 beside its detail) also carries cli_outdated {version, published, remedy}: an option or route the docs describe may simply be newer than that CLI. A deployment may refuse versions below a minimum: 426 cli_too_old with the same remedy. 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_<key id>_<secret>; only the hash is stored; ten wrong secrets for one key id from one address within ten minutes answer 429 with Retry-After.
A person whose address an organisation's identity provider covers, while that organisation enforces single sign-on, reaches every route other than the sign-in ones (/v1/auth/…) only with a session the provider made or the provider's own token: any other browser session, or another issuer's token, answers 403 sso_required (naming the provider) everywhere else, and the organisation's break-glass admin's password-and-code session answers 403 break_glass_restricted outside the routes that repair sign-in (its identity provider, its domains, its member list). On a deployment with the ENS security profile a covered address whose organisation does not enforce gets 403 password_login_disabled the same way (an enforcing one still answers sso_required, and the break-glass is no exemption there). API keys and tokens are not sign-ins and are never refused this way.
Words: a catalog in the account page, the CLI and these docs is a warehouse in the API (/v1/warehouses, warehouse_id, my_level). Catalog aliases (since 2026-09-25, additive; the warehouse names keep working): every /v1/warehouses… path also answers as /v1/catalogs…; the query parameters catalog_id, catalog_ids and catalog stand for warehouse_id, warehouse_ids and warehouse; a JSON body (Content-Type: application/json with a Content-Length, at most 1 MB) may send catalog_id / catalog_ids at its top level and in the items of a top-level grants list; JSON answers of at most 8 MB that carry warehouse_id also carry catalog_id with the same value. Where an answer's warehouse is a storage location (a catalog, GET /v1/connection) it also carries catalog_location; where it is the catalog's name (the read routes GET /v1/namespaces, /v1/tables, /v1/table), catalog. An explicit warehouse_* always wins. Not aliased: the routes that carry your data (POST /v1/query, GET /v1/object) and every sink route except creating one (POST /v1/sinks, top level only), whose bodies and answers pass exactly as sent: filter the sink list with GET /v1/sinks?warehouse=, not ?catalog= (sink answers still carry catalog_id).
The Iceberg catalog at https://catalog.lakehousebox.com and the object store at https://s3.lakehousebox.com are separate surfaces with their own credential, the catalog's catalog_credential from GET /v1/connection. They never accept LakehouseBox 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 /healthz → 200
Liveness: the state database answers and the catalog answers /v1/config.
GET /jwks.json → 200
The RS256 public key that verifies tokens from POST /v1/tokens.
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.
{"api_key": "al_live_…"}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 with a pending invitation joins the inviting organisation instead (will_join). An email domain never places anyone: an organisation that verified and opened the address's domain is only offered afterwards (domain_offers; POST /v1/orgs/{org_id}/join). While new accounts are admitted in batches (an hourly or total cap the operator sets, or the store short of room), a NEW address is put on the waitlist instead: 202 {email, waitlisted: true, message, next}, no code and no mail; the address gets the set-password mail when its turn comes. Existing accounts and invitations never wait.
{"email": "you@example.com", "workspace"?: "acme", "full_name"?: "Ana Lopez", "organization_name"?: "Acme Labs", "handle"?: "acme-labs"}lhbox signup --email …POST /v1/agent/identity → 201
An agent with no account and no person (a deployment setting turns it on): an UNCLAIMED organisation, its catalog `lakehouse` and a key with write on it, answered once. Sending the request accepts the Terms of Service and the Acceptable Use Policy. Nobody owns it: it is deleted with its data at expires_at (72 h) unless a person claims it; until then 1 GB, one catalog, nothing public, and its tokens change data only (any other write is 403 claim_required). Its fields include auth.md's anonymous registration (registration_id, pre_claim_scopes, post_claim_scopes).
(no body) or {"type": "anonymous", "client"?, "hostname"?, "client_name"?, "campaign"?: {source?, medium?, campaign?, content?}} (campaign: the tags of the page the command came from, each 1-64 of letters, digits, ".", "_", "-")lhbox newDELETE /v1/agent/identity → 200
The agent ends what `lhbox new` started, before the expiry: the unclaimed organisation, its catalog with every table and file, its tokens and keys, deleted now, as the expiry would at expires_at. For a key that leaked, or a catalog nobody needs. Only while nobody has claimed it; a claim that lands first keeps it.
{"confirm": "<org_id>"} (GET /v1/agent/status names it)lhbox discard403 mcp_human_only): a person or an API key does thisPOST /v1/agent/identity/claim → 200
A person keeps what an agent made without an account: a device code bound to the unclaimed organisation. The person opens verification_uri_complete, signs up or logs in, types the code and keeps the catalog in a new organisation (with a handle) or one they administer; the agent polls POST /v1/auth/device/token with device_code and receives a NEW key at the level the person chose. Every pre-claim key stops working at the claim, and the catalog keys the agent fetched are rotated. A new claim cancels the earlier pending one; the code lives 24 h, never past the organisation's expiry. Called with an MCP access token (an account a client set up) the answer has no device_code and no interval: no key comes from it; the connection ends at the claim and the person reconnects it, signed in.
(no body)lhbox claimcatalogs:write (an MCP access token; an API key has no scopes)GET /v1/agent/status → 200
Whether the caller's organisation is still unclaimed (made by an agent without an account) and the time it has left before it is deleted with its data.
lhbox statusaccount:read (an MCP access token; an API key has no scopes)GET /v1/handles/{handle} → 200
Is this organisation handle free? For the signup form as you type (per-address rate limit). A handle being chosen: one character, or 3-27 lowercase letters, digits and dashes, starting with a letter and ending with a letter or digit, because the organisation's default catalog is named after it (dashes as underscores: acme-corp -> acme_corp) and must read well in SQL; never two dashes in a row (`--` separates the handle from the catalog name in the organisation's storage bucket names, <handle>--<catalog>); app, api, docs, legal, admin, lhbox, www, s3, catalog and auth are reserved, and a word SQL reserves (DuckDB's reserved keywords such as select, end or union, and main, memory, system, temp) is refused. Handles chosen before 2026-10-10 keep working.
POST /v1/signup/verify → 201
Step 2 of 2: the right code creates the account (no API key of its own, and no password yet: /app/forgot sets the first one), an organisation (owned by you unless an invitation places you elsewhere), and the organisation's ONE default catalog: the one it has when you join it by invitation, else one created now and named after its handle, dashes as underscores (`acme-corp` -> `acme_corp`); and a CONNECTION for the machine that signed up: a token of the organisation named after it (member role, on that catalog only: read and write, or read for a member who joined), the same kind `lhbox login` makes. The key returned is that connection's; it lists under Settings › Access keys and is revoked there. An email domain never places anyone: an organisation that verified and opened the address's domain is only offered afterwards (domain_offers; POST /v1/orgs/{org_id}/join).
{"email": …, "code": "123456", "accept_terms": true, "engine": "duckdb", "hostname"?: "laptop", "client_name"?: "laptop", "client"?: "cli"} (engine: duckdb|pyiceberg|spark, default duckdb; client_name names the connection, default the hostname)lhbox signup --email … --code … --accept-terms --savePOST /v1/signup → 201
The one-call, UNVERIFIED signup for local development stacks. The managed service answers 403 verification_required: use /v1/signup/start + /verify.
{"email", "workspace", "engine", "accept_terms"}POST /v1/recover/start → 410
Retired on 2026-10-01: a code by email no longer mints a key with a person's full access (nor does signup since then). Always 410, nothing sent, nothing created. A person resets the password at /app/forgot and logs in; a machine runs `lhbox login` again and the person approves it.
any bodylhbox recoverPOST /v1/recover/verify → 410
Retired on 2026-10-01 with /v1/recover/start: always 410, no key minted.
any bodylhbox recoverLogin 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. An address with a pending invitation joins the inviting organisation instead (the organisation fields are ignored, nothing is reserved; will_join says so with via: invitation). An email domain never places anyone: an organisation that verified and opened the address's domain is only offered afterwards (domain_offers; POST /v1/orgs/{org_id}/join). The code path for agents and the CLI stays at POST /v1/signup/start. While new accounts are admitted in batches, a NEW address is answered 202 {email, waitlisted: true, message, next} instead: nothing is created and nothing is mailed until its turn comes (then the same set-password mail).
{"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)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 inviting organisation with the invited role -- a joiner provisions nothing: the organisation's default catalog is theirs), records the Terms version the form showed, and returns a session with warehouse{warehouse_id, name, project_id, created} and next. With an unanswered domain offer (domain_offers) the personal default catalog waits: warehouse is null, warehouse_deferred is true, and next names both ways on (join the offering organisation, or decline with POST /v1/onboard, which creates the catalog then).
{"token": …, "password": …} (12 to 256 characters, not containing the address or the organisation name)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). `session_cookie: true` (the account page) puts the refresh token in an HttpOnly cookie for this account instead of the body; asked from another site's page it is 403 origin_not_allowed.
{"email": …, "password": …, "code"?: …, "session_cookie"?: true}GET /v1/auth/google/start → 200
"Continue with Google" (only where the deployment has its own Google client: GET /v1/auth/config names `google`, otherwise 404). Google's authorize URL; a fresh state, nonce and PKCE verifier (S256) are bound to this browser in a sealed HttpOnly cookie on /v1/auth/google for 10 minutes. `accept_terms=true` only from the signup view's button, which says that continuing accepts the Terms: a new account needs it.
?accept_terms=true&hd=<domain>&prompt=none&login_hint=<email> (hd: pre-selects a Google Workspace account of that domain at Google, what GET /v1/auth/realm names; the token's hd claim is still checked. prompt=none + login_hint: the silent renewal of a short Workspace session)POST /v1/auth/google/callback → 200
The second half of "Continue with Google": the code and state Google sent back to /app/auth/google. State checked against the cookie (spent here), the code exchanged server-side, the ID token verified, the address verified by Google. A known Google account signs in. Otherwise Google's word counts only for gmail.com/googlemail.com or a Workspace account of the address's domain: then an account with that address is linked, or a new one made as by the signup form; for any other address nothing happens before our email code (401 email_code_required, POST /v1/auth/google/link). An organisation's own identity provider wins for the addresses it covers (403 sso_required). The link is written only after every check, the second factor included.
{"code": …, "state": …, "session_cookie"?: true}POST /v1/auth/google/link → 200
The email-code step of a Google sign-in at an address Google does not run: the google_token of 401 email_code_required and the six digits we emailed; then whichever of login, link or signup applies, with the rest of the sign-in (organisation provider, second factor, account state).
{"google_token": …, "code": "123456", "session_cookie"?: true}POST /v1/auth/google/reauth → 200
A browser session of an account with NO password that signs in with Google confirms it is still the person, the counterpart of POST /v1/auth/reauth (issue #1554). An account with a password always confirms with the password (and its second factor), never here. With two-factor on, the authenticator's code (or a backup code, spent) is checked first. Answers Google's authorize URL with max_age=0 (Google signs the person in again) and login_hint, bound to this browser and this session; the callback (POST /v1/auth/google/callback) then answers reauth instead of a session once the ID token is the Google account linked to the person and its auth_time is no earlier than 5 s before the start, and the session counts as recently signed in for 10 minutes. The callback refuses what a Google sign-in to the account would be refused for (its state, sso_required, workspace_required) and an account that set a password meanwhile (409 password_reauth_required). The routes that need a recent sign-in (device approval, MCP consent, the credential routes; not the static key pair) answer such a session 401 reauth_required with reauth: google, start naming this route and needs: [code] with two-factor on.
{"code"?: …} or {"passkey": <assertion for POST /v1/auth/passkeys/confirm/start {"purpose": "google_reauth"}>} (instead of the code)403 mcp_human_only): a person or an API key does thisPOST /v1/auth/login/mfa → 200
The second step of a login with two-factor on: the challenge from 401 mfa_required plus a 6-digit authenticator code or a single-use backup code, for the session. The challenge lives 5 minutes and takes 5 codes; a code is never accepted twice.
{"mfa_token": …, "code": …} or {"mfa_token": …, "passkey": <the assertion for POST /v1/auth/login/passkey/start {mfa_token}>}POST /v1/auth/mfa/enrol/start → 200
The enrolment a deployment requires (self-hosted): with the enrol_token of 401 mfa_enrolment_required from POST /v1/auth/login, a new authenticator secret, pending until a code confirms it. The token lives 10 minutes and dies with a password change; starting again replaces the pending secret.
{"enrol_token": …}POST /v1/auth/mfa/enrol/confirm → 200
The current code of the pending secret turns two-factor on and finishes the login: the session POST /v1/auth/login answers (marked two-factor), with the backup codes, shown once. `session_cookie: true` as for POST /v1/auth/login.
{"enrol_token": …, "code": "123456", "session_cookie"?: true}POST /v1/auth/login/passkey/start → 200
The options for navigator.credentials.get(): with no body, for a sign-in with a passkey alone (the browser offers the passkeys it holds for the site); with the mfa_token of 401 mfa_required, for the second step of a password login, naming that account's passkeys. The challenge lives 5 minutes and is used once.
{"mfa_token"?: …}POST /v1/auth/login/passkey → 200
A passkey to a session, without an address or a password: the passkey names its account and, because the device's unlock (fingerprint, face or PIN) is required, the session counts as two-factor. `session_cookie: true` as for POST /v1/auth/login.
{"credential": <PublicKeyCredential.toJSON() of the assertion>, "session_cookie"?: true}POST /v1/auth/reauth → 200
A person's browser session confirms it is still the person: the current password, and a code when two-factor is on (a backup code is spent). The routes that hand out a durable credential (API keys, tokens, uploaders, catalog key rotation, sinks, device tokens, device approvals) accept a session whose login, or whose last confirmation here, is within the last 10 minutes; otherwise they answer 401 reauth_required and the account page asks for the password. An API key is never asked. An account with no password that signs in with Google renews with POST /v1/auth/google/reauth instead (the 401 says reauth: google), and an identity provider's sign-in at the provider (reauth: identity_provider). A provider that sends no auth_time cannot show a recent sign-in: the 401 then carries id_token_without_auth_time and a remedy naming the claim an admin adds. One of the account's passkeys confirms on its own, as it signs in on its own; the refusals of an account with a passkey name it in passkey{start, purpose, alone}.
{"current_password": …, "code"?: …} or {"passkey": <PublicKeyCredential.toJSON() of the assertion for POST /v1/auth/passkeys/confirm/start {"purpose": "reauth"}>}403 mcp_human_only): a person or an API key does thisPOST /v1/auth/passkeys/confirm/start → 200
The options for navigator.credentials.get() naming the caller's passkeys, to confirm one act with a passkey instead of the authenticator's code: purpose reauth (POST /v1/auth/reauth), google_reauth (POST /v1/auth/google/reauth), account_delete (DELETE /v1/me), password_change, passkey_add (register/finish), totp_disable, backup_codes. The challenge is bound to this session and to that act, lives 5 minutes and is used once; the assertion goes to the act's route as {"passkey": …}.
{"purpose": "reauth"}403 mcp_human_only): a person or an API key does thisGET /v1/auth/passkeys → 200
The caller's passkeys (a person's browser session).
account:read (an MCP access token; an API key has no scopes)POST /v1/auth/passkeys/register/start → 200
The options for navigator.credentials.create(), bound to this session for 5 minutes. Needs the current password; when two-factor is on, a code must come too (send it again with register/finish, where it is checked, so a cancelled prompt spends no backup code).
{"password": …, "code"?: …} or {"password": …, "second_factor": "passkey"} (an existing passkey answers at finish)403 mcp_human_only): a person or an API key does thisPOST /v1/auth/passkeys/register/finish → 201
The new passkey checked and stored; a notice is mailed to the account's address. With two-factor on, the code is checked here, after the ceremony.
{"credential": <PublicKeyCredential.toJSON() of the attestation>, "name"?: "MacBook", "code"?: …, "passkey"?: <an existing passkey's assertion for confirm/start {"purpose": "passkey_add"}>}403 mcp_human_only): a person or an API key does thisDELETE /v1/auth/passkeys/{passkey_id} → 200
Remove one of the caller's passkeys: it no longer signs in, every session it opened is revoked (not the one making the call), and a notice is mailed. Needs a recent sign-in (the session's login, or POST /v1/auth/reauth, within the last 10 minutes).
403 mcp_human_only): a person or an API key does thisGET /v1/auth/mfa → 200
The caller's two-factor state.
account:read (an MCP access token; an API key has no scopes)POST /v1/auth/mfa/totp/start → 200
Begin two-factor enrolment (a person's browser session, with the current password): a new secret, pending until a code confirms it. The QR code on the account page encodes otpauth_uri; secret is for typing it by hand.
{"password": …}403 mcp_human_only): a person or an API key does thisPOST /v1/auth/mfa/totp/confirm → 200
The current code of the pending secret turns two-factor on. The backup codes are in this answer only; every other session of the account is revoked.
{"code": "123456"}403 mcp_human_only): a person or an API key does thisPOST /v1/auth/mfa/totp/disable → 200
Two-factor off: needs the password and a current code (or a backup code); the secret and the backup codes are deleted.
{"password": …, "code": …} or {"password": …, "passkey": <assertion for confirm/start {"purpose": "totp_disable"}>}403 mcp_human_only): a person or an API key does thisPOST /v1/auth/mfa/backup-codes → 200
New backup codes for old: needs a current code (or a backup code); the earlier codes stop working.
{"code": …} or {"passkey": <assertion for confirm/start {"purpose": "backup_codes"}>}403 mcp_human_only): a person or an API key does thisPOST /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). The account page sends no token: its HttpOnly cookie of the account named is read, rotated and set again (a cookie rotated out under 30 s ago is rotated again, not taken for theft, and every token such a re-rotation superseded stays usable until the next rotation, so two or more tabs refreshing at once keep the session).
{"refresh_token": …} or, from the account page, {"account"?: "<principal_id>"} with the cookies (none named: the most recently used)POST /v1/auth/logout → 200
Revokes the session, by its refresh token, by the account page's cookie (and clears it), or by the access token in Authorization (sid claim). Its access tokens are refused at once (401 session_revoked), not only when they expire.
{"refresh_token": …} or {"account": "<principal_id>"} / {"all": true} with the cookies or Authorization: Bearer <access token>GET /v1/auth/accounts → 200
The accounts this browser holds a live session cookie for (the account page's account picker; its scripts cannot read the HttpOnly cookies). Reads only; suspended accounts are left out.
GET /v1/auth/sessions → 200
The caller's live sessions: where and when they logged in; `current` marks the one making the call.
account:read (an MCP access token; an API key has no scopes)DELETE /v1/auth/sessions/{session_id} → 200
Revokes one of the caller's sessions.
403 mcp_human_only): a person or an API key does thisPOST /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. The newest 3 links stay valid together (a later request does not kill the one you are about to use); using one, or changing the password, spends them all. A new mail goes out only while fewer than 3 links are live, at most one a minute and 10 an hour per address (past a rule the same 202, nothing sent).
{"email": …}POST /v1/auth/password/reset → 200
The reset link: a new password, every session revoked, every passkey removed, every API key of the person revoked, every MCP connection the person approved disconnected (the client connects again from its app; the link that sets a first password too; machines connected with lhbox login hold tokens and keep working), a notice mailed; returns a fresh session -- except for an account with two-factor on, which gets no session: it logs in with the new password AND a code.
{"token": …, "password": …}POST /v1/auth/password/change → 200
A logged-in human changes their password; every OTHER session is revoked; unless drop_keys is false, every API key of the person is revoked and every MCP connection the person approved disconnected; a notice is mailed.
{"current_password": …, "new_password": …, "drop_keys"?: true, "code"?: … | "passkey"?: <assertion for POST /v1/auth/passkeys/confirm/start {"purpose": "password_change"}> (two-factor on)}403 mcp_human_only): a person or an API key does thisPOST /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 names the machine (default the hostname); requested_level, warehouses and warehouse_ids are accepted from older CLIs and choose nothing since 2026-11-02 (a login acts as the person). Formerly: requested_level what it asked for (default read), warehouses which catalogs at which level ([{name | warehouse_id, level}], from `login --catalog NAME[:LEVEL]`; resolved within the approver's organisation when the code is read; default the organisation's default catalog at requested_level). warehouse_ids (ids or names, all at requested_level) is the older spelling. Unauthenticated (the machine has no credential yet): every field is the requester's claim, and the approval page says so; the code lives 15 minutes.
{"client": "cli", "hostname": …, "client_name": …, "requested_level": "read|write", "warehouses": [{"name": …, "level": …}]} (all optional)lhbox login [--no-wait]GET /v1/auth/device/pending → 200
The consent screen's content for a pending code (people only; nothing is decided by reading it): what the program calls itself and its machine (its claim; nothing verifies it), that approving lets it act as the caller (acts_as, key_days), whether an organisation's policy refuses it (allowed, refusal), `request` (where and when the login started) and `confirm_required` (the approval carries the code typed from the terminal). A claim code answers its own content instead.
?user_code=XXXX-XXXXaccount:read (an MCP access token; an API key has no scopes)POST /v1/auth/device/approve → 200
A person signed in on the account page (a password, Google, or their organisation's identity provider; within the last 12 hours) decides a code login (`lhbox login --no-browser`): yes or no. Approved, the machine gets, when it polls, the person's own key: it acts as them, as the browser login's does, for 30 days, mailed to them as a new key. A key limited to some catalogs is made on the website, never here; the older picker's fields (warehouses, level, warehouse_ids, agents) are ignored. The approval carries confirm_code, the code the person typed from their own terminal (a code that came with a link is not enough); deny: true refuses it and needs none. Only people: a token approving would hand out a key. A CLAIM code (`lhbox claim`, POST /v1/agent/identity/claim) takes claim: {into: <org_id the approver administers> | "new" (with handle)} and level instead, with a sign-in of the last 10 minutes: the catalog moves there, every pre-claim key ends, and the agent's poll gets a new key at that level.
{"user_code": …, "confirm_code": …, "deny": false}403 mcp_human_only): a person or an API key does thisPOST /v1/auth/cli/authorize → 200
`lhbox login` in the browser (RFC 8252: a loopback redirect with PKCE). The person, signed in on the account page within the last 12 hours (AL_CLI_LOGIN_FRESH_SECONDS; never an API key or a token), lets the CLI on this computer act as them: the answer is where the page sends the browser, the CLI's own 127.0.0.1 listener with a one-time code (5 minutes) and the state. deny: true sends it back with error=access_denied. Refused while an organisation of the person lets only its admins connect machines (connect_agents).
{"redirect_uri": "http://127.0.0.1:<port>/callback", "state": …, "code_challenge": …, "code_challenge_method": "S256", "client_name": …, "hostname": …, "deny": false}lhbox login403 mcp_human_only): a person or an API key does thisPOST /v1/auth/cli/token → 200
The CLI trades the code its listener received, with the PKCE verifier only it holds, for the person's own key: it acts as them (kind human), as on the website, until expires_at (30 days); shown once, and mailed to the person as a new key. The code works once.
{"code": …, "code_verifier": …, "redirect_uri": …}lhbox loginGET /v1/auth/device/status → 200
Where a CLI login stands, for the page that approved it: pending | approved | denied | used | expired, and `claimed` (the machine collected its key). Approving is not connecting -- the token is created when the machine polls -- so the page says "waiting for <name> to pick up its key" until this says claimed. A pending code is visible to any signed-in human (as the consent read is); a decided one only to the human who decided it. `expired` is an approval nobody collected within the code's 15 minutes.
?user_code=XXXX-XXXXaccount:read (an MCP access token; an API key has no scopes)POST /v1/auth/device/token → 200
The CLI polls until the person 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; never the first poll after the human decides), expired_token, access_denied. Once approved: the person's own key, labelled "cli <hostname>", shown once: it acts as them until expires_at (30 days), the same key as the browser login's. A key that cannot be made after the approval (an organisation's policy changed) is refused and the code denied.
{"device_code": …}lhbox login --no-browser | lhbox login --resume [--wait]GET /v1/setup → 200
A self-hosted install's first-run setup: whether it is open (no install administrator yet and a live setup token from the host), and how the first administrator will sign in. 404 on the managed service.
POST /v1/setup → 201
The install administrator and the first organisation of a self-hosted install, once, with the setup token `./al setup-link` printed on the host. With a password, the answer's enrol_token starts the authenticator enrolment (POST /v1/auth/mfa/enrol/start, /confirm), whose answer is the session; without one, the install's identity provider signs the person in. Audited install.setup.
{token, email, display_name, password?, organization: {name, handle?}}lhbox admin setup --token T --email E --name N --org O [--handle H] [--password-stdin | --no-password]Remote MCP: OAuth 2.1 authorization server
GET /.well-known/oauth-protected-resource → 200
RFC 9728 metadata of the REST API itself: every 401 of /v1/* names it in `WWW-Authenticate: Bearer resource_metadata=…`. Its authorization server, the scopes, bearer tokens in the header, the documentation; where agents may start without an account, agent_registration names POST /v1/agent/identity.
GET /.well-known/oauth-authorization-server → 200
RFC 8414 metadata an MCP host reads to authorise against the remote MCP server: issuer, authorization/token/revocation endpoints, jwks_uri, scopes_supported, code + PKCE S256 only, public clients and registered confidential ones (token_endpoint_auth_methods_supported: none, client_secret_basic, client_secret_post), Client ID Metadata Documents supported, iss on authorization responses, and the deprecated registration_endpoint while dynamic registration is on.
GET /.well-known/jwks.json → 200
The RS256 public key(s) that verify every token this API signs (kid on every key and every token; two keys during a rotation). The same document as GET /jwks.json, at the path the MCP server's token verifier uses.
GET /oauth/authorize → 200
The authorization endpoint. The MCP host opens the person's browser here; the client is identified by a Client ID Metadata Document (client_id = its https URL, fetched server-side behind an SSRF guard and cached) or a registered client_id. redirect_uri must be one the client lists (exact; loopback on any port), response_type=code, code_challenge + code_challenge_method=S256 and resource (the MCP server's URL) are required. A bad client or redirect_uri is a 400 (never a redirect); every other error is a 302 to the redirect_uri with error, error_description, state and iss. On success: 302 to the consent page (/app/authorize?request=<id>), where the logged-in person decides.
?client_id=<https URL or dcr_…>&redirect_uri=…&response_type=code&code_challenge=…&code_challenge_method=S256&resource=<MCP server URL>&scope=<space-separated>&state=…GET /v1/oauth/authorize/pending → 200
The consent page's content for a pending authorization (a signed-in human: a login session, or a sign-in through the organisation's own identity provider, which then sees that organisation only): who is asking (client_name, client_uri, logo_uri, the host of its metadata URL, how it registered), the scopes as sentences, the MCP server the token is for, the organisation and the catalogs the person may grant with the level proposed (capped by their own), and the token record already bound to this client if any (the picker's initial state), and decision_needs_reauth: how the sign-in must be renewed before an approval (identity_provider, or POST /v1/auth/reauth for a session; the refusal's code when no new sign-in helps), or null, with decision_refusal (that refusal's code, message, remedy). An identity provider's sign-in sends its ID token in X-Id-Token here too: Entra puts auth_time in it only. Nothing is decided by reading it.
?request_id=<from the consent page URL>account:read (an MCP access token; an API key has no scopes)POST /v1/oauth/authorize/decision → 200
The signed-in human allows or denies. Allowing needs a recent sign-in (a session's login or its POST /v1/auth/reauth, an identity provider's auth_time, within the re-authentication window; denying needs none), and a sign-in through an organisation's own identity provider decides for that organisation only. Allowing creates a TOKEN RECORD of kind mcp in the organisation -- or reuses the one bound to (you, client_id) and replaces its grants -- named after the client and you, holding exactly the grants chosen (each capped by your own level; a catalog you hold nothing on is refused), member role unless you are an admin and ask otherwise; then mints a single-use authorization code (10 minutes). The token appears in the organisation's tokens list and is revoked there, which ends its refresh and access tokens at once.
{"request_id": …, "allow": true, "grants": [{"warehouse_id": …, "level": "read|write"}], "org_id"?: …, "role"?: "member|admin"} or {"request_id": …, "allow": false}403 mcp_human_only): a person or an API key does thisGET /v1/oauth/authorize/set-up → 200
What the consent page shows a person who is not signed in: who is asking, and whether the deployment offers to let that client set up an account for them now, with that account's limits.
?request_id=…POST /v1/oauth/authorize/set-up → 200
"Let <client> set it up": for a person with no account. The client gets an account an agent made (the unclaimed organisation of POST /v1/agent/identity: one catalog with write, 1 GB, nothing public, deleted with its data after 72 hours unless a person keeps it), counted against the same limits from this browser's address; the pending authorization is approved with write on that catalog and the browser goes back to the client with its code; the token's scope includes catalogs:write whatever the client asked. Offered only where the write service runs (so the client can write and keep the account). The click accepts the Terms of Service and the Acceptable Use Policy. The person keeps the account later through the client (POST /v1/agent/identity/claim); keeping it ends this connection, and the client asks once to reconnect.
{"request_id": …, "accept_terms": true}POST /oauth/token → 200
The token endpoint (form-encoded or JSON; public clients send client_id, never a secret). grant_type=authorization_code with code, code_verifier (PKCE S256), client_id, redirect_uri and resource -> an RS256 access token for the MCP server (aud = resource, sub = the mcp token record, act.sub = the person, scope, client_id, kind mcp, 15 minutes) and a refresh token (30 days sliding). A code is single use; presenting it twice revokes the tokens it produced. grant_type=refresh_token with refresh_token and client_id -> a new pair, the refresh token rotated; presenting the rotated-out one again revokes the whole family. A narrower scope may be asked for on refresh.
grant_type=authorization_code&code=…&code_verifier=…&client_id=…&redirect_uri=…&resource=… or grant_type=refresh_token&refresh_token=…&client_id=…[&scope=…]POST /oauth/revoke → 200
RFC 7009: a client that logs out revokes its refresh token (or an access token, whose session is revoked: every access token of that session is then refused by the API and its refresh token is dead). 200 with {} whatever the token, as the RFC says. The person's own revocation is the tokens list on the account page.
token=…[&token_type_hint=refresh_token|access_token]POST /oauth/register → 201
RFC 7591 dynamic registration, the DEPRECATED fallback for hosts that cannot publish a Client ID Metadata Document. Redirect URIs: https, loopback http on any port, or a native app's private-use scheme. A client that asks for client_secret_basic or client_secret_post gets a client_secret, shown once, and sends it to the token endpoint (PKCE still required); any other client is public. Registrations unused for 90 days are pruned. A self-hosted deployment can turn it off.
{"client_name": …, "redirect_uris": […], "grant_types"?: [authorization_code, refresh_token], "response_types"?: [code], "token_endpoint_auth_method"?: "none|client_secret_basic|client_secret_post", "client_uri"?, "logo_uri"?, "application_type"?: "web|native", "software_id"?}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, the deployment's own; it names a location, never an infrastructure provider.
{"name": …, "provider"?, "region"?, "org_id"?} Header Idempotency-Key: any stringlhbox project create --name …catalogs:write (an MCP access token; an API key has no scopes)GET /v1/projects → 200
Projects you own or reach through an organisation you belong to.
lhbox project listcatalogs:read (an MCP access token; an API key has no scopes)GET /v1/projects/{project_id} → 200
One project you reach, as the list shows it.
catalogs:read (an MCP access token; an API key has no scopes)PATCH /v1/projects/{project_id} → 200
Rename a project. Its placement and organisation are fixed at creation.
{"name": …}403 mcp_human_only): a person or an API key does thisDELETE /v1/projects/{project_id} → 200
Delete an empty project. A deleted catalog still in its grace period keeps its project, since a restore puts it back there: purge it first.
403 mcp_human_only): a person or an API key does thisPOST /v1/warehouses → 201
A warehouse is one Iceberg catalog: one table bucket named <organisation handle>--<catalog name, _ mapped to -> (overture--data; u-<8 hex of the principal id>--<catalog> outside an organisation) plus one storage identity confined to it by the catalog. Also a blob bucket (<handle>--<catalog>--blobs) for non-Iceberg files. Catalogs created before 2026-09-20 keep w-<uuid> / b-<uuid>; the response and every row name the buckets as they are (bucket, blob_bucket). Bucket names are DNS names of at most 63 characters, so the catalog name is bounded per handle (400 name_too_long_for_bucket says the maximum) and may not end in _; catalog names are unique per organisation (docs/permissions-and-access.md, "Names").
{"project_id": <id or name>, "name": …, "default_format_version": 2} (2 default, or 3: the Iceberg format version LakehouseBox's own create paths -- POST /v1/tables without format_version, the MCP create_table tool, table import -- give new tables here; an engine's own CREATE TABLE is honoured as sent) Header Idempotency-Keylhbox catalog create <name> [--project …] [--default-format-version 2|3]catalogs:write (an MCP access token; an API key has no scopes)GET /v1/warehouses → 200
The catalogs you can see, ids and names side by side: every catalog of your organisation(s) and of the projects you own (a member token: exactly the catalogs it was granted), or those of one project with ?project=. Each row carries its project_id.
?project=<id or name> (optional)lhbox catalog list [--project …]catalogs:read (an MCP access token; an API key has no scopes)GET /v1/warehouses/{warehouse_id} → 200
One catalog by id or by name: the same row as the list, plus the bucket name and placement.
the path holds the warehouse_id or the catalog's namelhbox catalog get <name or id>catalogs:read (an MCP access token; an API key has no scopes)PATCH /v1/warehouses/{warehouse_id} → 200
Change a catalog's settings. default_format_version (2|3): the Iceberg format version LakehouseBox's OWN create paths give a new table -- POST /v1/tables without format_version, the MCP create_table tool, table import. An engine's own CREATE TABLE is honoured as sent, never upgraded; existing tables are unchanged. tier_policy (hot|cold, admin only). snapshot_retention_days (1-90, or null for the default; admin only): the catalog's time-travel window -- from the next maintenance run (every 6 hours) every snapshot younger than N days is kept (at most 500 per table, the newest) and older ones are expired, so a table can be read at, or rolled back to, any snapshot of the last N days; null is the default policy, at most 20 snapshots and none older than 7 days, whichever is tighter. A longer window cannot bring back snapshots already expired, and retained snapshots keep their data files, which count as storage. virtual_host (true|false, admin only): serve https://<bucket>.s3.<domain> for the catalog's two buckets (Snowflake needs that form, and an admin's request for its recipe turns it on); the TLS certificate it takes lists the bucket names in the public Certificate Transparency logs, which is why it is off by default, public catalogs included (they are read path-style). false refuses new and renewed certificates; the one already issued keeps working until it is due for renewal (within about 60 days). edge_reads (true|false; an admin of the organisation or the catalog's project manager): edge caching for a private catalog (/docs/edge-caching/): its readers' lhbox duckdb sessions then read through our European CDN; false clears its cache within the hour. Sending the value already set changes and audits nothing. Audited as warehouse.update (changes, previous).
{"default_format_version": 3} and/or {"tier_policy": "hot"} and/or {"snapshot_retention_days": 30} (the path holds the warehouse_id or the catalog's name)lhbox catalog update <name or id> --default-format-version 2|3 --tier hot|cold --snapshot-retention-days N|default --virtual-host on|off --edge-reads on|offcatalogs:write (an MCP access token; an API key has no scopes)POST /v1/warehouses/{warehouse_id}/measure → 200
Measure the catalog's storage NOW instead of waiting for the next reading: the store's own figure for its table bucket and its blob bucket (both as the row names them) -- the live bytes of each bucket, and its live stored chunks as the object count (approximately one per file) -- summed and stored as a usage sample (source: on_demand) that GET /v1/usage, GET /v1/warehouses and the account page show until the next reading. The control plane reads the same figure for every catalog every few minutes anyway (source: store); this is for right after a load or a delete. Iceberg metadata files, which the store keeps inline, are not counted. Quota enforcement (the bucket quota, the 409s) uses the same figure. Once a minute per catalog. Audited as warehouse.measure.
the path holds the warehouse_id or the catalog's name; no bodylhbox catalog measure <name or id>catalogs:write (an MCP access token; an API key has no scopes)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 token write via token). A level of none is a membership read an admin revoked for this warehouse. A holder of namespace-level grants carries them in `namespaces` and whether they apply in `namespaces_apply` (only while it holds nothing on the catalog); a token holding only namespaces is listed with level none.
lhbox catalog grants <name or id>catalogs:read (an MCP access token; an API key has no scopes)PUT /v1/warehouses/{warehouse_id}/grants/{principal_id} → 200
Grant read or write on a warehouse to a member or a token of the organisation. A read grant yields read-only catalog and storage credentials (t-<uuid>-ro); a write grant the read/write ones. With `namespace`: read or write on that one namespace only, for a principal holding nothing on the catalog, through its own storage identity which the catalog and the storage confine to its namespaces (admins only; an enterprise feature, off unless enabled for the organisation).
{"level": "read"|"write", "namespace"?: "<namespace>"}lhbox catalog grant <name or id> --principal … --level read|write [--namespace NS]403 mcp_human_only): a person or an API key does thisDELETE /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). With ?namespace=<ns>: that namespace grant only (admins); the last one deletes the principal's own storage identity.
lhbox catalog revoke <name or id> --principal … [--namespace NS] [--yes]403 mcp_human_only): a person or an API key does thisDELETE /v1/warehouses/{warehouse_id} → 202
Delete a catalog, with its name typed in the body. By default the delete starts a grace period (7 days on the service): before the 202 the catalog is gone for every caller and out of every list, its grants are removed, a public catalog's anonymous-read policy is removed, warehouse.delete is audited; right after, in the background, its key pairs, its catalog access and its uploaders are revoked. Its name stays reserved, its storage is kept and still counts against the organisation's storage (not against catalogs per project), and POST /v1/warehouses/{id}/restore puts it back until purge_after. After purge_after the next maintenance sweep (every 6 hours) purges it. Vended storage sessions already handed out run to their own expiry (about an hour). With "purge": true -- or on a catalog already in its grace period -- the deletion is immediate and irreversible: in the background (usually within a minute) tables dropped as the tenant, both buckets and the identities with their policies removed, then warehouse.delete_complete with the table count and every storage step, and the tombstone GET /v1/warehouses/{id} reports (404, state deleting -> deleted, the counts). The nightly backup copy ages out within its retention window (14 days).
{"confirm": "<the catalog name, exactly>", "purge": false}lhbox catalog delete <name> --confirm [--purge]403 mcp_human_only): a person or an API key does thisPOST /v1/warehouses/{warehouse_id}/restore → 200
Restore a catalog in its grace period, by id or by the name of a deleted catalog you could see: the same id, name, buckets, settings and tables, and its grants. If its credentials were already revoked it gets a new key pair (fetch a fresh recipe with lhbox connect). A public catalog comes back private; revoked uploaders stay revoked. Audited warehouse.restore.
lhbox catalog restore <id or name>403 mcp_human_only): a person or an API key does thisPOST /v1/warehouses/{warehouse_id}/rotate → 200
Replace both of the warehouse's catalog credentials (the read/write identity t-<uuid> and the read-only t-<uuid>-ro; the CLI prints the two new secrets as ******** unless --show-secrets). Each new key is issued before the old one is deleted, so there is never a moment without a working key. The old READ/WRITE key is deleted before the answer: it and every catalog token minted from it are refused at once. The old READ-ONLY key (it could only read) is deleted by the background worker right after the answer -- one IAM write, about 10 s at the host's current identity count, later only if a catalog deletion's teardown is queued ahead of it -- and audited as warehouse.rotate_complete. Every OTHER key of both identities (a key can mint more keys for its own identity at the store) is reaped by that same background job, which runs under the per-catalog key lock and keeps whatever the row names when it runs, so it can never delete a later rotation's key. Storage sessions already vended run out on their own within an hour. Why the split: every IAM write costs the store a propagation deadline.
lhbox catalog rotate <name or id> [--show-secrets] [--yes]403 mcp_human_only): a person or an API key does thisPOST /v1/warehouses/{warehouse_id}/uploaders → 201
A write-only credential for the catalog's blob bucket (<handle>--<catalog>--blobs; b-<uuid> on older catalogs; the answer's `bucket`), for a relay, a camera, a field device or a partner that only drops files: one IAM identity u-<warehouse_uuid>-<short> whose policy allows exactly s3:PutObject on arn:aws:s3:::<blob bucket>/<prefix>* (single and multipart uploads) and, only with list: true, s3:ListBucket on the blob bucket conditioned on s3:prefix <prefix>* (the device must list with ?prefix=). Nothing on the table bucket, no reads, no deletes, no catalog access (a catalog token minted from the pair is refused). The secret is in this answer once and is not stored, except sealed for an uploader given drop-box protocols (the drop box's relay signs as it; deleted at revocation). With protocols (ftp, ftps, sftp) the uploader also gets a login at the drop box (ftp.lakehousebox.com: FTP/FTPS on 21, passive 50000-50019; SFTP on 2222): username <handle>.<catalog>.<name>, a generated 24-character password shown once in `dropbox`, files of at most 256 MiB, relayed under the prefix within seconds; the plan's dropbox_uploaders counts them (10 by default). Cost: 2 IAM writes at the store (CreateUser, CreateAccessKey), plus the two statements merged into the blob bucket's policy (an S3 call); nothing is re-put later.
{"name": …, "prefix"?: "photos/2026/", "list"?: false, "expires_at"?: null, "protocols"?: ["ftps", "sftp"]}lhbox catalog uploader create <catalog> --name relay --prefix photos/ [--list] [--protocols ftps,sftp] (--save FILE | --show-key)403 mcp_human_only): a person or an API key does thisGET /v1/warehouses/{warehouse_id}/uploaders → 200
The catalog's uploaders without their secrets (never shown again); revoked ones only with ?include_revoked=true.
lhbox catalog uploader list <catalog> [--all]catalogs:read (an MCP access token; an API key has no scopes)DELETE /v1/warehouses/{warehouse_id}/uploaders/{uploader_id} → 200
Revoke an uploader (by id or name): its access key is deleted at the store FIRST, so every request signed with it is refused from then on (a multipart upload in flight cannot complete), then its statements come off the blob bucket's policy and its identity is deleted: 2 IAM writes before the answer (3 for an uploader made before 2026-09-21, whose inline policy is deleted too). Idempotent: a revoked uploader answers 200 with already_revoked and retries whatever step is still pending. A catalog's deletion removes its uploaders' identities too.
lhbox catalog uploader revoke <catalog> --uploader <id|name> [--yes]403 mcp_human_only): a person or an API key does thisPOST /v1/sinks → 201
An ingest sink for ONE existing, unpartitioned table: producers without an engine POST JSON arrays to its send route with the send-only key this answer shows once; the roller appends what is waiting to the table every roll_seconds (default 300, floor 60), or sooner when roll_bytes (32-64 MiB) or inactivity_seconds say so, stamping __ingest_ts. One sink per table. No IAM write and no catalog commit at creation: the first roll adds the optional __ingest_ts column and the metadata-retention properties. The send key is a hash on the sink row, never a principal: it can neither become a token nor hold a grant.
{"name": …, "table": "<ns>.<table>", "warehouse": "<id or name>", "roll_seconds"?: 300, "roll_bytes"?: 67108864, "inactivity_seconds"?: null, "device"?: false, "device_require"?: {"field": …, "equals": …}, "device_strip"?: […], "mapping"?: {"preset": "ecowitt"} | {…}}lhbox sink create --catalog <catalog> --table <ns>.<table> [--roll-seconds 300] [--roll-bytes N] [--inactivity-seconds N] (--save FILE | --show-key)403 mcp_human_only): a person or an API key does thisGET /v1/sinks → 200
The sinks of every catalog the caller holds read on (an admin of the organisation: every catalog), or of one with ?warehouse=, with lag, last roll and rejects; never a key. ?include_deleted=true adds the deleted ones.
lhbox sink list [--catalog <id or name>]catalogs:read (an MCP access token; an API key has no scopes)GET /v1/sinks/{sink_id} → 200
One sink by id or name: state, roll policy, lag (bytes and batches waiting, seconds behind), last roll (at, batches, rows, rejected_rows, snapshot_id, seconds), rejects counters and where the rejects files are, totals received.
lhbox sink get <sink id or name>catalogs:read (an MCP access token; an API key has no scopes)DELETE /v1/sinks/{sink_id} → 202
Delete a sink: its send key is refused from now on, batches still waiting are discarded (the roller purges the intake prefix on its next tick; rejects files age out after 7 days), the table is untouched. 202.
lhbox sink delete <sink id or name> [--force] [--yes]403 mcp_human_only): a person or an API key does thisPOST /v1/sinks/{sink_id} → 202
The send route. Body: a JSON array of objects (<= 16 MiB); header Idempotency-Key: the batch id (1-128 chars of A-Z a-z 0-9 . _ : -). Each element's bytes are stored unchanged, one per line, as ONE NDJSON object under _sink/<sink_id>/<batch_id>.ndjson in the catalog's blob bucket, written as the catalog's own identity: no IAM write, no catalog call, one small S3 PUT. The same batch id again answers 200 duplicate without writing. Elements that are not objects are rejected here (reasons[]); whether a row fits the table's schema is decided at the roll and shows in GET /v1/sinks/{id} rejects. Intake bytes are metered as ingest_bytes (GET /v1/usage), not as storage.
[ {…}, {…}, … ] with headers Authorization: Bearer <send_key>, Idempotency-Key: <batch id>, Content-Type: application/jsonlhbox sink send <sink id> batch.json [--batch-id <id>]POST /d/{token} → 200
A sink's device route, for devices that cannot send a JSON array with Authorization and Idempotency-Key headers (a weather gateway posting a form over plain HTTP, a webhook posting one object). Body: form-urlencoded, one JSON object, a JSON array, NDJSON or CSV with a header, at most 256 KiB. The batch id is the Idempotency-Key when sent, else derived from the body, so a retried post answers 200 duplicate. A pinned body secret (device_require) is checked and, with device_strip, removed before anything is stored. Then the send route's store step (quota gate, one PUT of intake NDJSON). ?dry=1 answers the records it would store.
the device's body, any of the formats abovePOST /v1/sinks/{sink_id}/test → 200
A dry run of the device route with your own credential: the body (any format POST /d/<token> takes, <= 256 KiB) is parsed, stripped and mapped exactly as the device route would, and nothing is stored or metered. The pinned secret is reported (device_secret: ok | mismatch | null), not required.
the device's body, with its Content-Typelhbox sink test <sink> <file> [--content-type TYPE]catalogs:read (an MCP access token; an API key has no scopes)POST /v1/sinks/{sink_id}/device-token → 201
Give a sink a device token, or replace the one it has (the old one is refused from this answer on). Shown once; its SHA-256 is kept. POST /v1/sinks takes "device": true to make one at creation.
403 mcp_human_only): a person or an API key does thisDELETE /v1/sinks/{sink_id}/device-token → 200
Remove a sink's device route: the token is refused from now on; the send key and the sink go on.
403 mcp_human_only): a person or an API key does thisPATCH /v1/sinks/{sink_id} → 200
Change what a sink's device route checks and strips: device_require ({field, equals} or {field, equals_sha256}; null clears) and device_strip (field names; [] clears); the plain value is hashed at once and never stored. And its mapping: a declarative document (drop, rename, select, constants, timestamp, melt with a closed list of unit conversions) or {"preset": "ecowitt"}, validated against the table's current schema and applied by the roller to every batch not yet rolled (applies_to_pending_batches); null removes it. POST /v1/sinks takes the same mapping at creation.
{"device_require"?: {"field": "PASSKEY", "equals": "…"} | null, "device_strip"?: ["model"], "mapping"?: {"preset": "ecowitt"} | {…} | null}403 mcp_human_only): a person or an API key does thisPOST /v1/indexes → 201
An index rule: every object uploaded under a prefix of the catalog's blob bucket becomes one row of an existing table, its columns read by extractors from a closed list (basic: key, name, size, etag, content type, times, uploader; image: width, height, EXIF time; filename_time: a time from the file name in a named zone; path_parts: named groups of the key; constant). The rule owns an internal sink on the table, so the rows are committed by the roller like any sink's; found within poll_seconds (default 60) and committed at the internal sink's next roll (roll_seconds, default 300). Append-only. Zero IAM writes; the plan's blob_indexes limits how many.
{"name": …, "warehouse": …, "prefix": "camera/", "table": "<ns>.<table>", "extractors": ["basic", "image", …], "rename"?: {"last_modified": "uploaded_at"}, "poll_seconds"?: 60}lhbox index create --catalog <catalog> --name <name> --prefix <prefix> --table <ns>.<table> --extractors '<json>' [--rename a=b]403 mcp_human_only): a person or an API key does thisGET /v1/indexes → 200
The index rules of every catalog the caller holds read on (an admin of the organisation: every catalog), or of one with ?warehouse=.
lhbox index listcatalogs:read (an MCP access token; an API key has no scopes)GET /v1/indexes/{index_id} → 200
One index rule by id or name: what it watches, its last poll, what it indexed, its internal sink's lag.
lhbox index get <index>catalogs:read (an MCP access token; an API key has no scopes)DELETE /v1/indexes/{index_id} → 202
Stop indexing: the rule and its internal sink are deleted; the table, its rows and the uploaded objects stay.
lhbox index delete <index> [--yes]403 mcp_human_only): a person or an API key does thisPUT /v1/warehouses/{warehouse_id}/lifecycle → 200
Set the S3 lifecycle rules of the catalog's BLOB bucket (never the table bucket), replacing what was configured: each rule expires the objects under a prefix after N days (24 h each from the object's last write) or on a date, and/or aborts multipart uploads not completed after N days. The control plane writes the S3 LifecycleConfiguration XML and PUTs it as the tenant (the blob-bucket policy written at creation grants exactly that), then reads it back. The store's lifecycle worker passes daily and after every restart: deletion up to 24 h after an object is due, permanent; the storage meter follows at its next reading (every few minutes) or POST …/measure. Refused before the store sees it: expire_days 0 (the store accepts it and never fires it), any transition (no storage classes: 501 at the store), unknown fields, more than 1000 rules or 1 MiB. Tag and size filters are boto3's with the catalog credential; GET parses them back.
{"rules": [{"id"?: "camera-90d", "prefix"?: "camera/", "expire_days"?: 90, "expire_date"?: "2026-12-31", "abort_incomplete_multipart_days"?: 7, "enabled"?: true}, …]}lhbox catalog lifecycle set <catalog> --prefix camera/ --expire-days 90 [--abort-multipart-days 7]catalogs:write (an MCP access token; an API key has no scopes)GET /v1/warehouses/{warehouse_id}/lifecycle → 200
The lifecycle rules configured on the catalog's blob bucket, read from the store as the tenant and parsed back -- rules set with boto3 included: tags, size_greater_than / size_less_than and `other` (a Disabled Transition, NoncurrentVersionExpiration) appear on the rule and `expressible` says whether PUT here could re-create it. configured: false with an empty list when none is set. ?include_xml=true adds the stored document verbatim.
lhbox catalog lifecycle get <catalog> [--xml]catalogs:read (an MCP access token; an API key has no scopes)GET /v1/warehouses/{warehouse_id}/blobs → 200
One page of the catalog's BLOB bucket, listed as the catalog's own identity (ListObjectsV2, never the admin key): objects {key, name, size, last_modified, etag} and, with a delimiter (default `/`), the common prefixes as folders {prefix, name} -- the sink's `_sink/` among them. ?prefix= descends; ?delimiter= (empty for a flat listing); ?token= continues a truncated page; ?max_keys= 1..1000 (clamped). ?urls=1 adds a presigned GET `url` (900 s) to every object, so the account page's Files section shows thumbnails without a request per file. Bytes never pass through the API: the browser loads them from the store. Uploader keys are S3 credentials, not API credentials: they cannot call this.
?prefix=&delimiter=/&token=&max_keys=1000&urls=0lhbox file ls [prefix] --catalog <catalog>catalogs:read (an MCP access token; an API key has no scopes)GET /v1/warehouses/{warehouse_id}/blobs/presign → 200
One presigned GET URL for an object of the catalog's blob bucket: AWS SigV4 query auth signed with the catalog's own key pair, on the store's public endpoint (path-style, `https://s3.<host>/<bucket>/<key>`), valid ?expires= seconds (900 by default, 30..3600, clamped). ?download=1 adds response-content-disposition=attachment (signed) so the store hands the file out under its name. The URL is bound to GET, this key and the expiry; the store verifies it, the API never sees the bytes. The key is not checked for existence: the store answers 404 to whoever follows the link.
?key=<object key>&expires=900&download=0lhbox file get <key> --catalog <catalog>catalogs:read (an MCP access token; an API key has no scopes)POST /v1/warehouses/{warehouse_id}/blobs/upload → 200
One presigned PUT URL for a key of the catalog's file bucket: AWS SigV4 query auth signed with the catalog's own key pair, on the store's public endpoint (path-style), valid 900 s, bound to PUT, this key and (when given) this Content-Type and the owner's headers: cache_control (Cache-Control), content_disposition, content_language and metadata (each name sent as x-amz-meta-<name>), which the file keeps and the CDN of published files passes on. Values are printable ASCII on one line (no CR/LF), at most 512 characters; Cache-Control takes directive characters only; a file name in content_disposition must be a type the catalog's files accept; metadata at most 20 names and 2 KB. content_encoding and expires are refused (publish compressed data as .csv.gz; the CDN drops Expires). The caller PUTs the bytes to the store with exactly the returned headers; the API never sees them. A key ending in `/` with an empty body is a folder marker. The store enforces the storage limit and the accepted file types on the PUT itself (415 UnsupportedFileType, 403 over the limit). The platform's places are refused: every root key starting with `_` (feed intake, indexes, probe files) and the root `AGENTS.md` (its own route). The account page's Files section uses it for Upload and New folder. A file over 5 GB (or any large one) goes up in parts: POST …/blobs/multipart.
{"key": "public/tiles.pmtiles", "content_type": "application/vnd.pmtiles", "cache_control": "public, max-age=86400", "content_disposition": "attachment; filename=\"tiles.pmtiles\"", "content_language": "en", "metadata": {"source": "ign"}} (all but key optional)lhbox file put <path> [key] --catalog <catalog> --cache-control403 mcp_human_only): a person or an API key does thisPOST /v1/warehouses/{warehouse_id}/blobs/multipart → 200
Start a multipart upload of one key of the catalog's file bucket, for a large file: the key follows /blobs/upload's rules and must be a file type the file bucket accepts; size (bytes, at most 5 TiB) must fit the storage limit's headroom. content_type and the owner's headers (as for /blobs/upload) are stored with the upload, so the file carries them. The upload is started as the catalog's own identity. Answers upload_id, upload_token (the declared size, sealed: the complete wants it back and refuses parts that add up to more) and a plan: part_size (64 MiB, more for a file that would need over 10,000 parts) and part_count. Then: part URLs (…/multipart/parts), a PUT per part, …/multipart/complete; …/multipart/abort on failure. An open upload keeps its parts' bytes until it is aborted; a lifecycle rule with abort_incomplete_multipart_days clears forgotten ones. Audited.
{"key": "tiles/world.pmtiles", "size": 2147483648, "content_type": "application/vnd.pmtiles", "cache_control": "public, max-age=86400"}lhbox file put <path> [key] --catalog <catalog> --part-size403 mcp_human_only): a person or an API key does thisPOST /v1/warehouses/{warehouse_id}/blobs/multipart/parts → 200
Presigned PUT URLs for parts of an open multipart upload, 1 to 100 a call (part numbers 1..10000): each signed with the catalog's own key pair for PUT, this key, this upload and this part number, valid 900 s. PUT the part's bytes to its URL with no other header (every part but the last at least 5 MiB) and keep the answer's ETag. Ask for URLs as you go rather than all at once. Refused while the catalog's scope is over its storage limit.
{"key": "tiles/world.pmtiles", "upload_id": "…", "part_numbers": [1, 2, 3, 4]}lhbox file put <path> --parallel403 mcp_human_only): a person or an API key does thisPOST /v1/warehouses/{warehouse_id}/blobs/multipart/complete → 200
Complete a multipart upload from the parts the store holds for it, with the create's upload_token. `parts` (each part's number and the ETag its PUT answered) is optional but recommended: it must name the same part numbers the store holds (409 parts_mismatch otherwise, nothing aborted), and it chooses between two copies of a part sent twice (without it, 409 parts_ambiguous). Every part but the last is at least 5 MiB. Parts adding up to more than the declared size abort the upload (409 size_exceeds_declared). The catalog is measured again (at most every 30 s): over the storage limit, the upload is aborted and its parts discarded (409 quota_exceeded, aborted: true). A completion the store does not answer in time is 504 completion_unknown: list the key to see whether it completed. Audited (catalog.blob_multipart_complete).
{"key": "tiles/world.pmtiles", "upload_id": "…", "upload_token": "…", "parts": [{"part_number": 1, "etag": "…"}]} (parts optional)lhbox file put <path>403 mcp_human_only): a person or an API key does thisPOST /v1/warehouses/{warehouse_id}/blobs/multipart/abort → 200
Abort a multipart upload and discard its parts. Idempotent: an upload already completed or aborted answers aborted with was_open false. Audited (catalog.blob_multipart_abort).
{"key": "tiles/world.pmtiles", "upload_id": "…"}lhbox file put <path>403 mcp_human_only): a person or an API key does thisDELETE /v1/warehouses/{warehouse_id}/blobs → 200
Delete one object of the catalog's file bucket (?key=), as the catalog's own identity. One key, never a prefix: a folder goes when its last file and its marker do. Idempotent: a missing key answers as deleted. The platform's places are refused as for uploads. Audited (catalog.blob_delete).
?key=<object key>lhbox file rm <key> --catalog <catalog> --yes403 mcp_human_only): a person or an API key does thisGET /v1/warehouses/{warehouse_id}/agents-md → 200
The catalog's AGENTS.md: the object `AGENTS.md` at the root of its file bucket, the catalog's context for agents. Read as the catalog's own identity with one ranged GET: at most 64 KB of text (`truncated` beyond, and then no `sha256`). No AGENTS.md is `present: false`, never an error, and a read never creates the file bucket. `source` and `trust` label the text as the catalog writers' words: context about the data, not instructions. `public_url` while the catalog is public. ?text=false: presence, size and last change only (one HEAD; no text, no sha256).
?text=truelhbox catalog agents-md <catalog>catalogs:read (an MCP access token; an API key has no scopes)PUT /v1/warehouses/{warehouse_id}/agents-md → 200
Write the catalog's AGENTS.md: UTF-8, at most 64 KB, no NUL, stored as text/markdown at the root of the file bucket (made now if the catalog has none yet). `base_sha256` guards against a lost update: absent overwrites, a hash writes only over exactly that content, null writes only when there is none yet. While the catalog is public the file is readable anonymously at once (`public_statement`: ok, private, or the error of the statement's write, which never fails the request). Audited as catalog.agents_md_update. Writing the object straight to the bucket (aws s3 cp, boto3) also works and is not audited.
{"text": "<markdown>", "base_sha256": "<sha256 you edited>" | null}lhbox catalog agents-md <catalog> --set FILE|-catalogs:write (an MCP access token; an API key has no scopes)DELETE /v1/warehouses/{warehouse_id}/agents-md → 200
Remove the catalog's AGENTS.md. Idempotent. Audited as catalog.agents_md_delete.
lhbox catalog agents-md <catalog> --removecatalogs:write (an MCP access token; an API key has no scopes)DELETE /v1/warehouses/{warehouse_id}/lifecycle → 200
Remove every lifecycle rule from the catalog's blob bucket (DELETE ?lifecycle as the tenant). Idempotent: 200 with had_rules false when none was configured. Objects a rule already deleted are gone.
lhbox catalog lifecycle clear <catalog> [--yes]catalogs:write (an MCP access token; an API key has no scopes)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. It also creates the catalog's public-reader identity, so any account can attach the catalog read-only by name (GET /v1/public/{handle}/{catalog}/connection), and anyone with no account attaches it read-only by name through the public catalog (GET /public-catalog/v1/config?warehouse=<handle>/<catalog>, which hands out no credential). Its bytes move to the public tier (5 GB; an operator raises a reviewed organisation to 50 GB); 409 quota_exceeded (tier public) when that would overflow. The catalog's file bucket stays private unless `files` names ONE folder of it: anyone may then GET (range requests included) and list the files under that folder, and nothing else of the file bucket. A new folder replaces the old one; `"files": null` stops publishing files and keeps the tables public; no `files` key changes nothing.
{"confirm": "<the warehouse name, exactly>", "files": "<optional: one folder of the file bucket, e.g. public/; null to stop>"}lhbox catalog publish <name> --confirm [--files <prefix>/ | --no-files]403 mcp_human_only): a person or an API key does thisDELETE /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, the published folder of the file bucket included; bytes return to the private tier.
lhbox catalog unpublish <name>403 mcp_human_only): a person or an API key does thisGET /v1/public/{handle}/{catalog}/connection → 200
Attach a PUBLIC catalog of any organisation by name, read-only: any account gets the catalog's public-reader key (catalog read only: no writes, no other catalog, no private files; shared by every reader, replaced when the owner unpublishes) and a DuckDB recipe that attaches every table in one statement. A catalog that is not public answers exactly as one that does not exist.
lhbox duckdb --catalog <handle>/<catalog>; lhbox connect --catalog <handle>/<catalog>catalogs:read (an MCP access token; an API key has no scopes)POST /v1/sql/preview → 200
Run a scheduled-SQL query once and write nothing: rows, row count, Iceberg columns, the schema and format version create_table would make (format_version, else the catalog's default; a geometry under 2 is listed in problems), and whether the rows fit a table. Works without the scheduled-SQL feature and for an unclaimed organisation's key (a smaller budget, one at a time, sql_previews_per_hour; `sink_create` says what creating the sink still needs).
403 mcp_human_only): a person or an API key does thisPOST /v1/sinks/{sink_id}/run → 202
Queue one run of a scheduled SQL sink now, outside its schedule.
403 mcp_human_only): a person or an API key does thisPOST /v1/sinks/{sink_id}/dedupe → 202
Keep only the latest row per key in a keyed sink's whole table, once: the roller's next tick does it as one commit.
lhbox sink dedupe SINK [--wait]403 mcp_human_only): a person or an API key does thisPOST /v1/sql/connections → 201
A named, write-only credential for scheduled SQL (bearer, custom header or basic), bound to one https URL prefix of one catalog: the executor sends it only to URLs under that prefix. No route answers the value.
lhbox sink connection create NAME --catalog C --url-prefix URL --bearer|--header NAME|--basic --value-env VAR|--value @FILE403 mcp_human_only): a person or an API key does thisGET /v1/sql/connections → 200
A catalog's connections: name, URL prefix, kind, header name, dates. Never a value.
lhbox sink connection list --catalog Caccount:read (an MCP access token; an API key has no scopes)GET /v1/sql/connections/{name} → 200
One of a catalog's connections by name (?warehouse=), as the list shows it. Never its value.
account:read (an MCP access token; an API key has no scopes)PUT /v1/sql/connections/{name} → 200
Replace a connection's value (a rotated key); the next preview or run uses it.
lhbox sink connection replace NAME --catalog C --value-env VAR|--value @FILE403 mcp_human_only): a person or an API key does thisDELETE /v1/sql/connections/{name} → 200
Delete a connection; the catalog's SQL sinks whose query names a URL under its prefix are paused (paused_reason connection_deleted).
lhbox sink connection delete NAME --catalog C --yes403 mcp_human_only): a person or an API key does thisGET /v1/sinks/{sink_id}/runs → 200
A scheduled SQL sink's runs: state, timings, rows, egress counters, errors, commits. Read level on the sink's catalog (a sink of a catalog you cannot read is 404 sink_not_found).
catalogs:read (an MCP access token; an API key has no scopes)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.
{"warehouse_id": <id or name>, "namespace": …, "name": …, "schema": <Iceberg struct schema JSON>, "format_version": 3, "properties": {"write.target-file-size-bytes": "268435456", …}} (format_version optional: 2 or 3; WITHOUT it the catalog's default_format_version applies, 2 unless changed with PATCH /v1/warehouses/{id}; geometry/geography columns need 3; GET /v1/config/formats says which engines write each. properties optional: the table's own Iceberg properties, the rules of PATCH /v1/table; format-version is not one of them. Every unpartitioned table gets write.target-file-size-bytes = 134217728 (128 MiB) and write.parquet.row-group-limit = 500000 (the layout of /docs/performance/), each unless the body sets its own value) Header Idempotency-Keylhbox table create <catalog>.<namespace>.<table> --column name:type …catalogs:write (an MCP access token; an API key has no scopes)GET /v1/namespaces → 200
Namespaces in a catalog, read from the catalog service.
?warehouse=<id or name> (warehouse or warehouse_id: both spellings name the catalog, by id or by name, on every route below)lhbox namespace list --catalog …catalogs:read (an MCP access token; an API key has no scopes)GET /v1/tables → 200
Every table in a catalog, or in one namespace. detail=true adds each table's row_count, size_bytes, last_updated (the current snapshot's commit) and doc, read from its metadata and current snapshot summary (no data file is read; null where a summary lacks them). search= keeps the tables whose name, column names or column docs contain every word (a word one typo from a column name matches too), best first, and implies detail. At most 200 tables' metadata are read per call (note says how many were not).
?warehouse=<id or name>&namespace=<optional>&detail=true&search=<words> (or warehouse_id=)lhbox table list --catalog … [--namespace …]catalogs:read (an MCP access token; an API key has no scopes)GET /v1/public/{handle}/{catalog_name} → 200
A PUBLIC catalog's page without an account (PLAN §10.1): handle, name, catalog (handle/name), bucket, public_since, public_url, tables (each: namespace, name, columns with type and doc, row_count, last_updated, root_url, metadata_url) and recipes, a map of named entries (recipes.duckdb: no account and no credentials, one view per table; an entry may say what it requires). Only what the public bucket already exposes. A private catalog, an unknown handle or name, or a catalog that is not live answers the same 404 public_catalog_not_found. The listing is cached 60 s per catalog (one fill at a time; unpublishing takes effect at once); 30 requests a minute per address (burst 10), past it 429 rate_limited with Retry-After; a catalog that cannot be read answers 503 temporarily_unavailable.
(no body, no Authorization)GET /public-catalog/v1/config → 200
The client's first call, ?warehouse=<handle>/<catalog>: answers the prefix of every later path, <handle>.<catalog>, and the read endpoints it serves (no write exists). 400 without a name. The public catalog: a read-only Iceberg REST catalog of every PUBLIC catalog, for engines attaching one by name with no account, served through https://public-catalog.lakehousebox.com: ATTACH '<handle>/<catalog>' (TYPE iceberg, ENDPOINT 'https://public-catalog.lakehousebox.com', AUTHORIZATION_TYPE 'none'). Iceberg's error model; anything that is not a live public catalog answers the same 404; answers cached 30 s at the edge; past 600 store reads a minute per address, 429.
(no Authorization)GET /public-catalog/v1/{prefix}/namespaces → 200
The catalog's namespaces. The public catalog: a read-only Iceberg REST catalog of every PUBLIC catalog, for engines attaching one by name with no account, served through https://public-catalog.lakehousebox.com: ATTACH '<handle>/<catalog>' (TYPE iceberg, ENDPOINT 'https://public-catalog.lakehousebox.com', AUTHORIZATION_TYPE 'none'). Iceberg's error model; anything that is not a live public catalog answers the same 404; answers cached 30 s at the edge; past 600 store reads a minute per address, 429.
(no Authorization)GET /public-catalog/v1/{prefix}/namespaces/{namespace} → 200
One namespace: its name (its properties are the owner's and are not passed on). HEAD answers whether it exists. The public catalog: a read-only Iceberg REST catalog of every PUBLIC catalog, for engines attaching one by name with no account, served through https://public-catalog.lakehousebox.com: ATTACH '<handle>/<catalog>' (TYPE iceberg, ENDPOINT 'https://public-catalog.lakehousebox.com', AUTHORIZATION_TYPE 'none'). Iceberg's error model; anything that is not a live public catalog answers the same 404; answers cached 30 s at the edge; past 600 store reads a minute per address, 429.
(no Authorization)GET /public-catalog/v1/{prefix}/namespaces/{namespace}/tables → 200
The namespace's tables. The public catalog: a read-only Iceberg REST catalog of every PUBLIC catalog, for engines attaching one by name with no account, served through https://public-catalog.lakehousebox.com: ATTACH '<handle>/<catalog>' (TYPE iceberg, ENDPOINT 'https://public-catalog.lakehousebox.com', AUTHORIZATION_TYPE 'none'). Iceberg's error model; anything that is not a live public catalog answers the same 404; answers cached 30 s at the edge; past 600 store reads a minute per address, 429.
(no Authorization)GET /public-catalog/v1/{prefix}/namespaces/{namespace}/tables/{table} → 200
One table: metadata-location, metadata, and a config naming where to read its files with no key (the public-data edge; empty keys, so a client never signs). Nothing else the store answers. HEAD answers whether it exists. The public catalog: a read-only Iceberg REST catalog of every PUBLIC catalog, for engines attaching one by name with no account, served through https://public-catalog.lakehousebox.com: ATTACH '<handle>/<catalog>' (TYPE iceberg, ENDPOINT 'https://public-catalog.lakehousebox.com', AUTHORIZATION_TYPE 'none'). Iceberg's error model; anything that is not a live public catalog answers the same 404; answers cached 30 s at the edge; past 600 store reads a minute per address, 429.
(no Authorization)GET /v1/table → 200
Schema (with each column's doc), the real format version, sort_order, doc, last_updated, size_bytes, current_snapshot_summary and counts, read from the table's current metadata; snapshots=N adds snapshot_log, the last N commits newest first (committed_at, operation, added_records, deleted_records, total_records): the ingestion cadence and its gaps. rows is NET of delete files: the snapshot summary's total-records minus its position and equality delete counts when the writer left them, else counted from the manifest list and the delete manifests (rows_source says which path). Position deletes remove one row each, so the net is exact; with equality deletes (predicates) rows_is_exact is false and rows_note says why. identifier_field_ids are not a primary key: the catalog accepts duplicate rows.
?warehouse=<id or name>&namespace=…&name=…&snapshots=<0..50> (or warehouse_id=)lhbox table get <catalog>.<namespace>.<table>catalogs:read (an MCP access token; an API key has no scopes)PATCH /v1/table → 200
Set and/or remove table properties, and describe the table and its columns: ONE commit through the catalog (Iceberg REST commitTable with set-properties / remove-properties, and add-schema / set-current-schema for column descriptions, as the catalog's own identity), visible to every engine on its next load. `doc` is the table's description (its `comment` property); `column_docs` gives top-level columns theirs (each column's Iceberg `doc`): `describe_table` and `GET /v1/table` show both, and `list_tables` search matches them. Write holders only; audited table.update with the previous values. `lhbox table import` uses it to keep a GeoParquet's `geo` metadata as geo.encoding, geo.crs, geo.columns, geo.primary_column (the convention in docs/engines). The format version is not a property this route changes.
{"warehouse_id": <id or name>, "namespace": …, "name": …, "properties": {"geo.crs": "EPSG:4326", …}, "remove": ["key", …], "doc": "One row per …", "column_docs": {"ts": "When …", …}} (any of properties, remove, doc, column_docs; property values are strings -- numbers and booleans are stringified, nested objects refused; keys 1-128 chars of letters, digits, ".", "_", "-"; a description is at most 4000 characters, null removes it; at most 200 columns a request)lhbox table set-properties <catalog>.<namespace>.<table> --property key=value … [--remove key …]; lhbox table document <catalog>.<namespace>.<table> --doc TEXT [--column name=TEXT …] [--from FILE]catalogs:write (an MCP access token; an API key has no scopes)DELETE /v1/table → 200
Drop ONE table through the catalog (its DROP with purgeRequested=true, as the catalog's own identity): its data and metadata files go, the storage figure follows at the next reading. Write holders only; refused while a sink or a blob index rule writes to the table; in a PUBLIC catalog also force: true from an admin of the organisation, since its public URL stops serving the table. Audited table.delete with status dropped|failed. Cannot be undone; the CLI wants --catalog named, and asks unless --yes (never without a terminal or with --json).
{"warehouse_id": <id or name>, "namespace": …, "name": …, "force": true (public catalogs only)}lhbox table delete <namespace>.<table> --catalog <name> [--yes] [--force]catalogs:write (an MCP access token; an API key has no scopes)DELETE /v1/namespace → 200
Drop ONE EMPTY namespace through the catalog. A namespace that still holds tables or views is refused with them named: nothing is cascaded, drop each first. Refused while a sink targets a table of it. Write holders only; in a PUBLIC catalog also force: true from an admin. Audited namespace.delete with status dropped|failed.
{"warehouse_id": <id or name>, "namespace": …, "force": true (public catalogs only)}lhbox namespace delete <namespace> --catalog <name> [--yes] [--force]catalogs:write (an MCP access token; an API key has no scopes)POST /v1/credentials → 200
Short-lived storage credentials scoped to ONE table's object prefix, vended by the catalog. For direct S3 access from a script; engines that load tables through the catalog obtain these themselves. A write holder gets a read/write credential (the catalog's read/write identity); a read holder gets a read-only one (its read-only identity): GetObject on that table's prefix, ListBucket of that table's path and GetBucketLocation; no write, and no other table's files (a listing of the path without its trailing slash can also name the files of a table whose name begins with this one's, not yet closed). The lifetime is fixed at one hour; a credential stops working at once when the key pair it was issued from is rotated (then ask again).
{"warehouse_id": <id or name>, "namespace": …, "name": …} ("warehouse" is accepted for the same field)lhbox credentials <catalog>.<namespace>.<table>catalogs:write (an MCP access token; an API key has no scopes)GET /v1/object → 200
One file of a catalog's blob bucket (a photo whose key an index table holds): an image (JPEG, PNG, WebP, GIF) comes back downscaled to max_dimension on its long edge as JPEG, a text file of 64 KB or less as text, anything else as metadata; every answer carries a 15-minute download_url. Read level on the catalog; nothing outside its blob bucket. The bytes are fetched and decoded on the query host in a throwaway process (an image past 60 megapixels or a corrupt file comes back as metadata, not decoded; photos are turned upright from their EXIF orientation, transparency lands on white). An MCP access token needs catalogs:read.
?catalog=<name or id>&key=<object key>&max_dimension=<64..4096, default 1500>catalogs:read (an MCP access token; an API key has no scopes)POST /v1/query → 200
Runs one read statement (SELECT, WITH … SELECT, DESCRIBE, SUMMARIZE, EXPLAIN) in DuckDB on our side, over the catalogs you can read, and returns the rows: the path for hosts that cannot run DuckDB against LakehouseBox (no shell, or a sandbox that blocks the network). Read-only three ways (the parser, a READ_ONLY attach, the catalogs' read-only identities). No quota: 20 s per query in one queue every tenant shares, organisations taking turns, with queries_per_org running and queries_per_principal in flight at once, and query_requests_per_second per key (GET /v1/usage lists them); right after a busy, the same key is answered busy by the API for up to 2 s (answered_by: api). For anything heavier, run your own DuckDB (lhbox duckdb). An MCP access token needs catalogs:read. A repeat of a query on tables nobody has committed to since is answered from a result cache without running it or waiting in the queue (at most 15 minutes; 60 s when it uses the clock or chance; system views and table functions always run); cache: false runs it anyway. dry_run: true runs nothing and says what it would read, from each table's metadata.
{"sql": "SELECT … FROM catalog.namespace.table …", "max_rows"?: 200, "cache"?: true, "dry_run"?: false}lhbox query "SELECT …" [--max-rows N] [--dry-run]catalogs:read (an MCP access token; an API key has no scopes)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-<uuid> (reads and writes), read holders get t-<uuid>-ro (the catalog refuses its commits, the store its PUT/DELETE). With ?level=read a write holder gets the read-only identity's recipe too (created on first need): the one to hand a colleague who should read the catalog, or to attach in a session that must not write -- the account page's Catalog explorer attaches every catalog this way, and takes a pasted read-only recipe under 'Add catalog'. The snowflake recipe is the read-only identity (static keys, ALLOW_WRITES = FALSE) unless a write holder asks for level=write: then the read/write identity, ALLOW_WRITES = TRUE and a catalog-linked database. LakehouseBox is never in the query path: the engine talks to the catalog and the object store directly. The API returns the pair; the CLI prints the secret as ******** wherever the recipe repeats it unless --show-secrets (a masked DuckDB recipe starts with an error() that says so), and the sample code reads it from the variables credential_env names. An MCP access token never gets the static pair (it would outlive the connection; a public catalog's shared reader key at GET /v1/public/{handle}/{catalog}/connection is public by design): its answer is credentials: vended_only with level, catalog_uri and, for a write holder, vended_credentials naming POST /v1/credentials (about an hour, one table). A caller holding namespace-level grants and nothing on the catalog gets its own identity's recipe (duckdb and pyiceberg only), level_via namespace, and namespaces[{namespace, level, tables}]: the catalog does not list namespaces to it, so the recipe names them; level=read with a write namespace grant answers 409 read_recipe_unavailable. A catalog-level grant (PUT .../grants with a level) ends the principal's namespace grants on the catalog (namespace_grants_ended). A recipe whose key pair writes, asked with a browser session or an identity provider's sign-in, needs a recent sign-in (the session's login or POST /v1/auth/reauth within the last 10 minutes; the provider's auth_time): 401 reauth_required otherwise, its reauth field saying how. The read-only recipe and an API key are not asked, nor an account without a password (Google sign-in only) or a provider that sends no auth_time.
?warehouse_id=<id or name>&engine=duckdb&level=read (or warehouse=; duckdb default, pyiceberg, spark, snowflake, trino, polars, clickhouse (clickhouse always read-only, level=write is a 400; snowflake read-only unless level=write, which also records an audit event); level optional: read for the read-only recipe whatever you hold, write needs write, absent = the level you hold; edge_auto=1: for a catalog with edge caching on, DuckDB's read recipe through the CDN, else the storage's; edge=true: the CDN or a refusal saying why; edge=false: the storage; /docs/edge-caching/)lhbox connect --catalog … --engine duckdb|pyiceberg|spark|snowflake|trino|polars|clickhouse [--readonly|--write] [--show-secrets]catalogs:read (an MCP access token; an API key has no scopes)GET /v1/config/formats → 200
Which Iceberg format versions a table here can have, the default for LakehouseBox's own create paths, and for each version the engines that write it and those that only read it -- the ONE list (FORMAT_WRITERS in the control plane) every other sentence about format versions is rendered from. Public, like /v1/auth/config: a CLI, an agent or a page reads this instead of carrying its own list. Version strings are machine-parseable, `<engine>[>=<version>[+<lib>-<version>]]` (duckdb>=1.5.5, spark>=3.5+iceberg-1.11, pyiceberg>=0.12, spark); the prose is in note. A catalog's own default is default_format_version on GET /v1/warehouses/{id}.
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. domain_offers lists the organisations you may join because they verified and opened your address's domain (nothing is joined until you accept one with POST /v1/orgs/{org_id}/join).
lhbox org listaccount:read (an MCP access token; an API key has no scopes)PATCH /v1/orgs/{org_id} → 200
Rename the organisation, or open it to its verified domains: people whose address is on one are then offered membership (domain_offers) and join only if they accept; nobody is added automatically. deprovision chooses what removing a member stops: 'tokens' (the default: their tokens and connections) or 'everything' (also the sinks, blob uploaders and hosted agents they made).
{"name"?, "domain_join"?: true|false, "deprovision"?: "tokens"|"everything"}lhbox org set [--name …] [--domain-join on|off] [--deprovision tokens|everything]403 mcp_human_only): a person or an API key does thisGET /v1/orgs/{org_id}/policy → 200
What the organisation's members may do: who may publish catalogs (publish_catalogs: admins, the default; members, on a catalog they can write; or nobody), who may connect agents -- tokens, `lhbox login`, MCP clients and personal API keys -- (connect_agents: members, the default, or admins) and who may create hosted agents (hosted_agents: members, the default, or admins). Also what you may do, whom to ask, and the catalogs public now.
lhbox org policyaccount:read (an MCP access token; an API key has no scopes)PATCH /v1/orgs/{org_id}/policy → 200
Change what the organisation's members may do. A refused action answers 403 org_policy_forbids with the setting, its value and a remedy naming the admins. Nothing made before is undone: catalogs public now stay public (listed in public_catalogs, with a note, for you to unpublish), connections and hosted agents keep working until revoked or deleted. Audited as org.policy_update with each change's before and after.
{"publish_catalogs"?: "admins"|"members"|"nobody", "connect_agents"?: "members"|"admins", "hosted_agents"?: "members"|"admins"}lhbox org policy [--publish-catalogs admins|members|nobody] [--connect-agents members|admins] [--hosted-agents members|admins]403 mcp_human_only): a person or an API key does thisPOST /v1/orgs/{org_id}/domains → 201
Claim an email domain. The claim does nothing until it is verified: publish the TXT record the answer names (_lakehousebox.<domain>, a random value per claim), then call .../verify. Public mail domains (gmail.com, outlook.com, gmx.de, yahoo.co.uk, …) are refused outright. Several organisations may hold an unverified claim; the first to verify holds the domain alone. A claim on a domain another organisation holds verified is recorded too (held_by_another_organisation): it verifies only once that organisation's own TXT value is gone from the record.
{"domain": "yourcompany.com"}lhbox org domain --domain …403 mcp_human_only): a person or an API key does thisPOST /v1/orgs/{org_id}/domains/{domain}/verify → 200
Verify a claim: the control plane looks up the TXT record _lakehousebox.<domain> and compares it with the claim's value. The record must be a TXT record at that very name: an answer reached through a CNAME verifies nothing. When another organisation holds the domain verified and its own value is gone from the record, the domain passes to this claim (took_over; the other claim is unverified). Only a verified domain can be opened (PATCH domain_join). A verified claim is looked up again once a day; three definite misses in a row (no such name, or the value absent; never a resolver failure) unverify it, and so do 7 days without any answer.
lhbox org domain --domain … --verify403 mcp_human_only): a person or an API key does thisDELETE /v1/orgs/{org_id}/domains/{domain} → 200
Release a claimed domain, verified or not. Members who joined through it stay; nobody new is offered it, and domain join switches itself off when no verified domain is left.
lhbox org domain --domain … --remove403 mcp_human_only): a person or an API key does thisPOST /v1/orgs/{org_id}/join → 200
Accept a domain offer: join, as a member, an organisation that verified and opened your address's domain (GET /v1/orgs lists them as domain_offers). Accepting is the person's own act, done with their own login or API key; an organisation's token is offered nothing. Idempotent for a member. A person who left the organisation or was removed from it (matched by account, and by address across a new account; remembered for 12 months) is not let back in this way; an admin's invitation is.
lhbox org join --org …403 mcp_human_only): a person or an API key does thisGET /v1/orgs/{org_id}/members → 200
Who is in the organisation.
lhbox org membersaccount:read (an MCP access token; an API key has no scopes)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.
{"role": "admin"|"member"}lhbox org role --principal … --role admin|member403 mcp_human_only): a person or an API key does thisDELETE /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. Every token the person created in the organisation, and every token those created, is revoked in the same request (their `lhbox login` and MCP tokens included), and the invitations they or those tokens sent that nobody accepted are withdrawn. Sinks, blob uploaders and hosted agents they made keep working and are listed in left_behind for an admin to keep or delete, unless the organisation's deprovision setting is 'everything': then they are stopped too and listed in stopped.
403 mcp_human_only): a person or an API key does thisPOST /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. One live invitation per address: inviting it again refreshes it (same invitation_id, a new link and 7 days; the old link stops working; refreshed: true). The mail names the sender by the verified address of the person behind the call (for a token: the person who created it), which must be verified where the deployment verifies addresses. At most 20 invitations per person and 50 per organisation in any hour, re-sends included.
{"email": …, "role": "member"|"admin"}lhbox org invite --email … --role member|admin403 mcp_human_only): a person or an API key does thisGET /v1/orgs/{org_id}/invitations → 200
The organisation's pending invitations, expired ones included (expired: true); never the token. Accepted and withdrawn invitations are not listed.
account:read (an MCP access token; an API key has no scopes)DELETE /v1/orgs/{org_id}/invitations/{invitation_id} → 200
Withdraw a pending invitation: its link stops working (404 invitation_not_found) and signing up or logging in with the address no longer applies it. An accepted invitation is a membership: remove the member instead.
403 mcp_human_only): a person or an API key does thisPOST /v1/orgs/invitations/accept → 200
Redeem an invitation. The caller's email must be the invited one. A withdrawn invitation is not found, and neither is one whose sender could not send it now (removed, suspended, revoked, or no longer an admin). Already a member: the invitation is used up and the role held is kept and answered, with already_member: true (a role changes through PATCH /v1/orgs/{org_id}/members/{member_id}).
{"token": …}lhbox org accept --token …403 mcp_human_only): a person or an API key does thisTokens
POST /v1/orgs/{org_id}/tokens → 201
A token: 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 a token 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. `sinks` (sink ids) lets the token send batches to those sinks with its own key (POST /v1/sinks/{sink_id}); giving one needs write on the sink's catalog (an admin is uncapped), and the token must hold read on that catalog in the same request; the right ends with that read grant.
{"name": …, "role"?: "admin"|"member", "grants"?: [{"warehouse_id": …, "level": "read"|"write"}, …], "sinks"?: [sink_id, …]}lhbox token create --name … [--role admin|member] [--grant <warehouse_id>:read|write …] [--send <sink> …]403 mcp_human_only): a person or an API key does thisGET /v1/orgs/{org_id}/tokens → 200
The organisation's tokens with their role and grants, who created each and when its key was last used. A member token is shown only the grants on catalogs it can read itself; grants_hidden counts the others.
lhbox token listaccount:read (an MCP access token; an API key has no scopes)GET /v1/orgs/{org_id}/tokens/{token_id} → 200
One token by id or by name: the same view as the list (grants as [{warehouse_id, name, level}] on every token route since 2026-09-21), its connection block, creator and last use. Never the key.
account:read (an MCP access token; an API key has no scopes)PATCH /v1/orgs/{org_id}/tokens/{token_id} → 200
Edit a token's name, grants and/or role; the path names the token by id or by name. 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 token confines it. Audited with the previous role.
{"name"?: …, "role"?: "admin"|"member", "grants"?: [{"warehouse_id": …, "level": …}], "sinks"?: [sink_id, …] (replaces; [] clears)}lhbox token update <id or name> [--name …] [--role admin|member] [--grant <warehouse_id>:read|write …] [--send <sink> …]403 mcp_human_only): a person or an API key does thisDELETE /v1/orgs/{org_id}/tokens/{token_id} → 200
Every key of the token is revoked and the token suspended: it stops at once. So does every token it created, transitively (a token may mint tokens at up to its own level; revoking a leaked token stops the tokens it minted): the whole subtree in one transaction, each child audited as token.revoke with via=cascade, and a token being minted meanwhile by a member of the subtree is refused (403 creator_revoked on POST .../tokens). A descendant revoked earlier keeps its own timestamp; revoking a token that is already revoked sweeps its subtree again. Blob uploaders and sink keys the token created are not tokens: they stay until revoked on their own (DELETE /v1/warehouses/{id}/uploaders/{uploader_id}, the sink's routes). A hosted agent the token or one below it created is paused for good (paused_reason creator_token_revoked), even one already paused, and a run in progress stops at its next call; its identity is not revoked (its runs stay readable), but nobody resumes, runs or tests it again (409 agent_creator_gone): an admin deletes it, or creates a new agent under their own name from its definition. The path names the token by id or by name.
lhbox token revoke <id or name> [--yes]403 mcp_human_only): a person or an API key does thisHosted agents
POST /v1/agents → 201
Create an agent: a prompt LakehouseBox runs next to your data, with read grants on the named catalogs, optional one-line description (at most 200 characters, shown in the lists), schedule, memory keys, email to its owner and integrations. 403 feature_not_enabled when the organisation has no hosted agents. Integrations and web keys are attached by the person who connected them, signed in as themselves: a token is 403 integration_needs_person. 403 org_policy_forbids when the organisation lets only its admins create hosted agents (hosted_agents setting).
lhbox agent create <name> --prompt TEXT|@FILE --catalog <catalog>403 mcp_human_only): a person or an API key does thisGET /v1/agents → 200
The organisation's agents and its allowance (tokens this month, runs today). A member token is not shown the agents that read a catalog it cannot read. may_create: the organisation's hosted_agents setting lets you create one.
lhbox agent listaccount:read (an MCP access token; an API key has no scopes)POST /v1/agents/schedule-preview → 200
The next five run times of a schedule, checked against the organisation's limits as a save would be.
403 mcp_human_only): a person or an API key does thisGET /v1/agents/models → 200
The models LakehouseBox provides: where they run, whether they see images, how fast each uses the allowance (allowance_multiplier, in tokens of the default model) and its price per million tokens.
lhbox agent modelsaccount:read (an MCP access token; an API key has no scopes)GET /v1/agents/{agent} → 200
One agent: its current definition, catalogs, schedule, memory and last run. `this_key` says what the calling key may do with it, from the same checks the routes make: {manage, read_runs, runs_closed_because} (`you_created` is about the person behind the key, which a member token of the creator is not: it does not manage the agent).
lhbox agent show <name>account:read (an MCP access token; an API key has no scopes)PATCH /v1/agents/{agent} → 200
A new version of the definition (only the fields named change), new read grants, or pause/resume. 400 invalid_field for a field it does not know. An agent that uses (or, before this change, used) its creator's integrations is changed by that person only, signed in as themselves (a token: 403 integration_needs_person) -- or by a token that person approved with "manage agents" including their integrations, and then only prompt, description, model, schedule, run_seconds, state_keys, notify_owner and paused; each such change is mailed to the person with the command that undoes it. restore_version: <version id> (alone) puts an earlier definition back whole, under the same checks as the fields it changes (404 version_not_found). Anyone who manages it may pause it. Attaching integrations answers integration_review (the prompt and write targets they will run); confirm_definition: true approves a definition another credential wrote, so it may use the person's integrations. An agent whose creator is gone (the view's creator_gone: creator_token_revoked, for good, or owner_gone, while its person is not in the organisation) is resumed by nobody: 409 agent_creator_gone, whose remedy is a new agent under the admin's own name; deleting it stays open. Write tables are held to its creator's level: an edit adding a table its creator cannot write answers 409 creator_cannot_write, and while its creator holds less than write on a table it writes nobody resumes it (409 creator_cannot_write, with the tables; dropping those tables in the same request lifts it).
lhbox agent update <name> [--prompt ...] [--catalog ...] [--pause|--resume] [--restore-version V]403 mcp_human_only): a person or an API key does thisDELETE /v1/agents/{agent} → 200
Stop an agent for good: its identity is suspended; its runs stay readable.
lhbox agent delete <name> --yes403 mcp_human_only): a person or an API key does thisPOST /v1/agents/{agent}/run → 202
Run an agent now (202). `{"dry_run": true}`: a test run (its email and memory writes are recorded, not performed; outside the day's runs, with its own cap). 429 daily_runs_used, dry_runs_used or allowance_exhausted; 409 while a run is going, and 409 agent_creator_gone (a test run too) for an agent whose creator is gone; 409 creator_cannot_write (a test run too, whoever asks) while its creator holds less than write on a table it writes.
lhbox agent run <name> [--dry-run] [--wait]403 mcp_human_only): a person or an API key does thisPOST /v1/agents/{agent}/subscribers/me → 200
Email me this agent's notices: the caller subscribes themselves, never anyone else. Needs a verified address and read on every catalog the agent reads; for an agent that uses (or used) a person's integrations, its creator and admins only (403 cannot_subscribe); each notice carries the person's own stop link.
lhbox agent subscribe <name>403 mcp_human_only): a person or an API key does thisDELETE /v1/agents/{agent}/subscribers/me → 200
Stop receiving this agent's notices (the caller's own subscription).
lhbox agent unsubscribe <name>403 mcp_human_only): a person or an API key does thisGET /v1/agents/{agent}/brief → 200
What needs the owner first (what the agent flagged, decisions due, a pause, a failed last run, missed slots, failed tool calls, the allowance), then its memory as the agent sees it, its recent runs and the allowance. No model. Whoever may read the agent's memory; for an agent that used a person's integrations, a person only (`withheld`).
lhbox agent brief <name>account:read (an MCP access token; an API key has no scopes)POST /v1/agents/{agent}/talk → 200
`{message, about?}` (`about`: `{topic, item}` of an entry the agent flagged or has due): the agent answers from its brief and prompt in `reply`, and may propose a `proposal`: a new prompt (`before`, `after`) and items to mark done. Nothing changes until `.../apply`. Whoever may change the agent; for an agent that used a person's integrations, that person. Charged to the inference allowance (429 `allowance_exhausted`; 429 `busy` when many talks are under way); 502 `agent_did_not_answer` when the model failed (nothing changed).
lhbox agent talk <name> "<message>" [--about <topic>/<item>]403 mcp_human_only): a person or an API key does thisGET /v1/agents/{agent}/talk → 200
The latest exchanges with the agent, newest first (`limit`, at most 50): message, reply, proposal and its state (`proposed`, `applied`, `discarded`, `answered`, `failed`). Who may talk to it.
lhbox agent talk <name>account:read (an MCP access token; an API key has no scopes)POST /v1/agents/{agent}/talk/{talk_id}/apply → 200
Apply a proposal: the prompt becomes a new version of the agent (as an edit would) and the items are marked done in its memory as the person's. 409 `agent_changed` when the agent was edited since the proposal; 409 `talk_decided` when it was already applied or discarded.
lhbox agent talk <name> --apply <talk_id>403 mcp_human_only): a person or an API key does thisPOST /v1/agents/{agent}/talk/{talk_id}/discard → 200
Discard a proposal; nothing changes.
lhbox agent talk <name> --discard <talk_id>403 mcp_human_only): a person or an API key does thisGET /v1/agents/{agent}/runs → 200
An agent's runs: state, tokens, the final message, errors; `detail=true` adds each run's record. Who reads them: an admin, the agent's creator, and anyone of the organisation (a person or a token) who holds read on every catalog the agent was ever given (a run's output and memory carry what earlier runs read), except for an agent that ever used a person's integrations or that ran before that record was kept (2026-11-02); such a reader sees no memory and no notices (`view: catalog_reader`). Otherwise 403 not_agent_manager with `runs_closed_because` (integrations, history_unknown, no_catalogs, catalog_deleted, catalogs_not_held); for a token it also names the token and its role. For an agent that uses or used a person's integrations, a token gets the runs without their final message and no `detail` (403 integration_needs_person): only people read what those runs did.
lhbox agent runs <name> [--detail]account:read (an MCP access token; an API key has no scopes)GET /v1/agent-runs/{run_id} → 200
One run with its record: every tool call in order (a run_sql call with its SQL), the emails it sent, its memory before and after. Readable by whoever reads the agent's runs (GET /v1/agents/{agent}/runs). For an agent that uses or used a person's integrations, people only (a token: 403 integration_needs_person).
lhbox agent run <name> --waitaccount:read (an MCP access token; an API key has no scopes)POST /v1/integrations → 201
Connect an MCP server: OAuth (answers a link to open in a browser signed in to LakehouseBox), an API key, or none; the server's tools are listed. Or store a web API's key (`auth: "web_key"`, `host`, `header_name` or `key_param`) for an agent's fetch_url.
lhbox integration add <name> --mcp URL403 mcp_human_only): a person or an API key does thisGET /v1/integrations → 200
The organisation's integrations (metadata only, never a credential): an admin sees all, a member their own.
lhbox integration listaccount:read (an MCP access token; an API key has no scopes)GET /v1/integrations/{ref} → 200
One integration: its state, scopes, tools and the agents that use it; never a credential.
lhbox integration show <name>account:read (an MCP access token; an API key has no scopes)DELETE /v1/integrations/{ref} → 200
Revoke at the provider and wipe the stored credential.
lhbox integration delete <name> --yes403 mcp_human_only): a person or an API key does thisPOST /v1/integrations/{ref}/reconnect → 200
A new authorize link, for the person who connected it (needs_reauth, failed, an expired link).
lhbox integration reconnect <name>403 mcp_human_only): a person or an API key does thisAPI 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 (device-code endpoints). The account page shows the form, the "Log in with <issuer>" button, or both, from this answer; it hides "Create an account" when signup is false.
POST /v1/api-keys → 201
Another API key for the caller (for a second machine, a CI job). Shown once. From a person's browser session it needs a recent sign-in (the login, or POST /v1/auth/reauth, within the last 10 minutes); from an identity provider's token, a recent sign-in there (auth_time); with the person's API key nothing more is asked. A person is mailed a notice naming the key (never the key).
{"label": "ci"}lhbox api-key create --label …403 mcp_human_only): a person or an API key does thisGET /v1/api-keys → 200
The caller's keys, by id: never the secret.
lhbox api-key listaccount:read (an MCP access token; an API key has no scopes)GET /v1/api-keys/{key_id} → 200
One of your keys by id, as the list shows it: never the secret.
account:read (an MCP access token; an API key has no scopes)PATCH /v1/api-keys/{key_id} → 200
Relabel one of your keys. Nothing else about a key changes: make another instead.
{"label": …}403 mcp_human_only): a person or an API key does thisDELETE /v1/api-keys/{key_id} → 200
Revoke one of your keys. The key id is the part between al_live_ and the second underscore.
lhbox api-key revoke --key-id … [--yes]403 mcp_human_only): a person or an API key does thisPOST /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 open invitation for the address is applied instead. An email domain never places anyone: an organisation that verified and opened the address's domain is only offered afterwards (domain_offers; POST /v1/orgs/{org_id}/join). Idempotent: an existing organisation and warehouse are returned, not duplicated. A sign-in that GET /v1/connection would ask to confirm before the pair that writes gets the read-only recipe here instead (or none, when its grants have no read-only form): onboarding never asks.
{"engine"?: "duckdb", "accept_terms"?: true, "organization_name"?, "handle"?, "org_id"?}403 mcp_human_only): a person or an API key does thisGET /v1/terms → 200
The Terms version in force and whether you accepted it.
lhbox termsaccount:read (an MCP access token; an API key has no scopes)POST /v1/terms/accept → 200
Record acceptance of the current Terms version. A person's act: a token (kind token or mcp) is refused; Terms bind the account holder and a token needs no acceptance of its own.
{"version"?: <the current version>}lhbox terms --accept403 mcp_human_only): a person or an API key does thisDELETE /v1/me → 200
Delete your account. A person confirms with the email and the current password, and a code when two-factor authentication is on, whatever credential makes the call (an account that never set a password confirms with its own API key and the email alone, or with a sign-in at its identity provider in the last 10 minutes; a token confirms with its principal_id). Immediate: every API key revoked, every session revoked, identities unlinked, memberships removed; a credential of the deleted account answers 401 account_deleted from then on. An organisation you are the only member of is deleted in the same request, with its tokens and invitations, and so are your own catalogs outside any organisation; those catalogs are unreachable at once and kept for the grace period (7 days, until purge_after), then purged with their storage; backups expire within 14 days after that (Terms). Catalogs you made in an organisation that keeps members stay: they are the organisation's. Every token you created, in any organisation, and every token those created, is revoked with the account.
{"confirm": "<your email>", "current_password": "…", "code"?: "123456" (two-factor on)} or, from a browser session, {"confirm": …, "current_password": …, "passkey": <assertion for POST /v1/auth/passkeys/confirm/start {"purpose": "account_delete"}>}lhbox account delete --confirm <your email> [--password-env VAR] [--code …]403 mcp_human_only): a person or an API key does thisGET /v1/me/news → 200
Whether you receive the LakehouseBox product-news email (about once a month), since when and from where, and the exact words of the consent.
account:read (an MCP access token; an API key has no scopes)PUT /v1/me/news → 200
Turn the product-news email on or off for your own account (a person with a verified address; a token has no inbox). Turning it on records your consent: the words' version, the time and your IP. Idempotent.
{"subscribed": true|false}403 mcp_human_only): a person or an API key does thisPOST /v1/news/unsubscribe → 200
The unsubscribe link in every newsletter, no login: the token as ?t=… (the List-Unsubscribe one-click POST, RFC 8058) or {"token": …}. The same answer whether or not the address was subscribed.
?t=<token> or {"token": …}POST /v1/onboarding/unsubscribe → 200
The unsubscribe link in every getting-started email, no login: the token as ?t=… (the List-Unsubscribe one-click POST, RFC 8058) or {"token": …}. Stops those emails only; the newsletter and account emails are separate. The same answer whether or not you had unsubscribed.
?t=<token> or {"token": …}Usage and limits
GET /v1/usage → 200
Every limit, the current value and the headroom. Storage is the store's own per-bucket figure, read every few minutes (the latest sample of every warehouse in the scope, summed; Iceberg metadata files, which the store keeps inline, are not counted, and objects is approximate; 'unmetered' until the first sample, which the control plane takes ~2 minutes after a catalog is created); every storage figure carries measured_at and stale_after (when the next reading will have replaced it), and POST /v1/warehouses/{id}/measure refreshes one catalog now. 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. A token's answer also names `acting_for` (the person it acts for, with their role in each organisation) and `token_cannot` (what no token does: a token acts with its own role and grants, not those of the person who made it; `lhbox login` signs in as the person, not as a token). 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 the store'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 samples carry counters (the maintenance sweep reads them every 6 hours); no object keys and no query contents are collected. The per-catalog rows (warehouse_ids, warehouse_storage) list only the catalogs the caller holds read on (an admin of the organisation: all of them); the totals and the catalog count stay the scope's, since the limits are measured against them (so they include catalogs the caller cannot see).
?project_id=<optional id or name>&days=<1..30, default 7: the per-catalog history length>lhbox usage [--full] [--catalog <id or name>] [--days N] (also: lhbox whoami; both print a brief object unless --full)account:read (an MCP access token; an API key has no scopes)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 (before 2026-10-01), api_key.create/revoke, project.create, warehouse.create/rotate, table.create, credentials.vend, org.create/update/domain_claim/domain_verify/domain_unverify/domain_release/invite/invitation_accept/member_join/member_role/member_remove, org.reserve/activate, token.create/update/revoke (agent.* before 2026-09-29; a token.* filter finds both), warehouse.grant/grant_revoke/delete/delete_revoked/purge/restore/delete_complete/rotate_complete/measure, terms.accept, account.delete, quota.reject.
?org_id=<optional>&since=<24h|7d|30m|ISO-8601>&action=<name or prefix*>&limit=<1..1000, default 100>lhbox audit [--org …] [--since 24h|7d|30m|<ISO-8601>] [--action credentials.vend|org.*] [--limit N]account:read (an MCP access token; an API key has no scopes)Back office (platform admins)
GET /v1/backoffice/whoami → 200
Whether the caller may use the back office, and as whom (the page asks this first).
GET /v1/backoffice/overview → 200
The platform in numbers, from the control plane's database: organisations (active in 7/30 days), human accounts (verified, pending, with two-factor), tokens, catalogs, sign-ups per day and per week, bytes stored (the latest sample of every catalog) and the top organisations by bytes, S3 traffic over 7 days, logins, and what the control plane sees of its host (the filesystem holding its state; the rest is in the daily numbers report, linked). Tables are not counted here (null, with a note).
?days=30 (7-180, the sign-ups window)GET /v1/backoffice/users → 200
Human accounts, newest first, searched by email, name or organisation (q, substring, any case). Carries email addresses: personal data, to the admin's browser only.
?q=&limit=50&offset=0GET /v1/backoffice/orgs → 200
Organisations, newest first, searched by handle or name.
?q=&limit=50&offset=0GET /v1/backoffice/orgs/{org_id} → 200
One organisation (id or handle): members with last login and two-factor, tokens, catalogs with their latest sizes, plan and limits, the last 30 audited actions.
GET /v1/backoffice/install → 200
A self-hosted install's Install view, read-only (platform/docs/self-hosting.md "Administering an install"): the release (version, commit, signed, key fingerprint), every security setting's effective value and where it is set (.env, the security profile, the default; a secret only as set or not set) beside the ENS guide's value, the identity providers and SCIM, the SIEM export's counters per sink, the last backups, storage in use and the install administrators. Nothing here edits the configuration.
lhbox admin status [--login EMAIL]POST /v1/backoffice/orgs → 201
A new organisation and its first admin: an existing person (a verified, active account) becomes its admin at once; any other address gets an admin invitation (mailed when the install has mail; its link answered once). Works with self-registration closed. Audited org.create and org.member_join or org.invite (via backoffice).
{name, handle?, admin_email}lhbox admin org create <handle> --admin EMAIL [--name N]PUT /v1/backoffice/orgs/{org_ref}/admins/{email} → 200
Make an address an admin of the organisation: a member is promoted, an existing person added, any other address invited as admin. Audited (via backoffice).
lhbox admin org admin add <org> <email>DELETE /v1/backoffice/orgs/{org_ref}/admins/{member_ref} → 200
An admin of the organisation becomes a member, with what a demotion owes (their tokens capped, their keys rotated); an open admin invitation of the address is withdrawn instead. Audited org.member_role.
lhbox admin org admin remove <org> <email>POST /v1/backoffice/principals/{principal_ref}/suspend → 200
Suspend a person (reversible): login, sessions, API keys and every token they made are refused until reactivated; the catalog keys they fetched are rotated. Not oneself; another install administrator only with the operator key. Audited principal.suspend.
{reason?}lhbox admin person suspend <email> [--reason R]POST /v1/backoffice/principals/{principal_ref}/reactivate → 200
Lift a suspension; refused for a deleted account and for one the organisation's directory deprovisioned (SCIM reactivates it). Audited principal.unsuspend.
lhbox admin person reactivate <email>POST /v1/backoffice/principals/{principal_ref}/revoke → 200
Revoke every session (browser and MCP), every API key of the person and every token they made, in every organisation; their catalog keys rotated. The account stays. Audited principal.revoke_all.
lhbox admin person revoke-all <email> --yesGET /v1/backoffice/oauth-clients → 200
The OAuth/MCP clients an install administrator registered (approved), each with its live connections; ?all=1 adds the dynamic registrations. Never a secret.
?all=1 (the self-registered clients too)lhbox admin client list [--all]POST /v1/backoffice/oauth-clients → 201
Register an approved OAuth/MCP client: it connects through the consent page with dynamic registration off. Public (PKCE) by default; confidential: a secret answered once (sha256 at rest), sent to /oauth/token by HTTP Basic or client_secret_post. Audited oauth.client_register.
{client_name, redirect_uris[], client_id?, client_uri?, confidential?}lhbox admin client add --name N --redirect-uri URI [--client-id ID] [--client-uri URL] [--confidential]POST /v1/backoffice/oauth-clients/{client_id}/disable → 200
No new sign-in and no refresh for the client (its access tokens end within 15 minutes); reversible. Audited oauth.client_disable.
lhbox admin client disable <client_id>POST /v1/backoffice/oauth-clients/{client_id}/enable → 200
Undo disable. Audited oauth.client_enable.
lhbox admin client enable <client_id>DELETE /v1/backoffice/oauth-clients/{client_id} → 200
Remove the client and revoke every connection made through it; the hosted agents those connections created are paused (creator_token_revoked), not revoked. Audited oauth.client_remove.
lhbox admin client remove <client_id> --yesGET /v1/backoffice/plans → 200
GET /v1/admin/plans for a platform admin: every plan with its limits and features, the names an override may use.
lhbox admin plans --login EMAILGET /v1/backoffice/orgs/{org_id}/limits → 200
GET /v1/admin/orgs/{org}/limits for a platform admin: plan, overrides, effective limits and features, last changes.
lhbox admin org limits <org> --login EMAILPATCH /v1/backoffice/orgs/{org_id}/limits → 200
PATCH /v1/admin/orgs/{org}/limits for a platform admin: the same validation, the same org_limits_audit row and org.limits.update event, actor admin:<principal_id>.
{plan?, overrides?, note?}lhbox admin org set <org> [--plan P] [--feature NAME=on|off|N] [limit flags] --login EMAILDELETE /v1/backoffice/orgs/{org_id} → 202
Delete an organisation and every catalog of it (each through the ordinary deletion path: gone at once, storage torn down in the background, a tombstone), its tokens revoked and suspended, its projects, limits, invitations, domains and memberships removed. The members keep their accounts. Audited backoffice.org.delete.
{"confirm": "<handle>"}DELETE /v1/backoffice/principals/{principal_ref}/mfa → 200
The operator's two-factor reset (DELETE /v1/admin/principals/{…}/mfa) for a platform admin; not for the admin's own account.
Operator (not public by default)
GET /v1/admin/admins → 200
The platform admins: the granted accounts and the addresses of AL_PLATFORM_ADMIN_EMAILS (verified? two-factor on?).
PUT /v1/admin/admins/{principal_ref} → 200
Make a human account (id or email) a platform admin. The back office opens for it only with a verified address and two-factor on. Audited platform.admin_grant.
{note?}DELETE /v1/admin/admins/{principal_ref} → 200
Take platform admin away (an address in AL_PLATFORM_ADMIN_EMAILS stays one until the variable changes). Audited platform.admin_revoke.
GET /v1/admin/diagnostics/store-profile → 200
The store's own CPU profile over the given seconds, in Go's pprof format (go tool pprof reads it): which of the store's code paths take its CPU on the running cell. Nothing is kept.
query: seconds (1-30, default 10)GET /v1/admin/diagnostics → 200
What the cell is busy with now (issue #2386): the control plane's SQLite (database, WAL and shared-memory sizes, a PASSIVE checkpoint's answer, how long the write lock takes to get), fsync latency and write rate on the data volume and on the pod's own disk, and the busiest processes over sample_s (by name only, never their arguments). Measured when asked; nothing is kept.
query: sample_s (0.2-5, default 1)GET /v1/admin/admission → 200
The admission caps on NEW accounts (per hour, and a total counted from when it was set), the counts they are held against, and how many addresses wait.
PUT /v1/admin/admission → 200
Set the caps: a field sent is set (null removes a cap, or the custom waitlist message), a field left out stays; until the first PUT, per_hour is the deployment's AL_ADMISSION_PER_HOUR (default 3600). Past a cap, a signup (the web form and the code path alike) waits in line and is answered 202 {waitlisted: true, message, next}; nothing is created or mailed. The total counts an admission once its account exists, or while it is under an hour old; releases count against neither cap. Invitations, logins, API keys and device flows are never held. slot_reserve_pct (0-100, 0 = off, null = AL_ADMISSION_SLOT_RESERVE_PCT, default 20): new accounts also wait while the store's free volume slots are at or below that share of its max; an unreadable store never closes the door. Audited admission.update.
{per_hour?: int|null, total_cap?: int|null, slot_reserve_pct?: int|null, message?: string|null}GET /v1/admin/waitlist → 200
The addresses waiting, in the order they will be released, and the last releases that failed for good.
?limit (1-10000, default 200)DELETE /v1/admin/waitlist/{email} → 200
Take an address out of the line without mailing it (junk addresses ahead of real people). Audited waitlist.remove.
POST /v1/admin/waitlist/release → 200
Release the next `count` in line: each gets the web form's set-password link and finishes like any signup. A release whose mail cannot be sent goes back in line at its place; one that fails otherwise leaves the line (GET's failed). Refused when password login is off. Audited waitlist.release.
{count: 1-500}GET /v1/admin/onboarding → 200
The onboarding mails at a glance (plane/onboarding.py): the mode (AL_ONBOARDING_MAIL off|dry|on), since when people are mailed, what was sent by mail and delivery, how many opted out, and who is due now.
GET /v1/admin/news → 200
The mailing list at a glance: subscribers, withdrawals by reason, whether the relay, the legal footer and the webhook are configured, and the issues.
lhbox admin news statusPOST /v1/admin/news/issues → 201
A draft newsletter issue from Markdown (headings, paragraphs, bullet lists, `code`, **bold**, https links; <!-- notes --> dropped; no raw HTML). The answer has the rendered HTML. Audited news.issue.create.
{"subject": …, "body_md": …}lhbox admin news create --subject S --file issue.mdGET /v1/admin/news/issues/{issue_id} → 200
One issue: text, rendered HTML, state (draft|tested|sending|sent) and the delivery counts.
lhbox admin news show <issue_id>PATCH /v1/admin/news/issues/{issue_id} → 200
Edit a draft; an edit clears tested_at (the send needs a test of the final text).
{"subject"?: …, "body_md"?: …}lhbox admin news edit <issue_id> [--subject S] [--file issue.md]POST /v1/admin/news/issues/{issue_id}/test → 200
Send the issue to one address, subject prefixed [test]; records tested_at, which the real send needs.
{"to": …}lhbox admin news test <issue_id> --to you@example.comPOST /v1/admin/news/issues/{issue_id}/send → 202
Send the issue to everyone subscribed now, once: the recipients are fixed now and mailed in the background (a restart resumes, nobody is mailed twice; someone who unsubscribes before their turn is skipped; a relay in trouble pauses the send and retries, then leaves the rest queued). On an issue already sending this resumes it; retry_failed also puts the rows the relay refused for good back in line. Needs the list on, the relay, valid extra headers, the sender's name and postal address, and a test after the last edit. Audited news.issue.send / news.issue.resume.
{"confirm": "<issue_id>", "retry_failed"?: true}lhbox admin news send <issue_id> [--retry-failed]GET /v1/admin/plans → 200
The plan registry: every plan by name with its limits (free and custom, both the free plan's numbers today), the field names an override may name, and how each limit is enforced.
lhbox admin plansGET /v1/admin/orgs/{org_id}/limits → 200
One organisation (org_id or handle): its plan, its overrides, the effective limits (plan merged with overrides) with their enforcement, the operator's note and the last ten changes.
lhbox admin org limits <org>PATCH /v1/admin/orgs/{org_id}/limits → 200
Put an organisation on a plan and/or override limits. A null removes that override; other overrides stay. Every call is one row in org_limits_audit and one org.limits.update event in the organisation's audit log. When a storage limit changed, every bucket of the organisation gets its hard quota re-set at once; the read-only flip or un-flip itself follows the store's own quota pass (within about a minute; the host's enforce pass after each maintenance sweep is a backstop). The refusals (409 quota_exceeded, limit_exceeded) read the new limit on the next request.
{plan?: free|custom, overrides?: {storage_bytes|public_storage_bytes|objects|warehouses|projects|...: non-negative integer | null}, note?}; a plan feature is an override like any limit: {"overrides": {"public_images": 1}} switches photos from public folders on, 0 off, null back to the planlhbox admin org set <org> [--plan P] [--feature NAME=on|off|N] [--storage-gb N ...] [--clear FIELD]DELETE /v1/admin/domains/{domain} → 200
Release a domain claim for a dispute settled outside this API: the verified claim on the domain, or with ?org=<org_id or handle> that organisation's claim, verified or not. Domain join switches off when the organisation has no verified domain left; audited org.domain_release with actor operator.
DELETE /v1/admin/principals/{principal_ref}/mfa → 200
Two-factor off and every passkey removed, for a person who lost both the phone and the backup codes, or a device (principal_id or email), after the operator has checked who is asking; every session of the account and every API key of the person are revoked and every MCP connection the person approved disconnected (when there was something to reset); audited auth.mfa_reset.
POST /v1/admin/warehouses/{warehouse_id}/restore → 200
Restore a catalog in its grace period by warehouse_id, as POST /v1/warehouses/{id}/restore does, without the tenant's role check; audited warehouse.restore with actor operator.
DELETE /v1/admin/handles/{handle}/reservation → 200
Release a deleted organisation's handle before its reservation ends (a deleted organisation's handle is reserved until its catalogs are purged at the store and at the public-data edge and AL_HANDLE_RESERVE_DAYS have passed); audited handle.release with actor operator.
GET /v1/admin/orgs/{org_id}/limits/audit → 200
Every change to the organisation's plan and overrides, newest first (?limit=, default 100, max 1000).
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, except a few operator-only routes, which are omitted.
POST /internal/dropbox/auth → 200
SFTPGo's external-auth hook on the drop-box host: a login in, the SFTPGo user out (home, upload-only permissions, the protocols the uploader and the plan allow), or an empty username to refuse. Requires AL_DROPBOX_HOOK_SECRET; 404 without it.
GET /internal/dropbox/uploaders → 200
For the drop box's relay: the live drop-box uploaders (id, login, bucket, prefix; never a key), and the ones revoked in the last day. Requires AL_DROPBOX_HOOK_SECRET; 404 without it.
POST /internal/dropbox/presign → 200
For the drop box's relay: {uploader_id, key, content_type?} in, one presigned PUT out (900 s), signed with that uploader's own key for a key under its prefix; the signed headers come back to send exactly. Requires AL_DROPBOX_HOOK_SECRET; 404 without it.
POST /internal/slices/claim → 200
For the slicer service (worker/slices.py, its own process): {limit, warehouse_ids?} -> at most limit due rules (interval since the last check, or a refresh asked for; never without the enterprise plan, never a held catalog unless named), each claimed for this run and handed over with its catalog's key pair and the deleted rules of its layout it may adopt and each slice's state. A claimed rule is not due again until its report or a 10-minute timeout, so one broken rule never starves the others; a timed-out claim is recorded as a lost run (run_lost) and doubles the next wait.
POST /internal/slices/{slice_id}/report → 200
One run's outcome: ok (every copy done for source_version, the source's snapshot and schema), partial (cut short by the time bound: due again at once), waiting, unchanged, or error (each copy not rewritten, by code: only those retry, after a growing wait); each copy's state is merged; values left unsliced (a collision, the cap); the value map is append-only (409 value_map_conflict otherwise); new namespaces are audited. A report answers one claim (its `claim`): a late one of a run recorded as lost, or one for a deleted rule, is 409 stale_report / slice_not_active and changes nothing.
POST /internal/intakes → 200
A messaging intake on an existing sink, with its members: {sink_id, name, members: [{address, label}], addendum_version, accepted_by}; the sink's table must have the message columns, and an admin of the organisation (accepted_by, their email) must have accepted the WhatsApp addendum in force (addendum_version; 400 addendum_required).
GET /internal/intakes → 200
Every messaging intake, its members (numbers masked) and its held messages by state.
GET /internal/indexes → 200
For the poller (worker/index.py, in the sink-roller process): every active rule with its catalog's key pair, blob bucket, extractors and whether it is due.
POST /internal/indexes/{index_id}/batch → 202
The poller's records for one rule, stored in its internal sink by the sink's own store step; the batch id is the hash of the (key, etag) pairs, so a replay answers duplicate.
POST /internal/indexes/{index_id}/report → 200
One poll's outcome: when, objects listed and indexed, the watermark, the error.
GET /internal/sinks → 200
For the roller (worker/roll.py): every active or disabled sink with its catalog's key pair, buckets, roll policy and pending ledger, and `storage` (ok | gone | unattached | unknown: whether a missing table can be believed; the roller disables for table_missing only on ok), plus the sinks deleted in the last 7 days whose intake prefix it must purge. Empty while AL_SINKS_ENABLED is off.
POST /internal/sinks/{sink_id}/roll → 200
The roller reports one roll: the batch ids committed (marked committed with the snapshot id), rows, rejected rows, seconds, an optional disabled_reason (table_missing disables the sink, unless the catalog's storage is marked gone: then it holds); ledger rows committed more than 7 days ago are pruned.
POST /internal/sinks/{sink_id}/enable → 200
The roller, at most every 5 minutes per sink disabled for table_missing: the same as the operator's POST /v1/admin/sinks/{sink_id}/enable (the table loaded as the tenant and checked as a new sink's would be).
POST /internal/warehouses/{warehouse_id}/sink-hold → 200
For test harnesses: {seconds: 1..900} (required) holds one catalog's sinks, purges and blob index from the stack's roller (they are listed held, and the roller skips them) while the harness rolls that catalog itself (roll.py --once --warehouse <id>); {seconds: 0} ends the hold. A hold never lasts past 900 s from its start, however often it is renewed, and after it ends the catalog cannot be held again for 60 s (409 hold_cooldown). Hold before the catalog has work: a roller tick already in flight may still take it once. In memory: a restart ends every hold. Each hold and release is logged.
POST /internal/setup/token → 201
A self-hosted install's one-time setup token (`./al setup-link`, run by `./al bootstrap`): single use, AL_SETUP_TOKEN_TTL_SECONDS (an hour), a new one replaces the previous; 409 setup_done once the install has an install administrator, 404 on the managed service. Answers the token once, the link and the CLI line.
POST /internal/principals → 200
Operator: create a principal and its first key. email_verified: true (a human with an email only) vouches for the address, as a signup's code proves it; inviting needs a verified address.
POST /internal/principals/{principal_id}/suspend → 200
Operator: suspend or unsuspend a principal (the enforcement point).
POST /internal/api-keys/{key_id}/revoke → 200
Operator: revoke any key.
GET /internal/signup-codes/{email} → 200
Development stacks without a mail server: read a pending code.
GET /internal/public-images → 200
The S3 gateway's forward_auth for a photo (.jpg .jpeg .png .webp) read WITHOUT credentials from a catalog's file bucket: 200 only when the bucket (the host's first label for virtual-hosted S3, else the path's first segment, from the X-LHB-Host / X-LHB-Path headers the gateway sets) is the file bucket of a catalog whose organisation has the plan feature public_images; 403 with an S3 XML AccessDenied otherwise. Asked per request, so the operator's switch (PATCH /v1/admin/orgs/{org}/limits with overrides.public_images = 1 or 0; lhbox admin org set ORG --feature public_images=on) takes effect at once. Data types never ask: the gateway passes them to the store, and refuses every other type itself.
GET /internal/tls-ask → 200
Caddy's on-demand TLS gate for virtual-hosted S3 names (<bucket>.s3.<domain>, the shape Snowflake's S3-compatible volumes need), asked before a certificate is obtained or renewed: 200 only for a bucket of an existing catalog with virtual_host set (an admin's choice, public catalogs included) -- its table or blob bucket, looked up by name in the row's bucket / blob_bucket columns, so <handle>--<catalog>, …--blobs and the older w-/b-<uuid> are all served -- under the configured S3 host; 404 otherwise (not_virtual_host for a catalog that has not opted in; a deleting catalog included); 429 tls_issue_rate for a NEW name past AL_TLS_NEW_PER_WEEK (default 20) in 7 days, while a name approved before is always answered. Answers domain, bucket, warehouse_id, kind (tables|blobs), issue. Called on the docker network; never served publicly.
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.
POST /internal/usage → 200
The host's maintenance sweep (every 6 hours) reports one warehouse's footprint (bytes, objects, blob bytes and objects) and its S3 traffic counters. While the warehouse's latest figure is the store's and younger than an hour, the posted footprint is ignored (the row carries the store's figure and the posted counters; footprint: store, no quota written); otherwise it is the figure and recomputes the scope's storage quota (footprint: sweep).
POST /internal/deletions/purge-due → 200
The maintenance sweep (deploy/on_host_maintain.sh, every 6 hours) ends every grace period that has passed: each catalog whose purge_after is due goes to its storage teardown. Answers checked_at, purged[], purged_count, retained, next_purge_after, revocations_queued (credential revocations that failed earlier, queued again).
GET /internal/tier/plan → 200
The host's hourly tiering job (deploy/on_host_tier.sh) asks which buckets may move to the cold tier: one item per bucket of every ready tier_policy=cold catalog with the backend its tenant hashes to, plus the knobs (quiet_for, full_percent) it passes to volume.tier.upload.
POST /internal/tier/report → 200
The tiering job reports one run: per bucket the absolute cold footprint after it (cold_bytes, cold_volumes), what moved, seconds and the error text; recorded in tier_state and shown as stored.cold_bytes / cold.
GET /internal/lifecycle-audit → 200
The table-bucket guard, observable: as the admin, GET ?lifecycle on every catalog's TABLE bucket (expected 404 -- a rule there would be executed by the store's worker against Iceberg data) and GET ?policy on it (no statement may grant a lifecycle action). Lists offenders and errors; ok when both are empty; ?include_blobs=true also counts the blob buckets carrying rules. For the nightly sweep.
POST /internal/selftest/error → 200
Operator tool: raise one unhandled error on purpose to verify the error path end to end.