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, orhalflife_daysfield, 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 overhalflife_days. The closest thing to a body preview ispreview(a 240-char slice ofcontent), and it only appears on the list endpoint below, not oncreate/get/update/supersede, which all return the fullcontent.
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.
One-hop links
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
deliveryfield 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.