Unispec Docs

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 presetlink, 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.extract on 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 (409 otherwise).

On this page