Ingot
API v1Get the source
What it is

Memory your
model can query

Models are good at writing SQL. Why rely on similarity searches, when you can let it ask for exactly what it needs?

Features
RECALL

Somewhere to put a tool result

Ingot is a drop-in memory for an agent loop. Tool results can clog up your context window. Ingot gives you a dedicated place to store and retrieve them efficiently.

SIZE

The result that would not have fit

Some tools return payloads big enough to overwhelm your context window. Cast them to an ingot and the model only sees a concise receipt, so the context window stays manageable.

PRECISION

SQL gives exact answers

Naive RAG can give imprecise results, because it relies on approximate similarity search. SQL gives exact answers, so the model retrieves the correct data every time.

[ Benchmark ]

Structured beats
similarity

33 questions across 6 task classes with 3 runs each.

ingot (via MCP)RANK 1
97%±2
OVERALL ACCURACY — WEAKEST CLASS ABSENCE, 78%
6,319
CTX TOKENS
3.3
TOOL CALLS
44%
EV. PRECISION
ingot (via API)RANK 2
96%±2
OVERALL ACCURACY — WEAKEST CLASS AGGREGATE, 89%
3,815
CTX TOKENS
2.8
TOOL CALLS
39%
EV. PRECISION
vectorBASELINE
69%±5
OVERALL ACCURACY — WEAKEST CLASS ORDERING, 33%
46,732
CTX TOKENS
8.0
TOOL CALLS
4%
EV. PRECISION
control-same-store-top-kCONTROL
40%±5
OVERALL ACCURACY — WEAKEST CLASS MULTI-HOP, 11%
37,042
CTX TOKENS
7.6
TOOL CALLS
6%
EV. PRECISION
Every column, and what it does not measure →
[ Two calls ]

Write it once,
once, query it back

No embedding step, and no re-reading the transcript. The tool result immediately becomes a table the model can select from.

≡ ×Ingot · crm-notes
# 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
}
Who it’s for

Built for agents that call tools

For teams building their own agent harness for production apps, where the tools return real records and the answers have to be right.

For you ifGood fit
  • Your agent calls tools that return JSON lists: CRM contacts, CI runs, tickets, issues, orders.
  • It needs exact answers from what it stored: how many, which was first, what's missing.
  • Answers span more than one tool, joined on an id, a name or a path.
  • Tool results are too big for the context window.
  • You care about cost: fewer tokens per answer, and a smaller, cheaper model that can still get it right.
  • You want agent memory you can drop in, not a retrieval stack to assemble and run.
Probably not ifUse a vector store
  • Its memory is mostly conversation or notes with no fields to query.
  • You only use Claude Code, or another agent you didn't build. You can use Ingot with these, but it's not where you'd get the most value from it.

Ingot still does similarity search, so a mix of both is fine. See how they combine.

What it’s not

RAG Replacement

“RAG” can encompase complex document processing pipelines, but Ingot focuses solely on the storage and retrieval aspect, leaving the rest to other components.

The same rows through top-k alone, measured →
The jobA full RAG stackIngot
What it replaces
Chunk documentsA loader and a splitter in front of the store./file chunks per format, and can pull typed rows out of the same file.
EmbedA pipeline writing vectors into another system.Handled automatically, via an opt-in per table/tool result type
Store vectorsA vector database beside your data.The same tables as the rows. No second database.
Retrievetop_k(embedding).SQL, with cosine and BM25 as ranking functions inside it.
Count, aggregate, sort by timeNo nearest-neighbour formulation.A GROUP BY and an ORDER BY.
Join sourcesOne index per query.One SELECT across tables — chunks, files and typed rows together.
What goes inDocuments.Tool results, documents or external data you want to pre-populate the table with.
What it leaves out
Rerank and rewriteA reranker, often a query rewriter.Neither. Hybrid means the SQL you wrote ranks on BM25 and cosine together.
ANN indexHNSW or similar.Currently none. Cosine similarity is a good trade-off until the low millions of rows per table.
SchemaNone asked for.Defined dynamically when adding a memory. It can be inferred or declared explicitly.
How it works

Cast it, organize it,
read it back

First create an ingot, then add memories to it.
Its will then be available to query.

The same thing as a file you can type ↓
01

Cast an ingot

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/cast
02

Store tool results

Map 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/add
03

Query or recall

One SELECT, against a sandboxed DuckDB that unions the fresh rows with the Parquet. Or skip the SQL and ask in words.

POST /:account/:ingot/query
≡ ×One turn
01 · Harness

Dispatches the tool

Unchanged. The model asks for a tool, your harness runs it, and nothing about that hop knows Ingot exists.

toolCallId: call_01H8Z…
02 · Your tool -> Ingot

Sends the result to the ingot

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/add
03 · Ingot -> your tool

Answers with a receipt

How 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 rows
04 · Model -> Ingot

Reads back what it needs

Narrowed 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/query
The rows outlive the turn. A receipt hands back SQL rather than an id, so it still finds them from a context window that never saw them go in.
Vercel AI SDK Example

It fits inside
one execute

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

≡ ×Ingot · ai-sdk
// 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
}
STREAM

The write is cheap

/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: pending
LOOP

Give it something to run SQL with

You will need to include the query tool for this to really be useful.

tools: { query: ingot.tools.query }
YOUR UI

The browser can still have the rows

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"
}
Receipts & embeddings

Opt in to the
expensive parts.

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.

embed per tablereceipt per callwebhookrabbitmq
Retrieval, and RAG

RAG retrieval
within the same service.

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.

one SELECTcosineBM25metadata filterno vector db
Why a query engine, and not a vector store →
# 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"
}
Self-hosted, for now

Open Source with Self-hosting available.

Host it yourself! Check out the deployment guide in the documentation.