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.

Runs · no key (the embedder and the index are local) Assumes · the quickstart's tool section Source · 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.