Typed Memory
User-defined record schemas — strict, validated memory records alongside free-form memories.
Typed Memory lets you define strict record schemas and store validated, structured records next to free-form memories. Records are rows you can list, merge, and export — and each record also gets a derived embedding, so it surfaces in semantic search.
Use it when a memory has a shape: saved links, observations with expiry, action items, decisions, or a lead profile captured across a conversation.
Define a schema
Schemas use a restricted JSON-Schema subset: object root, one level of
properties, types string / number / integer / boolean / string-enum /
string-array. The SDK's field builders keep it terse:
import { createRecordsClient, defineSchema, f } from "@unispec-ai/sdk";
const records = createRecordsClient({ apiKey: process.env.UNISPEC_API_KEY });
const Link = defineSchema("link", {
linkType: f.enum(["todo", "explore-later", "customers-link"]),
href: f.string(),
description: f.string().optional().describe("Why this link matters"),
}, { embedTemplate: "Saved {linkType} link: {description} ({href})" });
await records.createSchema(Link);Using Zod? Convert with schemaFromZod (needs zod ≥ v4 as an optional peer):
import { z } from "zod";
import { schemaFromZod } from "@unispec-ai/sdk";
const Observation = z.object({
note: z.string(),
expiresAt: z.string().optional().describe("ISO date after which this is stale"),
});
await records.createSchema(await schemaFromZod("observation", Observation));Or start from a preset — link, observation, action-item, decision,
lead-profile:
const presets = await records.presets();
const lead = presets.find((p) => p.name === "lead-profile")!;
await records.createSchema(lead);Publishing a changed definition with addSchemaVersion(name, def) creates a
new version; existing records stay pinned to the version they were written
with.
Store and read records
Writes validate against the schema — unknown fields, wrong types, and missing
required fields are rejected with a 422:
await records.add("link", {
linkType: "todo",
href: "https://example.com/spec",
description: "Read before Friday",
}, { userId: "u_123" });
const rows = await records.list("link", { userId: "u_123" });Records also show up in semantic memory search — filter to one schema with
filters: { schema: "link" }; typed hits carry the parsed record alongside
the text.
Extract from conversation
extract runs LLM extraction against the schema and stores the result only if
it validates. Only explicitly stated values are captured; when nothing usable
is found the response is stored: false — a normal outcome, not an error.
const res = await records.extract("lead-profile", {
text: "Hi, I'm Ada — email ada@example.com. Hoping to buy this quarter.",
userId: "visitor-42",
});
// res.stored === true; res.record.record.email === "ada@example.com"On agent chat
Agents can extract records as a side effect of the turn — pass
memory.extract (up to 3 schemas) and stored rows come back on the response:
const agents = createAgentClient();
const res = await agents.agent("support").chat(messages, {
memory: { userId: "visitor-42", extract: ["lead-profile"] },
});
for (const rec of res.records_extracted ?? []) {
console.log(rec.schema, rec.record); // react inline — e.g. fire a lead webhook
}On the OpenAI-compatible endpoint the same rows arrive under
x_libra.records_extracted. Live token streaming (stream_mode: "live") runs
extraction after the stream ends — rows persist for the next turn but aren't
returned on the streamed response.
Update and expiry
update is a merge patch — provided fields replace, an explicit null
removes. The merged object re-validates against the record's pinned schema
version, and its search embedding is rewritten:
await records.update("lead-profile", rec.id, { timeline: "asap", company: null });Records with an expiry field (expirationTime, expires_at, or expiresAt —
e.g. the observation preset) are swept automatically once the timestamp
passes: the row and its embedding are deleted. Unparsable values are ignored;
expiry is opt-in per record.
For schema-wide pruning, set a retention policy (per record via expiry, per schema via retention):
const Obs = defineSchema("observation", { note: f.string() }, {
retention: { max_age_days: 90, max_count: 50 }, // max_count is per user
});Both knobs are optional; the sweep deletes rows past the age or beyond the
newest-N per user, embeddings included. The console constructor exposes the
same fields, and the records browser supports CSV import/export (headers =
field names, plus optional user_id; arrays use ; separators).
Webhooks
Subscribe a project webhook to the record.extracted event to react
server-side whenever an LLM extraction stores a record (via /extract or the
agents chat hook) — e.g. fire a lead notification without polling:
{ "event": "record.extracted", "project_id": "…", "schema": "lead-profile",
"record_id": "rec_…", "user_id": "visitor-42", "record": { "email": "…" } }Merged view
For accumulating profiles, merged folds a user's records into one object,
newest value per field winning:
const { merged, record_count } = await records.merged("lead-profile", "visitor-42");
// merged = { name: "Ada", email: "ada@example.com", timeline: "this-quarter" }Limits
- Up to 32 schemas per project, 32 properties per schema.
memory.extracton chat accepts at most 3 schemas per turn (one LLM call each — it adds latency to the turn).- Record writes (direct or extracted) meter as
memory.add. - Deleting a record removes its derived embedding; deleting a schema requires
deleting its records first (
409otherwise).