Models are good at writing SQL. Why rely on similarity searches, when you can let it ask for exactly what it needs?
Ingot is a drop-in memory for an agent loop. Tool results can clog up your context window. We can give you a dedicated place to store and retrieve them efficiently.
Some tools can return huge payloads that can overwhelm your context window. When casting them to the ingot, the model only sees a concise receipt, keeping your context window manageable.
Naive RAG can give you imprecise results, because it relies on approximate similarity searches. SQL, on the other hand, gives exact answers, ensuring that the model retrieves the correct data every time.
# 1 — remember a tool result 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": toolResult } 201 Created · rowsAdded 412
# 2 — read it back, the same second POST /api/v1/acme/ing_01H8Z…/query { "sql": "SELECT company, arr FROM contacts WHERE stage = 'won' ORDER BY arr DESC" } 200 OK · 34ms { "columns": ["company", "arr"], "rows": [ { "company": "Northwind", "arr": 184000 }, { "company": "Contoso", "arr": 96500 } ], "truncated": false }
“RAG” names two things that come apart: store documents so a model can find them, and put the top k chunks in the prompt. Ingot is the first. It replaces the half of the stack that stores and finds, and does no part of the half that writes the answer.
The same rows through top-k alone, measured →| The job | A full RAG stack | Ingot |
|---|---|---|
| What it replaces | ||
| Chunk documents | A loader and a splitter in front of the store. | /file chunks per format, and can pull typed rows out of the same file. |
| Embed | A pipeline writing vectors into another system. | Opt in per table. A sweeper works the queue. |
| Store vectors | A vector database beside your data. | The same tables as the rows. No second database. |
| Retrieve | top_k(embedding). | SQL, with cosine and BM25 as ranking functions inside it. |
| Count, aggregate, sort by time | No nearest-neighbour formulation. | A GROUP BY and an ORDER BY. |
| Join sources | One index per query. | One SELECT across tables — chunks, files and typed rows together. |
| What goes in | Documents. | An agent’s own tool results, typed. Documents are a second way into the same tables. |
| What it leaves out | ||
| Write the answer | Top-k chunks into the prompt, then a model call. | Not done here. Rows come back; the agent writes the answer. |
| Rerank and rewrite | A reranker, often a query rewriter. | Neither. Hybrid means the SQL you wrote ranks on BM25 and cosine together. |
| ANN index | HNSW or similar. | None. Brute-force cosine, a good trade until the low millions of rows per table. |
| Schema | None asked for. | Required up front for /add. A real cost, and one the benchmark does not put a number on. |
Three calls is the whole loop. That is genuinely it — everything else you might want, like keys, retention, schema or MCP, hangs off the same bearer token.
The same thing as a file you can type ↓One POST. Name it, say how long it lives — 30m for a session, 4w for a project — and keep the id it hands back.
POST /:account/castMap JSON paths onto typed columns and fan an array into rows. Set a key if you want upserts. Ask for a receipt and you get something small enough to hand back to the agent.
POST /:account/:ingot/addOne SELECT, against a sandboxed DuckDB that unions the fresh rows with the Parquet. Or skip the SQL and ask in words.
POST /:account/:ingot/queryUnchanged. The model asks for a tool, your harness runs it, and nothing about that hop knows Ingot exists.
toolCallId: call_01H8Z…One POST, before you return. Send the tool-call id along as externalId and you can ask for this receipt back later by a name that means something to you.
POST /:ingot/addHow many rows, which table, and a SELECT that returns exactly them. Return that as the tool output — it goes in the slot the blob would have filled.
201 · 412 rowsNarrowed over typed columns, in this step or in a session next week. Twenty rows out of four hundred, picked by the model — not by whoever wrote the tool six months ago.
POST /:ingot/queryThere is no adapter here, and no middleware. execute already returns whatever the model is going to read, so have it return the receipt instead of the rows — and the rest of your harness, the stream and the parts and the steps, never finds out anything changed.
// tool.ts — AI SDK 5. // post() is fetch with the bearer key on it. import { tool } from 'ai'; import { z } from 'zod'; const INGOT = 'http://localhost:3002/api/v1/acme'; const ingot = INGOT + '/ing_01H8Z…'; export const searchContacts = tool({ description: 'Search the CRM by stage.', inputSchema: z.object({ stage: z.string() }), async execute({ stage }, { toolCallId }) { const result = await crm.contacts.search({ stage }); const { receipt } = await post(ingot + '/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'], externalId: toolCallId, receipt: 'full', result, }); // The 412 contacts stay in the ingot. This // is what goes back in their place. return { rows: receipt.totalResults, table: receipt.table.name, query: receipt.query, }; }, });
# the tool-result part, as the model reads it { "rows": 412, "table": "contacts", "query": "SELECT * FROM contacts WHERE source_batch = 'batch_1508c8…'" } # about 180 tokens. The result it stands in # for was 412 objects and about 48,000. # the browser is handed the same small object, # as the stream's tool-output-available part — # it is one JSON either way, and this one fits # next step — the model narrows it itself POST /api/v1/acme/ing_01H8Z…/query { "sql": "SELECT company, arr FROM contacts WHERE source_batch = 'batch_1508c8…' AND arr > 100000 ORDER BY arr DESC LIMIT 20" } 200 OK · 31ms { "columns": ["company", "arr"], "rows": [ { "company": "Northwind", "arr": 184000 }, … ], "truncated": false }
/add returns the moment the rows land. The summary is written behind it and the receipt says pending, so your tool result is never sitting there waiting on a second model to finish a sentence.
receipt.status: pendingA receipt hands back a SELECT, which is only worth having if the model can run one. Wrap /query as a second tool, or point it at the MCP server and write neither.
stopWhen: stepCountIs(8)Nothing headed for the interface has to go through the context window. Return the full result from execute and hand the model the receipt from toModelOutput. One gets streamed, the other gets read.
toModelOutput()# opt in: per table, and per call POST /api/v1/acme/ing_01H8Z…/add { "table": "notes", "rows": "$.notes[*]", "columns": { "body": { "from": "$.body", "type": "VARCHAR", "embed": true } }, "receipt": "full", "result": toolResult } 201 Created · queuedForEmbedding 412 { "receipt": { "status": "pending", "model": "gpt-4.1-mini", "summary": null, "searchTerm": null, "receiptQuery": "SELECT … WHERE source_batch = 'batch_1508c8…'" } } # seconds later, that query answers { "summary": "412 call notes, 17 flagging renewal risk in EMEA", "search_term": "EMEA renewal risk" }
We split these two on purpose. embed belongs to the table: set it once when the table is declared and it applies to every write after that. receipt is per call, because it costs a model call every time. A loop storing ten thousand tool results should never end up paying for either by accident.
A receipt comes back pending, with the SELECT that will answer it. The model writing the summary is a network away; your rows are queryable the instant /add returns. Point the ingot at a webhook or a queue and you get told instead of having to ask.
text on its own embeds the question and ranks a table by cosine similarity, so rows come back carrying a score. SQL on its own is exact. Send both and the embedding binds as $q, so one SELECT can rank by meaning, match BM25 and filter on real columns at the same time.
That is normally three pieces of infrastructure: a vector store, a metadata index, and a filtering hop between them. We did not want to run any of those, so here it is one POST against the ingot you were already writing to. Neither half is on by default — you turn on embeddings and the keyword index per table, so a table holding no prose pays for neither.
# once — switch keyword indexing on POST /api/v1/acme/ing_01H8Z…/config/notes { "fts": { "enabled": true } } # ask by meaning. Rows come back scored. POST /api/v1/acme/ing_01H8Z…/query { "text": "renewal risk in EMEA", "table": "notes", "column": "body" } 200 OK { "columns": ["id", "body", "region", "score"], "rows": [{ "score": 0.83, … }] } # or all three at once — one round trip { "text": "renewal risk in EMEA", "sql": "SELECT body, region, array_cosine_similarity(body_vec, $q) AS near, fts_main_notes.match_bm25(_row_id, 'renewal') AS words FROM notes WHERE region = 'EMEA' AND created > '2026-01-01' ORDER BY near DESC LIMIT 8" }
# claude_desktop_config.json { "mcpServers": { "ingot": { "url": "http://localhost:3002/api/v1/acme/ing_01H8Z…/mcp", "headers": { "Authorization": "Bearer ing_sk_…" } } } }
MCP over streamable HTTP, stateless, behind the same bearer key and the same guards. The schema is handed over as the server’s instructions at initialize, so getting to the point where the model can write SQL costs no tool call at all.
There is no hosted Ingot yet, and we would rather say that at the top than let you find out three scrolls down. It is a NestJS service, a Postgres and a bucket. However you choose to run those three, sign-up is the same open POST, and the secret still comes back exactly once.