Skip to content

Three things worth building in an afternoon.

Complete programs, not fragments. Each one runs against a real account with an API key and nothing else, and each one is doing the thing the SDK docs are careful about: keys that mean something, a budget in tokens, and a pipeline that answers before it has finished.

SDK reference

A CLI that remembers what you did today

Standup is tomorrow morning and the honest answer to what you did is somewhere in a terminal, a branch name and four Slack threads. This is one command you run when you finish something, and one you run when you need it back.

Language
TypeScript, Node 20
Install
npm i @persistmemory/sdk
  • The idempotency key is the date and the text, not a random value. Re-running the same note after a dropped connection returns the first result instead of filing it twice.
  • `remember` answers 202 with a job id. Nothing has been extracted yet when the call returns, which is why the CLI prints the job id rather than a memory.
did.ts
#!/usr/bin/env -S node --experimental-strip-types
// did.ts - file a note, or ask what you did.
//
//   ./did.ts "moved the retry queue off Redis, Priya reviewed it"
//   ./did.ts --recall "retry queue"
//   ./did.ts --since 2026-08-01

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

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

const SPACE = process.env.PM_SPACE_ID ?? "sp_work";

async function file(text: string): Promise<void> {
  const day = new Date().toISOString().slice(0, 10);

  const result = await client.memories.remember(
    { text, spaceIds: [SPACE] },
    // Meaningful, not random. A retry of THIS note must not become a second
    // note, and a different note on the same day must not be swallowed.
    { idempotencyKey: `did:${day}:${text.slice(0, 60)}` }
  );

  // Deliberately not "saved". Extraction, entity resolution and conflict
  // detection all run after this returns, and may produce one memory,
  // several, or none.
  console.log(`queued ${result.jobId}`);
}

async function recall(query: string): Promise<void> {
  const found = await client.search.query({
    query,
    limit: 10,
    spaceIds: [SPACE],
    scope: "space"
  });

  if (found.diagnostics.degraded) {
    // Search answers even with the semantic index stopped. Saying so is the
    // difference between "nothing found" and "found less than usual".
    console.error(found.diagnostics.notice ?? "Searching without the semantic index.");
  }

  for (const { memory, score } of found.results) {
    console.log(`${score.toFixed(2)}  ${memory.title}`);
  }
}

async function since(date: string): Promise<void> {
  // `all` takes a bound on purpose: an unbounded walk on a busy account is
  // minutes of requests.
  const memories = await client.memories
    .list({ spaceIds: [SPACE], scope: "space", createdAfter: `${date}T00:00:00Z` })
    .all(200);

  for (const memory of memories) {
    console.log(`${memory.createdAt.slice(0, 10)}  ${memory.type.padEnd(11)}  ${memory.title}`);
  }
}

const [flag, ...rest] = process.argv.slice(2);
const argument = rest.join(" ");

try {
  if (flag === "--recall") await recall(argument);
  else if (flag === "--since") await since(argument);
  else if (flag) await file([flag, ...rest].join(" "));
  else console.error('usage: did.ts "what you did" | --recall <query> | --since <date>');
} catch (error) {
  if (error instanceof RateLimitError) {
    console.error(`rate limited, retry in ${error.retryAfterSeconds ?? 60}s`);
    process.exit(1);
  }
  throw error;
}

A chat bot that answers from memory

A team bot that can answer why did we leave the hosted index six weeks after anyone remembers the thread. It builds its prompt from a token budget of retrieved memories, then files the exchange back so the next answer is better than this one.

Language
Python 3.11, asyncio
Install
pip install persistmemory
  • Context is bounded by tokens rather than rows. Ten long memories overflow a window that fifty short ones fit inside, so a row limit is the wrong constraint.
  • Both turns go back in one `append` call. A claim is often split across an exchange, and extracting each turn on its own finds neither half.
  • The transport here is stdin, so the file runs as it stands. Swapping the readline loop for a Slack events handler is the only change; nothing above it moves.
bot.py
"""bot.py - answer from memory, then remember the answer.

    export PERSISTMEMORY_API_KEY=pm_live_...
    python bot.py                      # type a question, press enter
"""

import asyncio
import sys

from persistmemory import AsyncPersistMemory, RateLimitError

SPACE = "sp_engineering"
CHANNEL = "eng-help"


