Ingot
API v1Get the source

Similarity is
not a join

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.

What happens today

Three ways to lose
a tool result

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.

KEPT

It stays in the window

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 tokens
SUMMARISED

A model writes a paragraph about it

The 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 final
EMBEDDED

It is chunked into a vector store

Now 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 more
All three lose the same thing, which is the structure. The fact that this was four hundred rows of six typed fields, and that you could have asked a real question of them.
Why SQL

Models write SQL.
Let them.

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

≡ ×Ingot · one ingot, three tools
# 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
}
SCOPE

Every table of the ingot is in scope

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)
SANDBOX

The worst case is a slow SELECT

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()
SCHEMA

It is told what is there first

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/info
Where the embeddings went

Not a database.
A column.

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

one SELECTcosineBM25a real WHEREno second store
How retrieval works, on the landing page →
# 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"
}
One ingot per what

One ingot per
whatever you say

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.

retainFor: "30m"

Per chat

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.

retainFor: "12h"

Per run

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.

retainFor: "4w"

Per project

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.

no retainFor

Per user, per tenant, per agent

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.

POST /:account/castPOST /:account/:ingot/cloneGET /:account/ingotscast_ingotclone_ingotlist_ingotsdelete_ingot
What it costs to keep

Cheap enough
to keep it all

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.

01

The overlay

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 arrival
02

The base tier

Every 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 table
03

The engine

DuckDB 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 costsWhat that is
Bucket bytesParquet, columnar and compressed. This is the ingot at rest, and the only thing an idle one costs.
A PostgresThe 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 runsA 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 vectorEmbeddings 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 ingotCasting an ingot writes a row. Ten thousand scratch ingots that expire tonight are ten thousand rows tonight and nothing tomorrow.
Nothing while idleNo 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.
Self-deployed

Your bucket.
Your rows.

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.

your bucketyour PostgresParquetno export stepno vendor
The argument is a repository

Every claim above is a file you can go and disagree with

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.