A vector store answers exactly one question: what is this like? That is rarely the question an agent actually has. The real ones are joins — which of these also, how many, in what order, compared to when. Models write SQL well enough to ask all of those, so we think an ingot’s job is to hold tool results as tables and then get out of the way.
You have watched this happen. A tool returns four hundred rows of structured JSON, and by the next turn one of these three things has happened to it. You cannot query any of them.
Four hundred objects, read once and paid for on every turn after that. Until the window gets trimmed, and then it is as if the call never happened at all.
about 48,000 tokensThe prose survives, the numbers do not. Nothing downstream can filter it, sort it or count it, and the rows it was written from are already gone.
lossy, and finalNow there is one question you can ask of it, and you have to ask it by example: what is this like? Not how many. Not which of these also. Not in what order.
top-k, and no moreIt is the most written-down query language there is, and a model is fluent in it in a way it will never be fluent in your retrieval API. It also fails loudly, which is the part we care about most: a SELECT either returns rows or it errors with a reason, and a model that got it wrong can narrow it and try again. A ranking always returns something. Being wrong looks exactly like being right — and that is a horrible property in a system you are trying to learn to trust.
# three tools. one ingot. three tables. POST /api/v1/acme/ing_01H8Z…/add { "table": "contacts", "rows": "$.contacts[*]", "columns": { "id": { "from": "$.id", "type": "VARCHAR" }, "company": { "from": "$.org.name", "type": "VARCHAR" }, "arr": { "from": "$.deal.arr", "type": "DOUBLE" } }, "key": ["id"], "result": crmResult } POST /api/v1/acme/ing_01H8Z…/add { "table": "invoices", "rows": "$.data[*]", "columns": { "account": { "from": "$.customer", "type": "VARCHAR" }, "due": { "from": "$.due_date", "type": "DATE" }, "status": { "from": "$.status", "type": "VARCHAR" } }, "result": stripeResult } POST /api/v1/acme/ing_01H8Z…/add { "table": "tickets", "rows": "$.tickets[*]", "columns": { "account": { "from": "$.org", "type": "VARCHAR" }, "opened": { "from": "$.created", "type": "DATE" }, "subject": { "from": "$.subject", "type": "VARCHAR" } }, "result": deskResult }
# a question no tool call could have answered POST /api/v1/acme/ing_01H8Z…/query { "sql": "SELECT c.company, c.arr, count(*) AS raised FROM invoices i JOIN contacts c ON c.company = i.account JOIN tickets t ON t.account = i.account WHERE i.status = 'past_due' AND t.opened BETWEEN i.due AND i.due + INTERVAL '30 days' GROUP BY 1, 2 ORDER BY c.arr DESC" } 200 OK · 41ms { "columns": ["company", "arr", "raised"], "rows": [ { "company": "Northwind", "arr": 184000, "raised": 7 }, { "company": "Contoso", "arr": 96500, "raised": 3 } ], "truncated": false }
A statement gets offered every table its ingot holds, and the engine narrows to the ones it actually names. Three tools that have never heard of each other are three tables in one FROM clause.
sessions.all(tables)One statement, and it has to be a read. No ATTACH, no COPY, no second statement, no writes. The one thing that does run has a row cap and a timeout on it.
assertStartsAsSelect()Tables, columns and types come out of Postgres with no bucket read behind them, so asking is cheap enough to do every turn. Over MCP they arrive as the server’s instructions, before the model has spent a single tool call.
GET /:ingot/infoWe use embeddings. None of this is an argument against them — it is an argument about where they belong. A vector is a column sitting beside the row it was made from, and array_cosine_similarity(body_vec, $q) is an expression in a SELECT list like any other.
So meaning becomes one predicate in a statement that also joins two tables, filters on a real date, and counts. The setup with a vector store bolted on the side cannot write that statement at all: the vectors are over there, the columns are over here, and the only thing that ever crosses between them is a list of ids.
# rank by meaning, inside a join POST /api/v1/acme/ing_01H8Z…/query { "text": "unhappy about the renewal price", "sql": "SELECT c.company, c.arr, n.body, array_cosine_similarity(n.body_vec, $q) AS near FROM notes n JOIN contacts c ON c.id = n.contact_id WHERE c.arr > 100000 AND n.written > '2026-01-01' ORDER BY near DESC LIMIT 10" }
Ingot has no opinion about what an ingot is for. Casting one is a POST with a name and a retention, so the boundary can just be the boundary your system already has — a chat, a run, a project, a tenant.
A scratch ingot for one conversation. This session’s tool results, joinable to each other and to nothing else, gone half an hour after the last one lands. Nobody has to run a cleanup.
One agent run, one batch, one incident. Long enough that a retry an hour later reads what the first attempt wrote, and short enough that a failed run is not something you have to go and tidy up.
What a piece of work accumulates: every tool that touches the project writing into tables of the same ingot, and a month in which to ask questions across all of them.
Kept until something deletes it. That is the right answer when the lifetime is somebody’s account rather than a clock — and expiry is opt-in precisely because you cannot undo it.
A statement sees the tables of one ingot, and there is no query across two. So this is the one decision worth making deliberately: the grain you pick is the grain you can join across. Casting an ingot is one POST, though, so it is also a decision you are allowed to change your mind about.
An LSM tree, and nothing more exotic than that. None of it is resident: no index to keep warm, no cluster sized to the corpus, and nothing that bills you per vector.
Rows land in Postgres and are queryable the same second, next to the manifest that says where the folded ones went. This is the only tier a write ever touches.
queryable on arrivalEvery five minutes a sweeper folds the overlay into a new Parquet generation — one file per table, columnar and compressed, in a bucket you named.
one file per tableDuckDB is a library inside the process, never a server. A query builds an in-memory session from the manifest, runs one statement against it, and throws the whole thing away.
nothing stays warm| What an ingot costs | What that is |
|---|---|
| Bucket bytes | Parquet, columnar and compressed. This is the ingot at rest, and the only thing an idle one costs. |
| A Postgres | The catalogue, the rows written since the last roll-up, and the queues. Almost certainly a Postgres you were already running for something else. |
| CPU, while a query runs | A DuckDB session is built from the manifest, used once, and dropped. Between two queries an ingot is consuming nothing you could scale up even if you wanted to. |
| Nothing per vector | Embeddings are Parquet in the same bucket, keyed by _row_id beside the rows they belong to. There is no per-dimension price and no index node. |
| Nothing per ingot | Casting an ingot writes a row. Ten thousand scratch ingots that expire tonight are ten thousand rows tonight and nothing tomorrow. |
| Nothing while idle | No index to keep warm, no cluster sized to the corpus, no minimum. An ingot nobody is querying is some Parquet in a bucket. |
# ingot is not running. the ingot still is. # the highest gen- directory is the whole table. $ duckdb D SELECT company, sum(arr) AS book FROM read_parquet( 'acct_…/ing_…/tables/contacts/gen-000003/*.parquet' ) GROUP BY 1 ORDER BY book DESC; # there is no export step, because there was # never a second format to export from. what is # missing is the last five minutes, which are # still in your Postgres.
There is no hosted Ingot, and on this page that is the point rather than the caveat. The Postgres is yours, the bucket is yours, and what sits in the bucket is Parquet. Not an index. Not a proprietary segment file. Not something that needs this service running before you can read it.
Which means there is no export step, because there is no second format to export from. A table’s current generation is one file, and anything that reads Parquet reads it — DuckDB on your laptop, pandas, Spark, whatever you already pay for. If Ingot stops, the ingot does not.
The join is the query handler, the five minutes is a sweeper, the sandbox is two hundred lines of guard, and the bucket layout is one object. Nothing on this page is a position the code does not already hold — so if you think we have got one of them wrong, the place to say so is the repository.