Skip to content

Documentation

Two calls to remember and recall.

First-party clients for TypeScript and Python over the same HTTP API. Same endpoints, same retry policy, same paginator, and the same refusal to repeat a POST you have not told it is safe to repeat.

Three things to know first

remember does not return a memory

It hands material to the ingestion pipeline and answers 202 with a job id. Extraction, entity resolution, deduplication and conflict detection run afterwards and may produce one memory, several, or none. Poll the job if you need to know when. The terminal success state is completed.

Search degrades rather than fails

With embeddings unavailable it falls back to deterministic retrieval and still answers. Read diagnostics.degraded before you tell a user the system knows nothing. It may merely be looking with one eye.

POSTs are not retried unless you say they are safe

A POST that timed out may already have been processed: a connection that died after the server accepted the request looks identical to one that died before. Pass an idempotency key with meaning in it, not a fresh random value, which makes every retry a new request.

@persistmemory/sdk

TypeScript

ESM, no runtime dependencies, and a fetch you can inject, which is what makes a test of your own code incapable of opening a socket by accident.

npm install @persistmemory/sdk
import { PersistMemory } from "@persistmemory/sdk";

const client = new PersistMemory({
  apiKey: process.env.PERSISTMEMORY_API_KEY!
});

await client.memories.remember({
  text: "We chose Postgres for the ledger, not DynamoDB."
});

const found = await client.search.query({
  query: "what did we decide about the ledger"
});
console.log(found.results.map((one) => one.memory.title));

Assembling context for a model, and filing what came back

import { PersistMemory, NotFoundError, RateLimitError } from "@persistmemory/sdk";

const client = new PersistMemory({
  apiKey: process.env.PERSISTMEMORY_API_KEY!,
  timeoutMs: 15_000,
  maxAttempts: 4
});

async function answer(question: string, conversationId: string): Promise<string> {
  // Bounded by TOKENS, not by row count. Ten long memories overflow a
  // window that fifty short ones fit inside.
  const context = await client.search.context({
    query: question,
    tokenBudget: 1_500,
    scope: "combined",
    spaceIds: ["sp_work"]
  });

  if (context.truncated) {
    console.warn("Something relevant was left out for budget.");
  }

  const reply = await callYourModel(context.context, question);

  // Both turns in one call: a claim is often split across an exchange,
  // and extracting each turn in isolation finds neither half.
  const appended = await client.conversations.append(
    conversationId,
    {
      messages: [
        { role: "user", content: question },
        { role: "assistant", content: reply }
      ]
    },
    { idempotencyKey: `${conversationId}:${question.slice(0, 40)}` }
  );

  if (!appended.extracting) {
    // Said out loud rather than assumed. With no queue configured the
    // turns are stored and never become memory.
    console.warn(appended.note);
  }

  return reply;
}

Paging

// One page, for a UI that renders one page.
const first = await client.memories
  .list({ type: "decision", limit: 50 })
  .first();

// Or the whole walk. `all` needs a bound: an
// unbounded collect on a large account is
// minutes of requests and a heap full of results.
const decisions = await client.memories
  .list({ type: "decision" })
  .all(500);

// Or item by item, stopping when you like.
for await (const memory of client.memories.list({ scope: "combined" })) {
  if (memory.confidence < 0.5) break;
}

Errors

try {
  await client.spaces.get("sp_missing");
} catch (error) {
  if (error instanceof NotFoundError) return null;

  if (error instanceof RateLimitError) {
    // Already retried, and still refused.
    // retryAfterSeconds is what the server said.
    console.warn(error.retryAfterSeconds ?? 60);
  }

  throw error;
}

persistmemory

Python

Sync and async, both written out rather than one wrapping the other. A sync facade over an async client needs an event loop, and calling it from inside a running one either deadlocks or needs a background thread nobody asked for.

pip install persistmemory
from persistmemory import PersistMemory

client = PersistMemory()  # reads PERSISTMEMORY_API_KEY

client.memories.remember("We chose Postgres for the ledger, not DynamoDB.")

found = client.search.query("what did we decide about the ledger")
print([one["memory"]["title"] for one in found["results"]])

The same flow, async

from persistmemory import AsyncPersistMemory


async def answer(question: str, conversation_id: str) -> str:
    async with AsyncPersistMemory(timeout=15.0, max_attempts=4) as client:
        # Bounded by TOKENS, not row count. Ten long memories overflow a
        # window that fifty short ones fit inside.
        context = await client.search.context(
            question,
            token_budget=1_500,
            scope="combined",
            space_ids=["sp_work"],
        )
        if context["truncated"]:
            print("Something relevant was left out for budget.")

        reply = await call_your_model(context["context"], question)

        # Both turns in one call: a claim is often split across an
        # exchange, and extracting each turn alone finds neither half.
        appended = await client.conversations.append(
            conversation_id,
            [
                {"role": "user", "content": question},
                {"role": "assistant", "content": reply},
            ],
            idempotency_key=f"{conversation_id}:{question[:40]}",
        )
        if not appended["extracting"]:
            # With no queue configured the turns are stored and never
            # become memory. Say so rather than assuming.
            print(appended.get("note"))

        return reply

Paging

# One page, for a UI that renders one page.
first = client.memories.list(type="decision", limit=50).first()

# Or the whole walk, with a bound.
decisions = client.memories.list(type="decision").all(500)

# Or item by item, stopping when you like.
for memory in client.memories.list(scope="combined"):
    if memory["confidence"] < 0.5:
        break

# The async client iterates the same way.
async for memory in client.memories.list(type="fact"):
    ...

Errors

from persistmemory import NotFoundError, RateLimitError

try:
    client.spaces.get("sp_missing")
except NotFoundError:
    return None
except RateLimitError as limited:
    # Already retried, and still refused.
    print(limited.retry_after_seconds or 60)

# Idempotency, for anything that can retry.
client.memories.remember(
    "The migration is halfway done.",
    idempotency_key="standup:2026-08-28",
)

Twelve resources, identical in both clients

client.memories

Browse chronologically, read one, or hand over new material.

client.search

Rank by relevance, or assemble context inside a token budget.

client.spaces

Keep work apart. A memory filed in one never answers another.

client.sources

Where material came from, with its access scope.

client.documents

What was ingested, before anything was extracted from it.

client.jobs

What became of a piece of material handed to the pipeline.

client.entities

The people, projects and systems memories are about.

client.graph

Traverse from one entity to what it is connected to.

client.conflicts

Contradictions the system could not settle, and your verdict.

client.conversations

Append turns, and let extraction read the exchange as a whole.

client.integrations

Connected sources, and asking one to sync now.

client.health

Liveness and readiness.

Behaviour

Auth

Authorization: Bearer. A pm_live_ key or a session JWT.

Retries

429, 5xx and transport failures. Never 4xx. Three attempts by default.

Backoff

Exponential from 250ms, capped at 8s, full jitter. Retry-After is honoured as a floor.

Timeouts

30s per attempt, not per call, so backoff cannot eat the deadline.

Cancellation

An AbortSignal in TypeScript, a cancelled task or timeout= in Python. An aborted call is never retried.

Pagination

A missing nextCursor means stop. An empty page does not.

The API key is never returned, logged, stringified or put in an error. Printing a client gives a redacted key, and any key-shaped string in an error message is stripped on the way out, because the way a credential actually escapes is an object pasted into a bug report rather than a deliberate log line. Branch on the error class or on error code, never on the message: messages get rewritten, translated, and deliberately made vaguer for security.

Now give it something to remember.