Agent Development Kit · Build
Using memory
memory() composes two things an agent needs to recall anything: an embedder that
turns text into a vector, and an index that stores vectors and finds the near ones. Everything
else on this page — filters, variants, slices, the search tool you hand an agent — is that one
pair, addressed different ways. Every cell runs here, with no key and no database.
src/memory/
Step 1
An embedder and an index
A memory needs a model (anything satisfying Embedder: a
dimensions count, an optional modelName, and an
embed(texts) that returns one vector per text), an index, and a
collection name. The optional metadata Zod schema types every read
and write below and validates on the way in.
The embedder here is a stand-in built in the page: it hashes each word into one of 64 buckets
and counts them, so a vector is a pure function of its text — no key, no network, identical
output on every run. That makes it honest rather than good. It measures word overlap, not
meaning: flu and influenza land in unrelated buckets. In production you pass
voyage(name, { dimensions }) from @animahealth/adk/voyage, or any
other object satisfying Embedder, and nothing else on this page changes.
import { memory, inMemoryIndex } from '@animahealth/adk'
import type { Embedder } from '@animahealth/adk'
import { z } from 'zod'
const DIMENSIONS = 64
// A real embedder calls a model. This one hashes words into buckets — deterministic, offline,
// and lexical: it can only see the words two texts share.
const hashEmbedder: Embedder = {
dimensions: DIMENSIONS,
modelName: 'hash-64',
async embed(input) {
return {
model: 'hash-64',
embeddings: input.map((text) => {
const vector = Array.from({ length: DIMENSIONS }, () => 0)
for (const token of text.toLowerCase().match(/[a-z0-9]+/g) ?? []) {
let bucket = 0
for (const char of token) bucket = (bucket * 31 + char.charCodeAt(0)) % DIMENSIONS
vector[bucket] += 1
}
return vector
}),
}
},
}
const notes = memory({
model: hashEmbedder,
index: inMemoryIndex(),
collection: 'clinic_notes',
metadata: z.object({
patient: z.string(),
topic: z.enum(['symptom', 'medication']),
day: z.number(),
}),
})
const built = { collection: notes.collection, dimensions: hashEmbedder.dimensions }
built
inMemoryIndex() is a real VectorIndex that keeps its points in a Map
— the same interface sqlite-vec, pgvector and Qdrant implement, so the code below is the code
you keep when you swap it. Pass model: { index, query } instead of one embedder
for asymmetric retrieval; the factory throws at construction if the two dimensions disagree.
Step 2
Write, then search
upsert takes one item or an array of { id, content, metadata }. It
embeds the content for you, in batches of 128, and stores the text alongside the vector — so a
match carries its content back and you need no second lookup in another database. Pass
embedding yourself to skip the embed call for that item.
await notes.upsert([
{ id: 'n1', content: 'Sore throat and fever for three days.',
metadata: { patient: 'ada', topic: 'symptom', day: 3 } },
{ id: 'n2', content: 'Throat pain worse when swallowing.',
metadata: { patient: 'ada', topic: 'symptom', day: 4 } },
{ id: 'n3', content: 'Prescribed amoxicillin, 500mg three times daily.',
metadata: { patient: 'ada', topic: 'medication', day: 4 } },
{ id: 'n4', content: 'Ankle sprain after a fall while running.',
metadata: { patient: 'sam', topic: 'symptom', day: 1 } },
{ id: 'n5', content: 'Ibuprofen for the ankle swelling.',
metadata: { patient: 'sam', topic: 'medication', day: 2 } },
])
const found = await notes.search('sore throat', { topK: 3 })
const ranked = {
stored: await notes.count(),
matches: found.matches.map((match) => ({
id: match.id,
score: Number(match.score.toFixed(2)),
topic: match.metadata.topic,
content: match.content,
})),
}
ranked
A search returns { matches, embedding }: the matches, and the query
vector it computed — hand that to a second index instead of paying to embed the same string
twice. Each match is { id, score, content, metadata }, cosine-similarity ordered,
and the metadata is typed by the schema from step 1 (match.metadata.topic is
'symptom' | 'medication', and a typo is a compile error).
Look at the third result. It shares no word with the query and still scores. Cosine similarity
returns a number for every point in the collection, so a nearest-neighbour search always
returns something — which is what the next section's minScore is for.
The write path continues past upsert — reading by id, merging metadata, deleting,
and paging without a query — and the typed interface behind all of it is
Vector backends.
Step 3
Narrowing the search
Four options shape a search, and they compose. topK caps the results — every
shipped backend defaults to 10. filter restricts which points are eligible.
minScore drops weak matches. contains demands a literal substring of
the stored content, case-insensitively — a keyword gate bolted onto the vector search, not a
second query.
A filter is either shorthand — an object of key/value pairs, every one of which must match —
or the structured form with must, should and
must_not arrays of conditions. A condition tests one metadata key with
match (equality), text.contains (substring), or range
(gt/gte/lt/lte, numeric or lexicographic).
normalizeFilter is the exported function that turns the first form into the
second, and every backend receives the second.
const idsOf = (result: { matches: { id: string }[] }) =>
result.matches.map((match) => match.id)
// Shorthand: every key must match.
const forSam = await notes.search('pain', { filter: { patient: 'sam' } })
// Structured: day 4 or later, and not a medication note.
const recentSymptoms = await notes.search('pain', {
filter: {
must: [{ key: 'day', range: { gte: 4 } }],
must_not: [{ key: 'topic', match: { value: 'medication' } }],
},
})
// A literal substring of the stored content, whatever the query vector says.
const mentioningAnkle = await notes.search('anything at all', { contains: 'ankle' })
// Same query as step 2, with the weak match cut off.
const confident = await notes.search('sore throat', { minScore: 0.3 })
const narrowed = {
forSam: idsOf(forSam),
recentSymptoms: idsOf(recentSymptoms),
mentioningAnkle: idsOf(mentioningAnkle),
confident: idsOf(confident),
}
narrowed
The same filter shape works on count, scroll and
deleteByFilter. It is also where tenancy belongs: put the org id in the metadata
and filter every search on it, so no query can reach another tenant's rows.
Step 4
Handing the search to an agent
notes.tool(config) returns an ordinary FunctionTool, so the agent
gets recall the same way it gets any other capability. The tool's schema is fixed at one
query string — the model writes the search, your config sets the policy:
topK, minScore, a filter (or a function of state that
returns one), and a render that formats the matches. The name defaults to
memory_search. Only description is required — it is the whole
instruction the model reads before deciding to call.
import { adk } from '@animahealth/adk'
import { runTest, user, model, getToolResults } from '@animahealth/adk/testing'
const app = adk({ name: 'clinic' })
const recall = notes.tool({
name: 'recall_notes',
description: 'Search this patient\'s past clinic notes.',
topK: 2,
filter: { patient: 'ada' },
})
const assistant = app.agent({
name: 'clinic_assistant',
model: { provider: 'openai', name: 'gpt-5.6-luna' },
context: [app.context.system('Search the notes before answering.'), app.context.history()],
tools: [recall],
})
// The script decides THAT recall_notes is called; the search itself really runs.
const test = await runTest(assistant, [
user('What did Ada come in with?'),
model({ toolCalls: [{ name: 'recall_notes', args: { query: 'sore throat' } }] }),
model('Ada reported a sore throat and fever.'),
])
getToolResults(test.events)
The result is a string, because that is what a model reads. The default rendering tags each
match with its id — [memory] (id="n1") — and separates them with a rule; pass
render to produce your own, with the matches typed by your metadata schema. Note
what the filter did: the model asked for "sore throat" and could not have reached Sam's notes
whatever it asked, because the filter is config, not prompt.
memory.context() needs the async context path. The same config
also produces a ContextRenderer — recall injected before every model call
instead of waiting for the model to ask. It searches, so it is asynchronous, and the
reasoning loop builds context synchronously (buildContext in
src/context/build.ts throws on a renderer that returns a promise; only
buildContextAsync, which the voice runtime uses, awaits one). Until those meet,
reach for the tool above, or search before the run and inject the result with
app.context.system(…).
Step 5
Two views of one record
One record often has more than one useful text: a clean summary to search, the raw transcript
to read. variants gives a point one named vector per view — same id, same
metadata, different embedding and different stored content. The first name in the array is the
default, and memory.variant.<name> addresses the others.
That splits searching from returning. returning(name) keeps searching the current
variant's vectors but hands back another variant's content — search the tidy summary, show the
model the transcript. returning throws on a variant you did not declare;
variant is a plain record, so the same typo there is undefined.
const cases = memory({
model: hashEmbedder,
index: inMemoryIndex(),
collection: 'case_notes',
variants: ['summary', 'transcript'],
})
// The memory itself is its first variant; the others hang off `.variant`.
await cases.upsert({ id: 'c1', content: 'Sore throat, fever, likely viral.' })
await cases.upsert({ id: 'c2', content: 'Ankle sprain, rest and ibuprofen.' })
await cases.variant.transcript.upsert({
id: 'c1',
content: 'It started Friday. My throat hurts and I had a fever on Saturday night.',
})
await cases.variant.transcript.upsert({
id: 'c2',
content: 'I rolled my ankle running on Sunday.',
})
const contentOf = (result: { matches: { id: string; content: string }[] }) =>
result.matches.map((m) => [m.id, m.content])
const views = {
summaries: contentOf(await cases.search('fever')),
summarySearchTranscriptContent: contentOf(await cases.returning('transcript').search('fever')),
transcriptSearch: contentOf(await cases.variant.transcript.search('rolled my ankle')),
}
views
The first two searches rank identically — same vectors, same query — and differ only in the text they carry back. The third searches the transcript vectors instead, and reorders: "rolled my ankle" is a phrase from a transcript, not from a summary. Writes are per-variant, and every backend's compliance suite pins the consequence: a search over one variant sees only the points written for it.
Step 6
Several kinds in one collection
Variants are views of one record. Slices are different kinds of record sharing one
collection — problems, medications, letters — each with its own metadata schema, all
searchable at once. Pass slices instead of metadata and the shape of
the memory changes: writes go through memory.slice.<name>, which is where
the typed metadata lives, and there is no top-level upsert to write an unlabelled
point with.
const chart = memory({
model: hashEmbedder,
index: inMemoryIndex(),
collection: 'chart',
slices: {
problem: { metadata: z.object({ onset: z.string() }) },
medication: { metadata: z.object({ dose: z.string() }) },
},
})
await chart.slice.problem.upsert({
id: 'p1',
content: 'Acute sore throat',
metadata: { onset: '2026-03-01' },
})
await chart.slice.medication.upsert({
id: 'm1',
content: 'Amoxicillin for the throat infection',
metadata: { dose: '500mg' },
})
await chart.slice.medication.upsert({
id: 'm2',
content: 'Ibuprofen as needed',
metadata: { dose: '400mg' },
})
const everything = await chart.search('throat')
const medsOnly = await chart.slices(['medication']).search('throat')
const sliced = {
everything: everything.matches.map((m) => ({ id: m.id, kind: m.kind, metadata: m.metadata })),
medsOnly: medsOnly.matches.map((m) => m.id),
}
sliced
A cross-slice search returns one ranked list, and every match carries the
kind it came from — a discriminated union, so narrowing on
match.kind narrows match.metadata to that slice's schema.
chart.slices(['medication']) takes a subset and keeps the union typed to it;
chart.slice.medication searches exactly one. The kind also becomes
the tag in the default tool rendering from step 4, so a model reading cross-slice results can
tell a medication from a problem.
The seam
Where the vectors actually live
Everything above ran against inMemoryIndex() and vanishes when this tab closes.
Nothing above mentions a backend, and that is the point: VectorIndex is nine
methods and an optional close, and memory() is the only thing your
code talks to. collectionSpec computes what a backend has to provision for a
given config — without connecting to anything.
import { collectionSpec } from '@animahealth/adk'
collectionSpec({
model: hashEmbedder,
collection: 'case_notes',
variants: ['summary', 'transcript'],
})
Which backend to pick, what that spec provisions, what each one costs, and how their filtered searches differ is Vector backends.