Search

Search the documentation

Protobox

Memory

Durable, per-actor memory: store facts and recall them across sessions, from MCP, the SDK, or the CLI

Memory

Your assistant learns something worth keeping: a preference, a fact about the user, a decision that should hold across sessions. Without memory, it forgets the moment the session ends. With it, the assistant calls memory_store itself, unprompted, and the next session's memory_recall finds the fact again.

Memory is a first-party platform primitive. The same six actions are reachable over MCP, @protoboxai/sdk, and the protobox CLI. All three call the same handlers, so a memory stored from one surface is recalled identically from any other.

The six actions

ActionWhat it does
memory_storeStore a fact, in plain language. Embedded on write when an embedding engine is available.
memory_recallTop-K recall for a query (the simple retrieval form).
memory_searchSame engine as recall, with limit and minScore filtering.
memory_forgetDelete one memory by id, or every memory matching a content query.
memory_list_recentThe most recently stored memories, newest first (no search).
memory_historyThe full supersede chain for one memory — what it replaced, and what replaced it.

Every action is scoped to the calling actor (the end-customer, when one is bound) or the workspace principal otherwise, and to an optional space namespace. Pass the same space on store and recall to keep memories in separate buckets (e.g. one per repo for a coding agent).

MCP

Any connected MCP client calls the tools directly: memory_store, memory_recall, memory_search, memory_forget, memory_list_recent, memory_history. The tool descriptions tell the client's model when to call memory_store unprompted (a stated preference, a fact about the user, a decision worth keeping) and when not to (transient task state, secrets).

SDK

import { ProtoboxSDK } from '@protoboxai/sdk';

const sdk = new ProtoboxSDK({ apiKey: process.env.PROTOBOX_API_KEY! });

await sdk.memory.store({ content: 'the wombat likes tea', source: 'my-agent' });

const hits = await sdk.memory.recall({ query: 'wombat' });
// hits[0] => { id, content, score, source, matchedBy, createdAt, ... }

const filtered = await sdk.memory.search({ query: 'wombat', limit: 5, minScore: 0.5 });

await sdk.memory.forget({ memoryId: hits[0]!.id });
// or: await sdk.memory.forget({ query: 'wombat' });

const recent = await sdk.memory.listRecent({ limit: 20 });

const chain = await sdk.memory.history(hits[0]!.id);
// chain => [{ id, content, replaces, supersededBy, supersededAt, createdAt, ... }, ...] oldest to newest

Every call runs through the platform's existing tool-execute path (POST /api/v1/tools/native:memory_*/execute). It uses the same scoping, audit trail, and metering as an MCP client, not a second API surface.

CLI

protobox memory store "the wombat likes tea" --source cli
protobox memory recall "wombat"
protobox memory list
protobox memory forget <id>
protobox memory forget --query "wombat"
protobox memory history <id>

--json is supported on every subcommand for scripting. memory store defaults --source to cli when omitted. See Where a memory came from for why that matters.

Where a memory came from

Every memory carries a source: the CLI table's "Source" column, and the SDK's source field on every MemoryHit.

  • Explicit override: pass source (SDK/CLI) and it wins, always.
  • MCP clients: when no explicit source is given, the platform stamps the calling MCP client's name from its initialize handshake (e.g. claude-desktop).
  • No handshake to read: the SDK and CLI call the tools' REST execute path directly, which carries no MCP initialize. Without an explicit source, that stamps unknown. This is why protobox memory store defaults --source to cli rather than leaving it unset.

What memory costs on free

memory_store never triggers a server-side extraction call. Deciding what's worth remembering happens in the calling client's model (per the tool description), not on the platform. Storing is available on every tier.

Embedding a memory on write does cost a provider call, and follows the same engine policy as knowledge base search:

  • Free tier, no key of your own: memory_store still succeeds, but writes with no vector (embedded: false in the store response). memory_recall/memory_search still find it (see below).
  • An OpenAI key added under Integrations (any tier): embeddings use your key.
  • Paid tiers without a BYO key: embeddings use Protobox's managed key.

How recall works

memory_recall and memory_search share one retrieval engine, fusing up to three signals per query:

  • Semantic: vector similarity, when an embedding engine is available (see above). Skipped entirely on free tier without a key (no embedding call is made).
  • Keyword: full-text matching, always runs.
  • Entity: exact matching on proper nouns, dates, and numbers extracted from the query, so "Dr. Okafor" or "March 14" finds the row that names them, not just a semantically-similar paraphrase.

