Ingot
API v1Get the source

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?

Speaks
SQL QueriesSimilarity SearchesDocument Chunking
What it is

Queryable
memory

The argument, in full →
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. We can give you a dedicated place to store and retrieve them efficiently.

SIZE

The result that would not have fit

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.

PRECISION

SQL gives exact answers

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.

≡ ×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
}
What it’s not

Half of RAG.
The half that finds

“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 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.Opt in per table. A sweeper works the queue.
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.An agent’s own tool results, typed. Documents are a second way into the same tables.
What it leaves out
Write the answerTop-k chunks into the prompt, then a model call.Not done here. Rows come back; the agent writes the answer.
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.None. Brute-force cosine, a good trade until the low millions of rows per table.
SchemaNone asked for.Required up front for /add. A real cost, and one the benchmark does not put a number on.
How it works

Cast it, organize it,
read it back

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 ↓
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

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 not a model call

/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

A 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)
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"
}
# claude_desktop_config.json
{
  "mcpServers": {
    "ingot": {
      "url":
        "http://localhost:3002/api/v1/acme/ing_01H8Z…/mcp",
      "headers": { "Authorization": "Bearer ing_sk_…" }
    }
  }
}
MCP native

Same key.
No second auth path.

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.

account-widecast_ingot · clone_ingot · list_ingots · delete_ingot
per ingotdescribe · remember · query · recall · forget · configure_table · configure_delivery · drop_table
Self-hosted, for now

Bring it up. Sign up against your own address.

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.