URI versioning
Send Ingot-Version to pin a response shape: omitted means latest, unknown is a 400, and every response echoes the one used.
http://localhost:3002 /api/v1/:account/:ingot Ingot-Version: 2026-09-17
Version 2026-09-17 · JSON REST · MCP support
Send Ingot-Version to pin a response shape: omitted means latest, unknown is a 400, and every response echoes the one used.
http://localhost:3002 /api/v1/:account/:ingot Ingot-Version: 2026-09-17
Required everywhere except health, versions and sign-up. Only a SHA-256 digest of a key is stored, so a secret is shown once and never again.
Authorization: Bearer ing_sk_…
7 GET, 9 POST and 3 DELETE, plus 2 MCP paths that take any method.
Service 2 Accounts & keys 3 Ingots 8 Data 6 MCP 2
# 1 — sign up; the secret comes back exactly once curl -X POST http://localhost:3002/api/v1/accounts \ -H "Content-Type: application/json" \ -d '{"slug":"acme","name":"Acme Inc"}' # 2 — cast an ingot; keep the id, it is the next path segment curl -X POST http://localhost:3002/api/v1/acme/cast \ -H "Authorization: Bearer ing_sk_…" \ -d '{"name":"crm-notes","retainFor":"14d"}' # 3 — read it back, the same second curl -X POST http://localhost:3002/api/v1/acme/ing_01H8Z…/query \ -H "Authorization: Bearer ing_sk_…" \ -d '{"sql":"SELECT company, arr FROM contacts"}'
| Code | When |
|---|---|
| 200 | Read succeeded — including the POSTs that change nothing, and the patch that returns the whole object. |
| 201 | Something was created: an account, a key, an ingot, a row. |
| 204 | Something was removed: a key, a table, an ingot. |
| 400 | The body is not the right shape — an unknown field, a retainFor that is not a number and a unit. |
| 401 | No key, an unknown key, or one that has been revoked. |
| 403 | The key is valid, but not for the account named in the path. |
| 404 | No such account, ingot or table. |
| 409 | The write conflicts with what is already there — a slug or a name taken. |
| 422 | Well formed and refused: more than one statement in sql, a column type that will not hold the value, a predicate that gets out of its brackets. |
| 503 | Nothing is wrong with the request. Something this service depends on would not play its part. |
/api/healthopenLiveness. Version-neutral so a load balancer never has to be updated when the contract is.
200 OK { "status": "ok", "service": "ingot" }
/api/versionsopenThe changelog of the wire contract: the header name, the latest release, every version, and what each one changed.
Public, because deciding whether to integrate with a service is something you do before you have a key.
200 OK { "header": "Ingot-Version", "latest": "2026-09-17", "versions": ["2026-08-26", "2026-08-27", "2026-09-06", "2026-09-15", "2026-09-17"], "changelog": [ { "version": "2026-09-17", "summary": "…", "changes": ["…"] } ] }
/api/v1/accounts/:accountkeyThe account and the keys on it — metadata only. prefix is the non-secret head of a key, which is what tells two of them apart.
200 OK { "slug": "acme", "name": "Acme Inc", "keys": [{ "id": "key_01H…", "label": "ci", "prefix": "ing_sk_7f2c…", "lastUsedAt": "2026-08-27T09:14:02Z", "revokedAt": null }] }
/api/v1/accounts/:account/keyskeyMint another key with a label. The secret is returned once, and only once.
{ "label": "staging-agent" } 201 Created { "id": "key_01J…", "label": "staging-agent", "prefix": "ing_sk_91ab…", "secret": "ing_sk_…" }
/api/v1/accounts/:account/keys/:keyIdkeyRevoke a key. It stops authenticating on the next request.
204 No Content
/api/v1/:account/castkeyCast a new ingot. Takes a name, an optional retainFor: a duration, because the question you are asking is "how long", and an optional externalId of your own.
Expiry deletes the ingot and everything in it, and that is not reversible. Omit it and the ingot is kept until something deletes it. Keep the id it hands back: that is the :ingot segment on every route below — an ingot is addressed by id, never by name. With an externalId — a conversation id, a job id — cast is idempotent: asking again answers 200 with the ingot that handle already names, and changes nothing about it.
{ "name": "crm-notes", "retainFor": "14d", "externalId": "chat_8f2c" } 201 Created { "id": "ing_01H8Z…", "name": "crm-notes", "externalId": "chat_8f2c", "tables": 0, "rows": 0, "expiresAt": "2026-09-10T11:02:00Z" }
/api/v1/:account/ingotskeyThe account's ingots, as an array. A literal segment, registered before the :ingot routes so a listing is not read as an ingot called “ingots”.
This is how you get an id back if you did not keep the one cast handed you.
200 OK [ { "id": "ing_01H8Z…", "name": "crm-notes", "tables": 3, "rows": 412, "expiresAt": "2026-09-10T…" }, { "id": "ing_01J2Q…", "name": "session-42", "tables": 1, "rows": 18, "expiresAt": null } ]
/api/v1/:account/:ingot/clonekeyCopy an ingot under a new id: every table, its current Parquet, the overlay, tombstones and vectors, read as of one instant. Takes what cast takes, all of it optional.
Afterwards the two share nothing — writes, roll-ups and deletes on one never reach the other. name defaults to the source’s and, without retainFor, the clone expires when the source does. Delivery settings are not copied. Refused with 409 while a document is still being parsed. With an externalId, asking again answers 200 with the clone already made.
{ "name": "crm-notes-experiment", "externalId": "exp_42" } 201 Created { "id": "ing_01J9K…", "name": "crm-notes-experiment", "externalId": "exp_42", "tables": 3, "rows": 412, "expiresAt": null }
/api/v1/:account/:ingot/infokeyThe information schema — what a model reads before it writes SQL.
Answered entirely from Postgres: no bucket read, no DuckDB session. pending is the rows still in the overlay, which a query already sees.
200 OK { "name": "crm-notes", "embedding": { "model": "text-embedding-3-small", "dimensions": 1536 }, "config": { "delivery": { "t": "none" } }, "tables": [{ "name": "contacts", "rows": 412, "pending": 27, "generation": 9, "key": ["id"], "columns": [ { "name": "company", "type": "VARCHAR", "embedded": false, "required": true }, { "name": "arr", "type": "DOUBLE", "embedded": false, "required": true } ] }] }
/api/v1/:account/:ingot/configkeyWhere this ingot’s receipts and table changes are pushed as they land, and how long it is kept. By default nothing is pushed and receiptQuery is the contract — set a target when whatever wanted the summary will have moved on by the time a model writes it.
One strategy per ingot, not per /add: the thing that wants telling is the system holding the ingot. A patch, so an omitted field leaves the current value alone — turning delivery off is { "t": "none" }. Endpoints must be absolute http/https; loopback, link-local and private addresses are refused, because this service would be reaching them from inside its own network. Table events are signals to read /pending, not the rows: writes that land while one is queued fold into it.
| retainFor | Delete this ingot that long from now — 30m, 12h, 14d, 4w — or null to keep it. Call it on each use to keep an ingot alive while it is. |
| delivery.events | What to push: receipt.ready (the default), operations.appended, table.rolled_up, table.dropped. |
| delivery.t | none, webhook or rmq. The discriminant — the other fields follow from it. |
| delivery.endpoint | For webhook: the absolute URL each receipt is POSTed to. |
| delivery.queue | For rmq: the queue name. The broker is the deployment’s (INGOT_RABBITMQ_URL), never the caller’s. |
{ "delivery": { "t": "webhook", "endpoint": "https://acme.dev/hooks/ingot" } } 200 OK { "delivery": { "t": "webhook", "endpoint": "https://acme.dev/hooks/ingot", "events": ["receipt.ready"] }, "expiresAt": null } # each receipt then arrives as POST https://acme.dev/hooks/ingot Ingot-Batch: batch_1508c8… { "event": "receipt.ready", "ingot": "ing_01H8Z…", "batch": "batch_1508c8…", "externalId": "call_42", "sourceTable": "contacts", "summary": "412 EMEA accounts, …", "searchTerm": "EMEA renewal risk", "totalResults": 412, "query": "SELECT external_id, summary, …", "model": "gpt-4.1-mini", "readyAt": "2026-09-06T11:02:04Z", "attempt": 1 }
/api/v1/:account/:ingot/config/:tablekeyHow a table is read, not what is in it: the stemmer, the stopwords, which columns are indexed, what is stripped before tokenising.
A patch, so sending one setting leaves the other six alone — and the whole TableConfig comes back, defaults included, because a caller who changed one field otherwise has no way to see the rest.
{ "fts": { "stemmer": "english", "stopwords": "none", "ignore": "[^a-z0-9]+", "columns": ["body"] } } 200 OK { "fts": { "enabled": true, "stemmer": "english", "stopwords": "none", "ignore": "[^a-z0-9]+", "stripAccents": true, "lowercase": true, "columns": ["body"] } }
/api/v1/:account/:ingot/tables/:tablekeyDrop one table from the ingot, its Parquet and its overlay rows with it.
204 No Content
/api/v1/:account/:ingotkeyDestroy the ingot and everything in it.
204 No Content
/api/v1/:account/:ingot/addkeyStore a tool result. It lands in the Postgres overlay, so it is queryable the moment this returns — nothing waits on a Parquet file being rewritten.
| columns | Maps JSON paths onto typed columns. A path or a constant, never both. |
| rows | Selects an array to fan out into one row each. Omitted, the blob is one row. |
| key | What identifies a row, so a receipt can hand back SQL that still finds it next week. |
| receipt | none, schema or full. The rungs escalate, and so does what each costs — full is a model call, so summary and searchTerm come back null under a pending status, with the SELECT that will answer them. |
| columns[].embed | Embeds that column’s text. A property of the table rather than of the call — set once when the column is declared, and applied to every later write whether or not it repeats the flag. VARCHAR only; anything else is refused rather than quietly ignored. Turning it on for an existing column only affects rows written from then on: the ones already stored are not embedded and nothing backfills them yet. |
| result | The tool result itself. Anything JSON, null included. |
{ "table": "contacts", "rows": "$.contacts[*]", "columns": { "id": { "from": "$.id", "type": "VARCHAR" }, "company": { "from": "$.org.name", "type": "VARCHAR" }, "arr": { "from": "$.deal.arr", "type": "DOUBLE" }, "notes": { "from": "$.notes", "type": "VARCHAR", "embed": true } }, "key": ["id"], "receipt": "full", "result": toolResult } 201 Created { "table": "contacts", "rowsAdded": 412, "columnsAdded": ["notes"], "queuedForEmbedding": 412, "payload": { "kilobytes": 84.2, "estimatedTokens": 21507 }, "receipt": { "status": "pending", "model": "gpt-4.1-mini", "summary": null, "searchTerm": null, "batch": "batch_1508c8…", "receiptQuery": "SELECT … FROM ingot_receipts WHERE source_batch = 'batch_1508c8…'", "key": ["id"], "items": [ { "key": { "id": "c_91" }, "query": "SELECT * FROM contacts WHERE id = 'c_91'" } ] } } # seconds later, receiptQuery answers { "summary": "412 EMEA accounts, 17 at risk", "search_term": "EMEA renewal risk" }
/api/v1/:account/:ingot/filekeyStore a document. It returns the moment the bytes are stored and queued; parsing, chunking and embedding follow in the background, so nothing in the response is the content.
What comes back is two SELECTs rather than a status to poll. query returns no row while the document is in flight and exactly one when it lands, ready or failed; chunksQuery returns its chunks in order once there are any.
| file | The document, as a multipart part named file. One per call, up to INGOT_MAX_UPLOAD_BYTES — 32 MB unless the deployment says otherwise. |
| body | Every option below, as one JSON string in a part named body. Omitted, the document is parsed, chunked and embedded with no extraction, which is what most uploads want. |
| externalId | Your own handle for the document — a job id, a ticket. |
| mediaType | What the document is, when the upload cannot say: a client that sends application/octet-stream for everything, a generated name, a .txt that is really CSV. It overrides what the upload declares, never what the bytes say — a mismatch is still refused. |
| extract | Typed rows into a table of your own, through the same mapping /add uses. A CSV’s from paths resolve against its own fields and call no model; prose has no paths, so each column needs describe and a model fills it. |
| chunkTokens | Roughly how large a chunk is, and overlapTokens how much of the previous one it repeats — the deployment’s defaults, 512 and 64, unless set. Overlap is ignored where a format’s own boundaries decide: a slide does not overlap the next. |
curl localhost:3002/api/v1/acme/ing_01H8Z…/file \ -H "Authorization: Bearer ing_sk_…" \ -F "file=@q3-contracts.pdf" \ -F 'body={ "externalId": "job-4471", "extract": { "table": "contracts", "columns": { "counterparty": { "describe": "who the contract is with", "type": "VARCHAR" } } } }' 201 Created { "fileId": "file_3f9c1a…", "filename": "q3-contracts.pdf", "mediaType": "application/pdf", "bytes": 482113, "status": "pending", "query": "SELECT … FROM ingot_files WHERE file_id = 'file_3f9c1a…'", "chunksQuery": "SELECT … FROM ingot_file_chunks WHERE file_id = 'file_3f9c1a…' ORDER BY ordinal", "extractingInto": "contracts" }
/api/v1/:account/:ingot/querykeyRead it back. sql is run as written — exactly one SELECT, in a locked-down DuckDB session, over a view unioning the overlay with the Parquet base. text is embedded and ranks a table by similarity.
Given both, the embedding is bound as $q and your SQL may use it, which is how a hybrid search is one round trip rather than two. A result cut short by limit carries next; send it back as cursor with the same query for the rows after it. It is an offset, so writes between pages move what follows, as LIMIT … OFFSET would. A POST that changes nothing, hence the explicit 200.
# structured { "sql": "SELECT company, arr FROM contacts WHERE stage = 'won' ORDER BY arr DESC" } # or in words { "text": "renewal risk in EMEA", "table": "notes", "column": "body" } 200 OK { "columns": ["company", "arr"], "rows": [ { "company": "Northwind", "arr": 84000 } ], "truncated": false, "next": null, "elapsedMs": 34 }
/api/v1/:account/:ingot/deletekeyForget the rows matching a where predicate. It is resolved to row ids and those are written as tombstones, so a later query filters against a finite set rather than a growing list of predicates.
A POST rather than a DELETE because it carries a body, and a body on a DELETE is a thing intermediaries drop.
{ "table": "contacts", "where": "stage = 'lost'" } 200 OK { "table": "contacts", "rowsForgotten": 17, "truncated": false }
/api/v1/:account/:ingot/tables/:table/pendingkeyWhat the next roll-up will fold in: rows still in the Postgres overlay, oldest first, and rows forgotten since the last roll-up. All of it is already visible to /query.
Rows are paged by sequence — pass next back as after, with limit up to 10,000 (1,000 by default). Tombstones are never paged: they apply to the Parquet as well, and applying some of them is wrong. Each page is one snapshot, and base names the files of the generation it is pending against; if the generation changes between pages, a roll-up happened — start again.
GET …/tables/contacts/pending?limit=500 200 OK { "table": "contacts", "generation": 9, "base": [ { "part": 1, "rows": 40210, "bytes": 1893044 } ], "rows": [ { "rowId": "…", "seq": "80412", "ingestedAt": "2026-09-15T09:12:03.114Z", "values": { "id": "c_91", "company": "Northwind", … } } ], "tombstones": [ { "rowId": "…", "at": "2026-09-15T09:40:55.020Z" } ], "next": "80912" }
/api/v1/:account/:ingot/tables/:table/parquetkeyThe table’s base tier as the Parquet file itself, streamed from the bucket rather than rebuilt. ?generation=&part= names one; the current generation’s first part otherwise.
The file is the last roll-up and nothing since: overlay rows are not in it, and rows forgotten after it was written still are. Ingot-Tombstones says how many; /pending lists them. A 404 until the table has been rolled up once. Honours Range, so a DuckDB can read it remotely. A replaced generation stays readable for INGOT_GENERATION_GRACE_MS (an hour) and is then a 410 — read /pending again.
GET …/tables/contacts/parquet?generation=9&part=1 Range: bytes=-65536 206 Partial Content Content-Type: application/vnd.apache.parquet Content-Range: bytes 1827508-1893043/1893044 Accept-Ranges: bytes Ingot-Generation: 9 Ingot-Part: 1 Ingot-Tombstones: 17
/api/v1/:account/mcpkeyAccount-wide MCP, for a client that has not been handed an ingot yet. Cast one, then reconnect to the scoped path below.
/api/v1/:account/:ingot/mcpkeyMCP scoped to one ingot: the tools take no ids and cannot reach another. Stateless — a fresh server per request, no session pinned to a replica.
The same bearer key and the same two guards as every other route; there is no MCP-specific auth path, which is the point. The schema is handed over as the server’s instructions at initialize, so writing SQL costs no tool call.
# claude_desktop_config.json { "mcpServers": { "ingot": { "url": "http://localhost:3002/api/v1/ acme/ing_01H8Z…/mcp", "headers": { "Authorization": "Bearer ing_sk_…" } } } }
Sign up, mint a key, cast an ingot. The secret comes back exactly once.
curl -X POST http://localhost:3002/api/v1/accounts