Results are fused by reciprocal rank fusion; each hit's matchedBy array names which lane(s) found it (e.g. ['keyword', 'entity']), and score is a 0-1 relevance number you can filter on with memory_search's minScore. On free tier, keyword and entity matching alone are enough to find an exact name or date. Only the semantic lane requires a key.

What used to be true

When a fact changes, the old one is superseded, not erased. Storing an update marks the old memory supersededBy the new one and stamps replaces on the new memory — the old row stays in the database, just excluded from ordinary recall.

Two ways a memory gets superseded:

  • Explicit — pass replaces with the id of the memory this fact updates:

    protobox memory store "the wombat now prefers coffee" --replaces <old-id>
    await sdk.memory.store({ content: 'the wombat now prefers coffee', replaces: oldId });
  • Automatic — omit replaces and store a near-duplicate of an existing fact (same named entity — a person, date, or number — plus enough word overlap to be "the same subject, updated"), and the platform supersedes the best match on its own. No LLM call: it's a lexical check (entity-term overlap + word-set similarity), so it works on every tier, including free. The store response reports what it superseded:

    const result = await sdk.memory.store({ content: 'Dr. Okafor prescribed the blue inhaler' });
    // result.superseded => [{ id: '...', content: 'Dr. Okafor prescribed the red inhaler' }]

By default, superseded facts don't show up. memory_recall, memory_search, and memory_list_recent only return current facts. Pass includeHistory: true (SDK/API) or --include-history (CLI) to see the whole chain, past and present:

const current = await sdk.memory.recall({ query: 'wombat' }); // only the latest
const everything = await sdk.memory.recall({ query: 'wombat', includeHistory: true }); // current + superseded

See exactly what changed and when with memory_history — the full chain for one memory, oldest to newest, regardless of which id in the chain you pass:

protobox memory history <id>
const chain = await sdk.memory.history(memoryId);
// chain[0] is the oldest version; the last entry is current (supersededBy: null)

A memory is never deleted by supersession — only memory_forget deletes.

Bring your memories from another assistant

Switching to Protobox from ChatGPT, Claude, or Gemini doesn't mean starting from zero. Export your memories from the other assistant, then import them:

protobox memory import ./export.zip --from chatgpt
protobox memory import ./export.zip --from claude
protobox memory import ./saved-info.json --from gemini
protobox memory import ./export.zip                    # --from omitted: auto-detected

Or via the SDK:

import { readFileSync } from 'node:fs';

const result = await sdk.memory.import({
  file: readFileSync('./export.zip'),
  fileName: 'export.zip',
  format: 'chatgpt', // or 'claude' | 'gemini' | 'auto'
});
// result => { vendor: 'chatgpt', imported: 12, skippedDuplicates: 0, skippedInvalid: [] }

Or POST /api/v1/memory/import (multipart) directly.

What gets imported. Only the export's memory/preferences section — ChatGPT's "memories" and custom instructions, Claude's memory/preferences file (where present), Gemini Takeout's "Saved info" — never the full conversation log. Extracting facts from a raw transcript needs an LLM pass over every message, which the platform doesn't do on your behalf; import only reads what the source assistant already distilled into durable memory.

Attribution. Every imported fact is stored with source: "imported-<vendor>" (e.g. imported-chatgpt), so memory_recall/memory_search results and the CLI's Source column always show where a memory came from.

Idempotent. Re-running the same import (or importing an export that overlaps with facts you've already stored by hand) adds nothing twice — a fact is deduped by its content, scoped to the same actor/space memory_store uses. The response's skippedDuplicates count tells you how many were already there.

Malformed or unsupported exports fail loudly. If the file doesn't carry a memory/preferences section any of the three parsers recognize — or the vendor can't be auto-detected — nothing is imported and the error names what was expected. A partial or malformed import never leaves the workspace half-populated. Per-fact issues (an unusable entry within an otherwise-good export) don't fail the whole import: they're reported in skippedInvalid alongside the facts that did import.

Cap. A single import tops out at 5,000 facts; an export over that is rejected before anything is written.

What's Next

Knowledge Base

Upload docs and FAQs for semantic search

Tools

Create custom functions clients can call over MCP

On this page