Memory

Catentio's memory ("cold memory") is a workspace-scoped RAG (retrieval-augmented generation) store: every agent reads from it, and specific agents write to it as they work. Entries are typed and embedded so semantic search can pull relevant history into an agent's context window without dragging in everything.

The /v1/memory API lets you inspect entries, search them, and curate the store. The destructive writes — edit, delete, bulk-delete — are gated behind an OTP challenge so a compromised portal session can't silently rewrite or wipe the store agents rely on.

The entry object

{
  "id": 12482,
  "type": "feedback",
  "slug": "dont-burn-session-on-remote-diagnostics",
  "name": "Don't burn the session on remote diagnostics",
  "description": "When bang has hands on a client, prefer asking him to check over doing 10 SSM round-trips.",
  "content": "If bang has hands on a client, 'you check this' beats 10 SSM round-trips...",
  "agent": "hachimi",
  "scope": "shared",
  "source_attribution": null,
  "status": "active",
  "always_load": false,
  "last_verified_at": "2026-05-06T08:00:00.000Z",
  "meta": null,
  "created_at": "2026-05-06T08:00:00.000Z",
  "updated_at": "2026-05-06T08:00:00.000Z",
  "has_embedding": true,
  "hit_count": 14,
  "impression_count": 22,
  "hit_rate": 0.6363636363636364,
  "last_hit_at": "2026-05-11T09:12:00.000Z"
}
Field Type Notes
id int Auto-incrementing integer.
type string Free-form label, not a closed enum — the runtime's own examples are note, pattern, fact; this deployment also uses feedback/project/reference and similar. content is required to embed; type just tags it.
slug string | null Optional wikilink identity. Unique among graph-node types when set.
name string | null Short label shown in pickers.
description string | null One-liner.
content string The full body — what actually gets embedded. Required on create.
agent string | null Owning agent slug, or unset for cross-agent entries.
scope string Defaults to "shared" on create; "personal" is the other convention in use. Not a hard-enforced enum.
source_attribution string | null Free-form provenance note.
status enum active, superseded (set automatically by supersede, never directly), archived (settable via PATCH).
always_load bool If true, the entry renders into the agent's boot context on every run instead of only when it's retrieved by search.
last_verified_at timestamp | null Stamped by verify; null until first verified.
meta object | null Free-form JSON, entry-kind-specific.
created_at / updated_at timestamp ISO 8601 UTC.
has_embedding bool Detail-only. Whether the row currently has a stored embedding vector.
hit_count / impression_count int Detail-only, migration 0025. impression_count increments every time the row is a scored recall candidate; hit_count increments when it clears the threshold and actually gets injected.
hit_rate float Detail-only. Computed at read time (hit_count / impression_count, 0.0 if no impressions) — never stored.
last_hit_at timestamp | null Detail-only. Most recent hit.

There is no title, body_excerpt, tags, ttl_at, or halflife_days field, and no halflife-decay scoring formula. Older references to a recency-halflife RAG score are stale — ranking today is embedding-similarity plus the hit/impression signal above, not a decay curve over halflife_days. The closest thing to a body preview is preview (a 240-char slice of content), and it only appears on the list endpoint below, not on create/get/update/supersede, which all return the full content.

Endpoints

Stats

GET /v1/memory/stats

Workspace rollup:

{
  "total": 1842,
  "by_agent": [{ "key": "hachimi", "count": 1402 }, { "key": "", "count": 220 }],
  "by_type": [{ "key": "reference", "count": 412 }, { "key": "feedback", "count": 312 }],
  "by_scope": [{ "key": "shared", "count": 1622 }, { "key": "personal", "count": 220 }]
}

by_agent/by_type/by_scope are count-descending arrays of {key, count}, not a flat map. An entry with no agent set groups under key: "".

List entries

GET /v1/memory/entries

Query params:

Param Type Notes
q string Semantic search query (embeds + cosine similarity). When set, the response is search results, not a paginated browse — see below.
agent string Filter to one agent.
type string Filter to one type.
scope string Filter to one scope.
limit int (1–200) Default 50. Also the search k when q is set.
const { data } = await catentio.memory.listEntries({ q: 'pawpado deploy' });
entries = catentio.memory.listEntries({"q": "pawpado deploy"})
curl 'https://catent.io/api/v1/cp/v1/memory/entries?q=pawpado+deploy&limit=10' \
  -H "Cookie: catentio_session=<session cookie set at login>"

Each row carries preview (content[:240]) instead of the full content, plus id, type, slug, name, description, agent, scope, source_attribution, status, always_load, last_verified_at, meta, created_at. Without q, rows are ordered newest-first and score is null; the envelope is {"data": [...], "meta": {"limit", "search": false, "total"}}. With q, rows are ordered by similarity, score carries the match, status is not filtered (history stays visible), and the envelope is {"data": [...], "meta": {"limit", "search": true}} — note there is no total on the search path.

Retrieve an entry

GET /v1/memory/entries/{entry_id}

Returns the full detail row (the entry object above, including content in full).

Create an entry

POST /v1/memory/entries

Body:

{
  "content": "saas-pawpado deploys via build-on-box (CI does git pull + rebuild)...",
  "type": "reference",
  "name": "Always commit-then-push for pawpado",
  "agent": "hachimi",
  "scope": "shared"
}

