Ingot
API v1Get the source
[ API reference · v1 ]

The Ingot HTTP API

Version 2026-09-17 · JSON REST · MCP support

BASE URL

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
AUTH

One bearer key

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_…
SURFACE

21 routes

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
Quickstart
≡ ×Three calls
# 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"}'
Status codes
CodeWhen
200Read succeeded — including the POSTs that change nothing, and the patch that returns the whole object.
201Something was created: an account, a key, an ingot, a row.
204Something was removed: a key, a table, an ingot.
400The body is not the right shape — an unknown field, a retainFor that is not a number and a unit.
401No key, an unknown key, or one that has been revoked.
403The key is valid, but not for the account named in the path.
404No such account, ingot or table.
409The write conflicts with what is already there — a slug or a name taken.
422Well 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.
503Nothing is wrong with the request. Something this service depends on would not play its part.
Reference

Endpoints

[ Service · version-neutral ]2 routes
GET/api/healthopen

Liveness. Version-neutral so a load balancer never has to be updated when the contract is.

200 OK
{ "status": "ok", "service": "ingot" }
GET/api/versionsopen

The 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": ["…"] } ] }
[ Accounts & keys ]3 routes
GET/api/v1/accounts/:accountkey

The 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 }] }
POST/api/v1/accounts/:account/keyskey

Mint 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_…" }
DELETE/api/v1/accounts/:account/keys/:keyIdkey

Revoke a key. It stops authenticating on the next request.

204 No Content
[ Ingots ]8 routes
POST/api/v1/:account/castkey

Cast 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.

30m12h14d4w
{ "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" }
GET/api/v1/:account/ingotskey

The 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 } ]
POST/api/v1/:account/:ingot/clonekey

Copy 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 }
GET/api/v1/:account/:ingot/infokey

The 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 } ] }] }
POST/api/v1/:account/:ingot/configkey

Where 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.

retainForDelete 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.eventsWhat to push: receipt.ready (the default), operations.appended, table.rolled_up, table.dropped.
delivery.tnone, webhook or rmq. The discriminant — the other fields follow from it.
delivery.endpointFor webhook: the absolute URL each receipt is POSTed to.
delivery.queueFor 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 }
POST/api/v1/:account/:ingot/config/:tablekey

How 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"] } }
DELETE/api/v1/:account/:ingot/tables/:tablekey

Drop one table from the ingot, its Parquet and its overlay rows with it.

204 No Content
DELETE/api/v1/:account/:ingotkey

Destroy the ingot and everything in it.

204 No Content
[ Data ]6 routes
POST/api/v1/:account/:ingot/addkey

Store 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.

columnsMaps JSON paths onto typed columns. A path or a constant, never both.
rowsSelects an array to fan out into one row each. Omitted, the blob is one row.
keyWhat identifies a row, so a receipt can hand back SQL that still finds it next week.
receiptnone, 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[].embedEmbeds 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.
resultThe 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" }
POST/api/v1/:account/:ingot/filekey

Store 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.

PDFPPTXCSVHTMLMarkdownplain text
fileThe document, as a multipart part named file. One per call, up to INGOT_MAX_UPLOAD_BYTES — 32 MB unless the deployment says otherwise.
bodyEvery 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.
externalIdYour own handle for the document — a job id, a ticket.
mediaTypeWhat 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.
extractTyped 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.
chunkTokensRoughly 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" }
POST/api/v1/:account/:ingot/querykey

Read 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 }
POST/api/v1/:account/:ingot/deletekey

Forget 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 }
GET/api/v1/:account/:ingot/tables/:table/pendingkey

What 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" }
GET/api/v1/:account/:ingot/tables/:table/parquetkey

The 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
[ MCP over streamable HTTP ]2 routes
ALL/api/v1/:account/mcpkey

Account-wide MCP, for a client that has not been handed an ingot yet. Cast one, then reconnect to the scoped path below.

cast_ingotclone_ingotlist_ingotsdelete_ingot
ALL/api/v1/:account/:ingot/mcpkey

MCP 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.

describerememberqueryrecallpendingforgetconfigure_tableconfigure_deliverydrop_table
# claude_desktop_config.json
{ "mcpServers": { "ingot": {
    "url": "http://localhost:3002/api/v1/
           acme/ing_01H8Z…/mcp",
    "headers": { "Authorization":
      "Bearer ing_sk_…" } } } }
Get started

Self Hosted

Sign up, mint a key, cast an ingot. The secret comes back exactly once.

Read the quickstartcurl -X POST http://localhost:3002/api/v1/accounts