async def answer(client: AsyncPersistMemory, conversation_id: str, question: str) -> str:
    context = await client.search.context(
        question,
        # Tokens, not rows. This is the real constraint on the prompt side.
        token_budget=1_500,
        scope="space",
        space_ids=[SPACE],
    )

    if context["truncated"]:
        print("(something relevant was left out for budget)", file=sys.stderr)

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

    appended = await client.conversations.append(
        conversation_id,
        [
            {"role": "user", "content": question},
            {"role": "assistant", "content": reply},
        ],
        # The most likely duplicate in the whole API is a re-sent turn after a
        # dropped response, so this one is worth keying carefully.
        idempotency_key=f"{conversation_id}:{question[:60]}",
    )

    if not appended["extracting"]:
        # Said out loud rather than assumed. With no queue configured the
        # turns are stored and never become memory.
        print(appended.get("note", "turns stored, not extracted"), file=sys.stderr)

    return reply


async def call_your_model(context: str, question: str) -> str:
    """Whatever you already use. The context is ready to paste into a prompt."""
    return f"[answering with {len(context)} characters of memory] {question}"


async def main() -> None:
    async with AsyncPersistMemory(timeout=20.0, max_attempts=4) as client:
        conversation = await client.conversations.create(
            title="eng-help",
            channel=CHANNEL,
            space_ids=[SPACE],
        )

        loop = asyncio.get_running_loop()
        while True:
            question = (await loop.run_in_executor(None, sys.stdin.readline)).strip()
            if not question:
                return
            try:
                print(await answer(client, conversation["id"], question))
            except RateLimitError as limited:
                # Already retried by the client and still refused, so this is
                # the server's own number rather than a guess.
                print(f"rate limited, retry in {limited.retry_after_seconds or 60}s")


if __name__ == "__main__":
    asyncio.run(main())

Meeting notes, filed into the right Space

You keep meeting notes as markdown files. This walks a directory, finds or creates one Space per project, files each file's notes into it, and waits for the pipeline to say what it made of them.

Language
TypeScript, Node 20
Install
npm i @persistmemory/sdk
  • The Space is found before it is created. Creating blind gives you two Spaces called Platform and nothing afterwards can tell which one a memory should have gone into.
  • A Space is a boundary, not a folder. A memory filed in one never answers a question scoped to another, which is what keeps two contradictory truths from meeting.
  • The job poll stops on `completed` or `failed`. `completed` is the terminal success state, not `succeeded`.
file-notes.ts
#!/usr/bin/env -S node --experimental-strip-types
// file-notes.ts - put a directory of meeting notes into Spaces.
//
//   ./file-notes.ts ./notes/platform Platform
//
// Files are named YYYY-MM-DD-<topic>.md, which is where the idempotency key
// comes from: re-running the script never files the same meeting twice.

import { readdir, readFile } from "node:fs/promises";
import { basename, join } from "node:path";
import { PersistMemory, NotFoundError } from "@persistmemory/sdk";

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

async function spaceNamed(name: string): Promise<string> {
  // Look before creating. Two Spaces called Platform is a state nothing
  // downstream can repair, because neither is wrong.
  for await (const space of client.spaces.list()) {
    if (space.name.toLowerCase() === name.toLowerCase()) return space.id;
  }

  const created = await client.spaces.create(
    { name, kind: "project", description: `Meeting notes for ${name}` },
    // A double-clicked create is the same request twice, and this is the one
    // call where that leaves permanent damage.
    { idempotencyKey: `space:${name.toLowerCase()}` }
  );
  return created.id;
}

async function waitFor(jobId: string): Promise<string> {
  for (let attempt = 0; attempt < 30; attempt += 1) {
    try {
      const job = await client.jobs.get(jobId);
      // `completed` is the terminal success state. Waiting for `succeeded`
      // waits forever.
      if (job.status === "completed" || job.status === "failed") return job.status;
    } catch (error) {
      if (!(error instanceof NotFoundError)) throw error;
      // A job id can be handed back a moment before the row is visible.
    }
    await new Promise((resolve) => setTimeout(resolve, 2_000));
  }
  return "pending";
}

const [directory, projectName] = process.argv.slice(2);
if (!directory || !projectName) {
  console.error("usage: file-notes.ts <directory> <project name>");
  process.exit(1);
}

const spaceId = await spaceNamed(projectName);
const files = (await readdir(directory)).filter((name) => name.endsWith(".md")).sort();

for (const file of files) {
  const text = await readFile(join(directory, file), "utf8");
  const meeting = basename(file, ".md");

  const { jobId } = await client.memories.remember(
    { text, title: `Meeting notes: ${meeting}`, spaceIds: [spaceId] },
    { idempotencyKey: `notes:${projectName}:${meeting}` }
  );

  const status = await waitFor(jobId);
  console.log(`${file.padEnd(34)} ${status}`);
}

// What the pipeline actually made of them, which is the only real receipt.
const decisions = await client.memories
  .list({ spaceIds: [spaceId], scope: "space", type: "decision" })
  .all(100);

console.log(`\n${decisions.length} decisions in ${projectName}:`);
for (const decision of decisions) console.log(`  ${decision.title}`);

Build the fourth one.