RememberStackremember.dev/docs

Build a memory-backed agent

This page builds one small, complete program: a project assistant for the billing migration team. It loads a folder of meeting notes into RememberStack, waits until they are processed, and answers questions such as "when does the migration go live, and who decided?" with citations it checks before showing them. Run it again next week and it picks up only the notes that changed.

It pulls together the other guides: Ingest files, Wait until a document is queryable, Give an agent context, Cite the source of an answer and Handle unknowns and ambiguity.

What you need

  • A RememberStack endpoint and token in the environment (REMEMBER_API_URL, REMEMBER_API_KEY), from the Quickstart.
  • pip install remember anthropic, and ANTHROPIC_API_KEY set. Any model provider works; only the ask_model function below talks to it.
  • A folder of Markdown meeting notes, one file per meeting, named by date:
notes/
├── 2026-06-02-kickoff.md
├── 2026-06-16-planning.md
└── 2026-09-17-standup.md

The program

Save as assistant.py:

"""Project assistant: answers questions from meeting notes, with citations."""
 
from __future__ import annotations
 
import argparse
import re
import sys
from datetime import UTC, datetime
from pathlib import Path
 
import anthropic
import remember
from remember import ContextBundleV2, MemoryApiError
 
MODEL = "claude-sonnet-5"  # replace with the model you use
SOURCE_KIND = "meeting-notes"
 
SYSTEM_PROMPT = """\
You are the billing migration team's project assistant. You answer only
from the memory context in the user's message; do not fill gaps from
general knowledge.
 
- "Facts" are what the memory holds true. "Source passages" are what
  people said. Never present a passage as an established fact.
- Cite every statement with the IDs in square brackets, such as [C3].
  Use only IDs that appear in the context.
- If the context says nothing is known, say you do not know. If a list is
  marked incomplete, say the answer may be incomplete.
- If facts contradict each other, give both sides with their citations.
- A fact's validity is when it was true; "said on" is when someone said
  it. Do not mix them.
"""
 
# --- memory: ingest ---------------------------------------------------------
 
def meeting_time(path: Path) -> datetime:
    """Meeting date from a 'YYYY-MM-DD-*.md' name, else the file's mtime."""
    match = re.match(r"(\d{4})-(\d{2})-(\d{2})", path.name)
    if match:
        year, month, day = (int(part) for part in match.groups())
        return datetime(year, month, day, tzinfo=UTC)
    return datetime.fromtimestamp(path.stat().st_mtime, tz=UTC)
 
def load_notes(client: remember.Client, folder: Path) -> None:
    """Send every note; wait only for the ones that are new or changed."""
    pending = []
    for path in sorted(folder.glob("*.md")):
        version = client.ingest(
            path,
            source_kind=SOURCE_KIND,
            source_ref=path.name,
            source_modified_at=meeting_time(path),
        )
        state = "new version" if version.created else "unchanged"
        print(f"{path.name}: {state}")
        if version.created:
            pending.append(version.version_id)
 
    if not pending:
        print("Nothing new to process.")
        return
    print(f"Waiting for {len(pending)} version(s); this takes minutes...")
    for start in range(0, len(pending), 1000):
        client.wait_for_readiness(pending[start : start + 1000], timeout=3600)
    print("Ready.")
 
# --- memory: context --------------------------------------------------------
 
def build_context(bundle: ContextBundleV2) -> tuple[str, dict[str, object]]:
    """Render facts and passages with short citation IDs.
 
    Returns the text for the model and a map from citation ID to the claim
    it stands for.
    """
    citations: dict[str, object] = {}
 
    def cite(claim) -> str:
        for key, known in citations.items():
            if known.claim_id == claim.claim_id:
                return key
        key = f"C{len(citations) + 1}"
        citations[key] = claim
        return key
 
    facts = bundle.facts
    lines = ["## Facts the memory holds"]
    if facts.negative is not None:
        lines.append(f"(none: {facts.negative.kind}; {facts.negative.explanation})")
    claims_by_id = {claim.claim_id: claim for claim in facts.evidence}
    for fact in facts.facts:
        v = fact.validity
        when = f"valid from {v.valid_from:%Y-%m-%d}" if v.valid_from else "validity unknown"
        if v.valid_until:
            when += f" until {v.valid_until:%Y-%m-%d}"
        notes = []
        if fact.contradiction is not None:
            rivals = "; ".join(member.label for member in fact.contradiction.co_members)
            notes.append(f"contradicted by: {rivals}")
        if fact.support == "withdrawn":
            notes.append("support withdrawn, unconfirmed")
        refs = [
            cite(claims_by_id[link.claim_id])
            for link in facts.fact_evidence
            if link.fact_id == fact.fact_id and link.claim_id in claims_by_id
        ]
        extra = f"; {'; '.join(notes)}" if notes else ""
        lines.append(f"- {fact.label} ({when}{extra}) [{', '.join(refs)}]")
    if facts.truncation is not None and facts.truncation.truncated:
        lines.append("(This list of facts is incomplete.)")
 
    said = bundle.claims_and_sources
    lines += ["", "## Source passages"]
    if said.negative is not None:
        lines.append(f"(none: {said.negative.kind}; {said.negative.explanation})")
    for claim in said.evidence[:20]:
        when = f"{claim.asserted_at:%Y-%m-%d}" if claim.asserted_at else "undated"
        lines.append(
            f'- [{cite(claim)}] {claim.document_title or claim.doc_id}, said on {when}:'
            f' "{claim.source_span}"'
        )
 
    return "\n".join(lines), citations
 