content and type are required (400 if either is missing/blank); slug, name, description, source_attribution, agent, scope (defaults "shared"), always_load (defaults false), and meta are optional. The runtime computes the embedding automatically and writes the row in the same call. A per-workspace tier cap on cold_memory_entries applies.

Update an entry

PATCH /v1/memory/entries/{entry_id}

OTP-gated — memory is RAG context agents read autonomously, so editing it is treated as destructive. Patchable: content, description, scope, always_load, meta, status ("active" or "archived" only — "superseded" is exclusively set by the supersede endpoint below and rejected here with 400). Pass re_embed: true if you changed content and want the embedding refreshed; otherwise semantic search keeps matching on the stale vector.

See OTP gate below for the challenge/retry flow — identical to delete.

Delete an entry

DELETE /v1/memory/entries/{entry_id}

Hard-deletes the row (cold memory is RAG context, not an audit log — a soft-delete would still surface in vector search). OTP-gated, same flow as update. Returns 204 No Content on success.

Bulk delete

POST /v1/memory/entries/bulk-delete

Same OTP flow as single delete. Body:

{ "ids": [12482, 12483, 12484] }

The OTP challenge's scope string encodes the sorted id set, so a code issued for one batch can't be replayed against a different one.

Verify an entry

POST /v1/memory/entries/{entry_id}/verify

Stamps last_verified_at to now. Not OTP-gated. Returns {"last_verified_at": "..."}.

Supersede an entry

POST /v1/memory/entries/{entry_id}/supersede

The "edit a fact without destroying its history" path: instead of overwriting the row, this creates a new entry, links it to the old one with a supersedes edge, and flips the old row's status to superseded. Not OTP-gated (nothing is deleted).

Body: content is required (400 if empty); type, slug, name, description, agent, scope are optional and inherited from the superseded entry when omitted. Returns the new entry in the standard detail shape.

GET /v1/memory/entries/{entry_id}/links

The graph neighborhood one hop out from a node:

{
  "outgoing": [{ "edge_type": "uses_tool", "dst_slug": "tool-memory_search", "dst_id": 991, "resolved": true, "other": {} }],
  "incoming": [{ "edge_type": "supersedes", "dst_slug": "dont-burn-session-on-remote-diagnostics", "dst_id": 12482, "resolved": true, "other": {} }]
}

Graph

GET /v1/memory/graph

Query params: type (repeatable), agent, status (default active), limit (1–3000, default 1500). Returns {"nodes": [...], "edges": [{"source", "target", "dst_slug", "edge_type"}], "meta": {"node_count", "edge_count", "dropped_dangling", "truncated"}} — a flat edge query for the portal's graph visualization, not a BFS traversal. Edges pointing at a node outside the returned set are dropped and counted under dropped_dangling.

The OTP gate

PATCH /v1/memory/entries/{id}, DELETE /v1/memory/entries/{id}, and POST /v1/memory/entries/bulk-delete require an OTP. Call the endpoint without OTP headers and it 403s with:

{
  "otp_required": true,
  "challenge_id": "V3x9k2mQpR7z",
  "scope": "memory.delete:12482",
  "expires_at": 1778662020.512,
  "delivery": "email:adi…@gmail.com"
}
Field Notes
challenge_id A raw secrets.token_urlsafe(12) string — no prefix (not chl_...).
scope A machine-readable action key (memory.delete:<id>, memory.update:<id>, memory.bulk_delete:<count>:<hash>), not a human sentence.
expires_at A raw Unix timestamp (seconds, float) — not an ISO 8601 string.
delivery Where the 6-digit code went: email:<masked-address> normally, or log:journalctl if Resend isn't configured (common in dev — check journalctl -u catentio-runtime).

The code is emailed, not sent to Discord. The runtime delivers the OTP via Resend to the operator's configured admin inbox (masked in the delivery field for safety). Treat any reference to a Discord DM for this flow as stale.

The challenge is single-use and expires 10 minutes after issue. Re-call the same endpoint with:

X-Catentio-OTP-Challenge: V3x9k2mQpR7z
X-Catentio-OTP-Code: 482917
curl -X DELETE 'https://catent.io/api/v1/cp/v1/memory/entries/12482' \
  -H "Cookie: catentio_session=<session cookie set at login>" \
  -H "X-Catentio-OTP-Challenge: V3x9k2mQpR7z" \
  -H "X-Catentio-OTP-Code: 482917"

Last line of defence. Even with a hijacked portal session, an attacker can't edit or drain the RAG store without also reaching the operator's email inbox. Don't share OTP codes; if you see a challenge you didn't trigger, ignore it and rotate whatever credential caused it.

Errors

Status Code When
400 content/type missing on create; bulk-delete body wasn't a non-empty list of ints; status: "superseded" attempted via PATCH; empty content on supersede.
403 otp_required Update/delete/bulk-delete attempted without a valid OTP challenge+code. The detail carries the challenge (see above).
404 Entry id doesn't exist.
502 CP couldn't reach the runtime.

Next

  • Skills — how skills frequently pull context from memory.
  • Agents — which agents write to memory.
  • Portal → Memory — the dashboard view including the curation UI.