# --- model -----------------------------------------------------------------
 
def ask_model(question: str, context: str) -> str:
    """The only provider-specific code: send context and question, get text."""
    llm = anthropic.Anthropic()
    response = llm.messages.create(
        model=MODEL,
        max_tokens=1024,
        system=SYSTEM_PROMPT,
        messages=[
            {
                "role": "user",
                "content": f"<memory>\n{context}\n</memory>\n\nQuestion: {question}",
            }
        ],
    )
    return "".join(block.text for block in response.content if block.type == "text")
 
# --- answer -----------------------------------------------------------------
 
def answer(client: remember.Client, question: str) -> str:
    bundle = client.combined_context(question)
    context, citations = build_context(bundle)
    reply = ask_model(question, context)
 
    used = sorted(set(re.findall(r"\[?(C\d+)\]?", reply)), key=lambda key: int(key[1:]))
    invented = [key for key in used if key not in citations]
    if invented:
        reply += f"\n\n(Warning: the model cited unknown sources {', '.join(invented)}.)"
 
    sources = ["", "Sources:"]
    for key in used:
        claim = citations.get(key)
        if claim is not None:
            title = claim.document_title or claim.doc_id
            sources.append(f'  [{key}] {title}: "{claim.source_span}"')
    return reply + ("\n" + "\n".join(sources) if len(sources) > 2 else "")
 
def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    commands = parser.add_subparsers(dest="command", required=True)
    load = commands.add_parser("load", help="send a folder of meeting notes")
    load.add_argument("folder", type=Path)
    ask = commands.add_parser("ask", help="ask a question")
    ask.add_argument("question")
    args = parser.parse_args()
 
    with remember.Client.from_env() as client:
        try:
            if args.command == "load":
                load_notes(client, args.folder)
            else:
                print(answer(client, args.question))
        except MemoryApiError as error:
            print(f"memory error: {error.status_code} {error.detail}", file=sys.stderr)
            return 1
        except TimeoutError as error:
            print(f"still processing: {error}", file=sys.stderr)
            return 1
    return 0
 
if __name__ == "__main__":
    sys.exit(main())

Run it

python assistant.py load notes/
python assistant.py ask "When does the billing migration go live, and who decided?"

An answer looks like this:

The billing migration now goes live in October 2026 [C1]. Ravi moved it
from June because the invoice exporter needs a rewrite, and Dana agreed
[C2]. The June date from the kickoff no longer holds [C3].
 
Sources:
  [C1] 2026-09-17-standup: "The migration moves from June to October."
  [C2] 2026-09-17-standup: "Dana agreed and will tell finance."
  [C3] 2026-06-02-kickoff: "We go live with the new billing system in June."

Edit a note and run load again: only that note becomes a new version and only it is waited on.

How it works

Loading. Each note is keyed by source_kind="meeting-notes" and its file name, and dated by the meeting date in its name (source_modified_at). Re-running the loader is safe: unchanged files return created=False and are skipped; changed files become new versions of the same document. The client sends .md files as text/markdown. wait_for_readiness checks every 15 seconds and stops at once if a stage dead-letters; its timeout is raised from the 30-minute default to an hour because a batch of notes takes longer than one document.

Context. One combined_context call returns what the memory holds true (facts) and what people said (claims_and_sources) side by side. build_context keeps them under separate headings, turns every claim into a short ID (C1, C2, …), and carries the signals the model must not ignore: negative, contradictions, withdrawn support, truncation.

Citations. The model cites short IDs. answer checks every cited ID against the ones it sent; an ID the model made up is flagged instead of being shown as a source. The sources list quotes source_span, the exact passage, rather than the model's paraphrase.

Where to take it next

  • Ask about the past. Pass time={"mode": "history"} or an at instant to combined_context for questions such as "what was the plan in July?" (Ask about the past).
  • Resolve names first. For questions about a person, call resolve_entity and ask the user which one when there are several; then use facts_context(..., entity_ids=[...]) (Handle unknowns and ambiguity).
  • Let the agent write memory. Render each assistant session as a conversation document and ingest it (Ingest conversations and transcripts).
  • Give it tools instead of a fixed call. Run remember mcp and let an MCP-capable agent choose among facts_context, claims_and_sources_context, resolve_entity and SQL queries itself (Connect your coding agent).