A tool is the agent reaching for memory. A resource is something watching the memory from outside. They must not be confused, because one of them warms what it touches and the other must not.
← back to README · related: architecture · temperature · search
The Memory* tools are the agent's hands — it calls MemorySearch, MemoryFetch, MemoryTree to use memory, and every such read is a signal: the agent cared about these nodes, so they warm up (temperature boost) and rank higher next time.
A resource is for a different consumer: a desktop client, a dashboard, anything rendering the database rather than reasoning over it. That consumer needs to read state without being a signal. If a UI that draws the temperature heatmap boosted every node it displayed, the heatmap would measure its own rendering instead of the agent's attention. So resources are defined by what they must never do.
A resource read is passive observation. It:
- never boosts temperature — observing the graph is not attending to it (this is the whole reason resources are separate from
Memory*read tools, which always boost viaboostResultNodes); - never takes
Store.OpMu— it is a pure read, it must not serialize against writers; - never emits a snapshot — there is no state change to record.
This is the inverse of the read-tool discipline in .claude/rules/mcp-tool-conventions.md §5–6: read tools boost and a write tool locks; a resource does neither, by construction. The separation is structural — resources live in pkg/mcp/resources/ with their own Deps that has no Tracker and no emitter, so the no-boost/no-lock/no-snapshot invariant can't be violated by forgetting a convention.
Resources are addressed under the remindb:// scheme. Most are static (a fixed URI, no parameters); some are templated (a URI pattern with a variable):
remindb://overview → application/json (static)
remindb://files → application/json (static)
remindb://tree → application/json (static — full hierarchy)
remindb://tree/{rootId}{?depth} → application/json (templated — bounded subtree)
remindb://graph → application/json (static — relations graph)
remindb://snapshots → application/json (static — full history)
remindb://snapshots{?limit} → application/json (templated — newest N)
remindb://snapshots/{id}/diffs → application/json (templated — per-snapshot diffs)
remindb://temperature → application/json (static — per-node heatmap)
remindb://doctor → application/json (static — health-check report)
remindb://logs → application/json (static — recent server log records)
remindb://sessions → application/json (static — active MCP client sessions)
remindb://sessions/history → application/json (static — durable per-client session ledger)
remindb://sessions/history/{hash} → application/json (templated — one client's ledger)
remindb://sessions/logs → application/json (static — per-session logfile index)
remindb://sessions/logs/{id} → application/json (templated — one session's captured trace)
remindb://rescan → application/json (static — latest source-rescan tick)
Static resources answer "what is the state of the whole database". The templated forms (registered via AddResourceTemplate, RFC 6570 so the matcher spans the optional query / path variable) answer a narrower question: remindb://tree/{rootId}{?depth} the subtree under a node, remindb://snapshots{?limit} the newest N snapshots, remindb://snapshots/{id}/diffs the diffs of one snapshot. The static/templated split is a deliberately separate mechanism mirroring the resources/tools split: predictable surface first, parameterised surface only where a concrete need appeared.
remindb://overview exposes the same introspection MemoryStats reports, but as stable JSON instead of formatted text. Both are pure projections of inspect.Collect() — one source of truth, two presentations, zero duplicate stat logic.
{
"db_path": "/repo/.remindb/memory.db",
"db_bytes": 196608,
"nodes": {
"total": 142,
"by_type": {
"heading": 38,
"text": 96,
"code": 8
},
"tokens": 18450
},
"snapshots": {
"count": 7,
"head_id": 7,
"cursor_hash": "9f3c1a7e",
"latest_message": "write:aB3",
"latest_age_s": 42
},
"temperature": {
"avg": 0.37,
"median": 0.31,
"hot": 12,
"cold": 48,
"pinned": 3
},
"relations": {
"total": 27,
"by_origin": {
"parsed": 22,
"manual": 5
},
"pending": 4
},
"fts_rows": 142
}The shape is locked — clients depend on these keys. Notes on two fields that could be read two ways:
snapshotsis all zero-valued when the database has no snapshots yet (head_id: 0,cursor_hash: ""); it never omits keys.relations.totalis the sum ofby_origin(parsed + manual edges).pendingis reported as its own sibling and is not folded intototal— the envelope keeps resolved and pending relations distinct so a client can show both.
remindb://files exposes the compiled source files grouped by compile root, with per-file node and token counts — the JSON twin of remindb inspect --files. Both project the same store.ListFileSummaries() query: one source of truth, two presentations, zero duplicate query logic.
{
"roots": [
{
"root": "/repo/docs",
"files": [
{ "path": "docs/architecture.md", "nodes": 12, "tokens": 840 }
]
}
]
}The shape is locked — clients depend on these keys. Notes:
rootsis sorted by compile root; the empty-string root (files not attributed to a compile root — "ungrouped") always sorts last. The envelope reports the bare""root, not a(ungrouped)label — the client owns the display string.- Within each root, file order is whatever
ListFileSummariesreturns; only the cross-root grouping is added here. - Empty database →
{ "roots": [] }; the key is never omitted.
remindb://tree exposes the parent/child node hierarchy as nested JSON — the structured twin of the MemoryTree tool's indented text. Both walk the same store.GetAllNodes() + store.BuildTree() primitives: one source of truth, two presentations. The text form is for the agent reasoning in prose; the JSON form is for a UI drawing the tree.
The templated remindb://tree/{rootId}{?depth} returns only the subtree rooted at rootId. ?depth=N bounds it to N descendant generations below that root; omitting the query (or depth=0) returns the full subtree.
{
"roots": [
{
"id": "aB3", "type": "heading", "label": "Architecture", "depth": 0,
"tokens": 120, "temperature": 0.42, "source": "docs/architecture.md",
"children": [
{
"id": "aB3-1", "type": "text", "label": "Overview", "depth": 1,
"tokens": 80, "temperature": 0.31, "source": "docs/architecture.md",
"children": []
}
]
}
]
}The shape is locked — clients depend on these keys. Notes:
rootsis always present: an empty database →{ "roots": [] }; the static read returns the whole forest, the templated read returns exactly one element (the requested root).- Every node carries all eight keys including
children([]at a leaf, never omitted).sourceis the node's source path made relative to the latest compile root, matchingMemoryTree;depthis the node's own depth in the full tree, unaffected by?depthbounding. - An unknown
rootIdis an error, not an empty body — the client gets a failed read, distinguishing "no such node" from "node with no children".
remindb://graph exposes the relations knowledge graph — the "brain" view — as stable JSON for a UI that draws it. It is pure exposure: resolved edges come straight from store.GetAllRelations(), pending/unresolved edges from store.GetAllPendingRelations(), and the node set from store.GetAllNodes() — no traversal logic, the same flat queries the rest of the store uses.
{
"nodes": [
{ "id": "aB3", "label": "Architecture", "type": "heading", "temperature": 0.42 },
{ "id": "aB3-1", "label": "Overview", "type": "text", "temperature": 0.31 }
],
"edges": [
{ "source": "aB3", "target": "aB3-1", "weight": 4.2, "origin": "parsed" },
{ "source": "aB3", "target": "aB3-1", "weight": 1.0, "origin": "manual" }
],
"pending": [
{ "source": "aB3", "target_label": "Roadmap", "target_source": "docs/roadmap.md",
"target_id_hint": "", "weight": 1.0, "origin": "parsed" }
]
}The shape is locked — clients depend on these keys. Notes:
nodesis the referenced set only: a node appears iff it is the source or target of a resolved edge, or the source of a pending edge. Orphan nodes (no relations) are not in the graph — useremindb://treefor the full node hierarchy.edgesare resolved relations (both endpoints exist).originisparsed(from the weight wiki-link syntax) ormanual(fromMemoryRelate);weightdefaults to1.0. Each edge is directedsource → target.pendingare unresolved edges: thesourcenode exists but the target was never resolved to a node, so it is described bytarget_label/target_source/target_id_hint(any may be empty) instead of atargetid. Pending edges are kept a distinct array — never folded intoedges— so a client can render them differently (dashed, greyed).- All three keys are always present; an empty database →
{"nodes":[],"edges":[],"pending":[]}, nevernull.
remindb://snapshots exposes the version history behind MemoryHistory — every snapshot, newest-first, with the parent links that reconstruct branch topology. It is pure exposure: rows come straight from store.ListSnapshots, the HEAD marker from store.GetHeadSnapshotID — no new diff logic. remindb://snapshots{?limit} returns just the newest ?limit=N (omit for full history). remindb://snapshots/{id}/diffs returns the per-snapshot diff records (store.GetDiffsBySnapshot), the data behind MemoryDelta.
{
"snapshots": [
{ "id": 3, "parent_id": 2, "message": "write:aB3", "compile_root": "/repo", "created_at": 1737072000, "is_head": true },
{ "id": 1, "parent_id": null, "message": "compile", "compile_root": "/repo", "created_at": 1737070000, "is_head": false }
]
}{
"snapshot_id": 3,
"diffs": [
{ "op": "mod", "node_id": "aB3", "old_hash": "h1", "new_hash": "h2", "old_content": "before", "new_content": "after" }
]
}The shape is locked — clients depend on these keys. Notes:
- Snapshots preserve store order (newest
idfirst); the timeline UI reverses for display.parent_idis JSONnullfor a root snapshot, never0— that distinction is what lets a client draw branches. is_headmarks the snapshot at the cursor. At most one istrue; if no snapshot has been recorded, none is.snapshotsanddiffsare always present; an empty database / a snapshot with no diffs →[], nevernull.- An unparseable
{id}or non-positive?limitis an error, not an empty body — the client gets a failed read.
remindb://temperature is the heatmap source for a UI that draws node attention. Every node lands in one nodes array — hot, cold, and pinned together, not split — so the renderer classifies each node itself from temperature against the cut points the summary echoes. The aggregate summary mirrors MemoryStats' temperature block (avg, median, hot, cold, pinned) with both thresholds sourced from the live configured values (.remindb/config.json → temperature.cold_threshold / temperature.hot_threshold, resolved through temperature.Config), not hardcoded constants — so the heatmap and the cold/hot counts always agree with what the notifier and inspector report.
{
"summary": {
"avg": 0.29,
"median": 0.30,
"hot": 1,
"cold": 2,
"pinned": 1,
"cold_threshold": 0.1,
"hot_threshold": 0.5
},
"nodes": [ { "id": "aB3", "label": "Auth design", "temperature": 0.8, "pinned": false } ]
}The shape is locked. Notes:
nodesis always present ([]on an empty database, nevernull) and is the whole node set — there is no separatecoldarray; the summary'scold/hotcounts and the per-node temperatures are two views of the same fetch, so they cannot disagree.summaryechoescold_thresholdandhot_thresholdprecisely so a client reproduces the exact classification the counts were derived from.hot/coldcount pinned nodes too (same semantics asMemoryStats);pinnedis the independent pinned count.- Reading this resource does not boost — a heatmap that warmed the nodes it rendered would measure its own rendering. Use
MemoryStatswhen you want the access to count.
remindb://doctor exposes the integrity report remindb doctor produces, but as stable JSON instead of the formatted text panel — so a desktop client renders the health panel without shelling out to the CLI. Both are pure projections of pkg/doctor (doctor.Run): the resource and doctor --json share one Report.MarshalJSON, so they are byte-equivalent by construction — one source of truth, no duplicated check logic. The resource runs doctor.Run (read-only), never doctor.Heal; it has no --fix analogue.
{
"status": "warn",
"checks": [
{ "name": "fts5_sync", "status": "pass", "detail": "FTS5 index in sync with 12 nodes" },
{ "name": "stale_compile_root", "status": "warn", "detail": "1/2 compile roots no longer exist: [/old/repo]" }
]
}The shape is locked. Notes:
statusis the overall worst-wins header (failbeatswarnbeatspass), the JSON twin of the text report's status line — added toReport's JSON precisely so this resource anddoctor --jsonstay identical.checksis always present and ordered asdoctor.AllChecks()declares them; each entry carriesname,status,detail.fix_applied/fix_errornever appear here — the resource path never heals.- Reading this resource does not boost, lock, or snapshot. Use the
doctorCLI when you want to repair (--fix); the resource is observation only.
remindb://logs exposes the recent server log records for a desktop log console — the records serve writes to stderr/file, mirrored into a bounded in-memory ring buffer. The buffer is composed as a slog.Handler decorator over the existing stderr/file handler: it records what Handle receives, then delegates. Level filtering (--verbose, server.logging.level) happens upstream of the decorator, so the buffer is a faithful mirror of exactly what was emitted — never more. Payloads and node bodies are never logged (the logging conventions forbid it), so they never reach this resource.
{
"records": [
{ "time": 1737200000123, "level": "INFO", "msg": "serve: starting", "attrs": { "db": "mem.db" } }
],
"dropped": 0
}The shape is locked. Notes:
recordsis always present ([]before anything is logged), ordered oldest-first / newest-last, capped atserver.logging.buffer_size(default 1000, must be > 0).droppedis the count of records evicted once the buffer filled past capacity — a non-zero value tells a console it is not showing the full history.timeis Unix milliseconds;levelis the slog level string (DEBUG/INFO/WARN/ERROR);attrsis the flattened structured fields, always an object (group-prefixed with.when slog groups are used).- Reading this resource does not boost, lock, or snapshot. The buffer lives in process memory only — it is not persisted to the database and resets on restart.
remindb://sessions exposes the MCP client sessions currently attached to this serve process — the one bound to db_path. It answers "who's attached to this brain" for a desktop client. The registry is an enrichment side-table over the SDK's live session set: a single receiving-middleware stamps connected_at on first-seen, bumps last_activity on every inbound request except ping, and increments count_tool_calls on tools/call. Disconnect is not observed — it is inferred at read time by reconciling against the SDK's live set, so the count can never disagree with the SDK.
{
"db_path": "/repo/.remindb/memory.db",
"sessions": [
{ "id": "k7f3…",
"client_meta": { "name": "claude-code", "title": "Claude Code", "version": "1.2.0", "protocol": "2025-06-18" },
"transport": "stdio",
"connected_at": 1737200000, "last_activity": 1737200042, "count_tool_calls": 9 },
{ "id": "9a2c…",
"client_meta": { "name": "openclaw", "version": "0.4.0", "protocol": "2025-06-18" },
"transport": "http", "listen": "127.0.0.1:7474",
"connected_at": 1737200010, "last_activity": 1737200050, "count_tool_calls": 3 }
]
}The shape is locked. Notes:
sessionsis always present ([]when no client is attached), ordered oldest-connected first.idis the SDK session id; for the lone stdio session the SDK assigns none, so a stable hash (internal/contentid, the same primitive the persistent ledger will key on) is synthesized instead.client_metais always present; itsname/version/protocolcome from the client'sinitializehandshake, andtitle(human display name) is omitted when the client sends none. These are client self-reported and unauthenticated — display them, don't trust them as identity (this is exactly why the persistent ledger keys identity on a hash, not onclient_meta.name).transportis the process's transport (constant — oneserve= one transport).listenis the HTTP bind address and is omitted entirely for stdio sessions.connected_at/last_activityare Unix seconds;count_tool_callscountstools/callonly — resource reads and pings are activity but not tool calls.- Reading this resource does not boost, lock, or snapshot. The registry is in-process memory only; it is not persisted and resets on restart.
remindb://sessions answers "who's attached now"; remindb://sessions/history is its durable counterpart — "who has ever attached, for how long, how much". It is the read surface over the persistent session ledger (.remindb/sessions/, see configuration), accumulating across reconnects and serve restarts. remindb://sessions/history/{hash} returns the single client identified by hash (the id surfaced in the clients array).
{
"db_path": "/repo/.remindb/memory.db",
"clients": [
{ "hash": "Xy3K9mQ2pLs",
"client": { "name": "claude-code", "title": "Claude Code", "version": "1.2.0", "protocol": "2025-06-18" },
"transport": "stdio",
"sessions": 7, "lifetime_seconds": 18450, "last_disconnect": 1737200042, "tool_calls": 213 }
]
}The shape is locked. Notes:
clientsis always present ([]when nothing has ever attached), ordered by ledger filename.hashis the stable identity key — a content hash of the client's name/title/version/protocol/transport, not the spoofableclient.name. It is whatremindb://sessions/history/{hash}takes; the by-hash form returns the bare client object (nodb_path/clientswrapper) and errors on an unknown hash.clientis the last-seen self-reported metadata (same caveat assessions: display, don't trust as identity).sessionscounts distinct connections ever seen;lifetime_secondsis their summed connection duration;last_disconnectis Unix seconds (0 if no session has cleanly closed yet);tool_callsis the lifetime total across all of this client's sessions.- A session that crashed mid-life contributes its last checkpoint (loss ≤ one
flush_interval); a reconnect is a fresh session and never double-counts. - Reading this resource does not boost, lock, or snapshot. It is a pure projection over the on-disk JSONL —
resources.Depsholds only the ledger reader, no tracker or emitter.
remindb://sessions/logs is the read surface over the per-session logfiles under .remindb/logs/ (see configuration) — so a desktop client / operator can audit one MCP client's tool-call + Warn/Error trace without shelling into the workspace. The static URI is the index; remindb://sessions/logs/{id} returns one session's captured records, keyed by the session-id slug remindb://sessions reports (not the client content hash sessions/history uses).
{
"db_path": "/repo/.remindb/memory.db",
"logs": [
{ "session_id": "k7f3a9c2", "size_bytes": 4096, "rotated": false, "modified_at": 1737200042 }
]
}{
"session_id": "k7f3a9c2",
"entries": [
{ "time": "2026-05-19T12:34:56.0009Z", "level": "DEBUG", "msg": "mcp call",
"fields": { "tool": "MemoryWrite", "elapsed_ms": 4, "payload_bytes": 41 } }
]
}The shape is locked. Notes:
logsis always present ([]when session logging is disabled, unconfigured, or no client has logged yet — the directory simply doesn't exist or is empty), one entry per active logfile, ordered by filename.session_idis the logfile stem (the same slugremindb://sessionssurfaces);size_bytes/modified_at(Unix seconds) describe the active file;rotatedistruewhen a single-generation<id>.log.1tail exists alongside it.remindb://sessions/logs/{id}returns{session_id, entries}whereentriesis always present ([]for a file that exists but has no parseable records), each entry the locked{time, level, msg, fields}—timeis RFC3339Nano,levelthe slog level string,fieldsan object (omitted when empty), in append order (newest last). An unknown / missing id is a clean not-found error, never a panic.- Rotated tail is not included.
{id}returns the active file only. The.1is an explicitly-discarded single-generation safety tail (the prior.1is overwritten on the next rotation) and is the older half — prepending it would contradict "append order, newest last". The index'srotatedflag tells a consumer truncation happened; the structured read stays coherent. - The on-disk file is JSONL and the resource deserializes the same
sessionlog.Recordtyperender()serializes — one shared definition, no second hand-rolled parser that could drift. A round-trip test asserts fidelity. - Reading this resource does not boost, lock, or snapshot — it is a pure file read;
resources.Depsholds only the logs-directory path, no tracker or emitter. It carries only the payload-free fields the shared log already filters to (see logging-conventions §4), so no user content is exposed by construction.
remindb://rescan exposes the latest tick of the serve source-rescan loop — for a live rescan-activity panel. The loop publishes one snapshot per tick into a concurrency-safe in-process holder (pkg/mcp/rescanstat, the same shape as the session registry); the resource is a pure projection of it. With no --source (no loop) or before the first tick, the holder is its zero value.
{
"interval_s": 30,
"last_meta": {
"run_at": 1737200000,
"error": "",
"added": 2,
"modified": 1,
"removed": 0,
"purged_files": [ { "path": "notes/old.md", "nodes": 4 } ]
}
}The shape is locked. Notes:
interval_sis the configured rescan interval in seconds; it reflects live.remindb/config.jsonreloads (0until the first tick has run).last_metais exactly one tick's result, replaced wholesale each tick.run_atis Unix seconds (0= never run);erroris that tick's failure string, empty on success (a failed tick still publishes — counts stay zero,erroris set).added/modified/removedare the tick's compile counts. There is nototal— a consumer sums the three.purged_fileslists each source file that was deleted from disk that tick, withnodes= how many context nodes it carried. Always present ([]when nothing was purged). Purging is whole-file only — a file removed from the source tree drops all its context nodes — so there is no separatepurged_nodescount; the per-filenodesfully describes the purge. Entries sort bypath.- Reading this resource does not boost, lock, or snapshot. The holder is in-process memory only; it is not persisted and resets on restart. For durable history, opt into
server.rescan_files— every tick is then also appended to.remindb/rescan.jsonl(this exact per-tick shape), surviving restarts. See configuration. Reading that file back over MCP is out of scope for now.
A renderer that wants live state subscribes instead of polling. The server supports resources/subscribe / resources/unsubscribe (go-sdk v1.6.0) and emits notifications/resources/updated for a URI when its backing state changes. Subscription bookkeeping is the SDK's; the only server-side logic is which URIs are subscribable and how updates are coalesced.
Subscribable set. Only resources that meaningfully change at runtime are subscribable. Subscribing to anything else is rejected. The set, with the state change that triggers an update:
| URI | Triggered by |
|---|---|
remindb://graph |
write · summarize · compile · forget · rollback · relate |
remindb://snapshots |
write · summarize · compile · forget · rollback · rescan |
remindb://tree |
write · summarize · compile · forget · rollback · rescan |
remindb://files |
compile · rescan |
remindb://rescan |
a source-rescan tick that mutated the store |
remindb://temperature |
a temperature tick that decayed ≥ 1 node |
remindb://logs |
a new server log record |
(graph fires on any content write because relations are parsed from content — a payload with [[wikilinks]] adds edges; a debounced re-read on a link-free write is harmless. rollback does not restore relations but does change the referenced-node set the graph projects. rescan fires only on a mutating tick — a no-op scan that found no changes does not notify, so a "live activity panel" updates on real rescan activity, not on a fixed heartbeat.)
overview, doctor, sessions, sessions/history, and sessions/logs are not subscribable — overview/doctor are aggregate projections better polled on demand, sessions/sessions/history change only on connect/disconnect, and sessions/logs is an on-demand operator audit surface (subscribing would fire on every tool call). The templated forms (tree/{rootId}, snapshots{?limit}, snapshots/{id}/diffs, sessions/logs/{id}) are not independently subscribable; subscribe to the static parent.
Coalescing. Each subscribable URI has a debounce window. A burst of changes inside the window collapses to one notifications/resources/updated fired on the trailing edge — logs and temperature never flood. The intervals are config-driven (server.resources, see configuration.md), never hardcoded: a global debounce default plus per-resource overrides keyed by the short resource name (graph, snapshots, tree, files, rescan, temperature, logs). Defaults when the block is absent: 500ms global, with built-in floors of 1s for logs and 2s for temperature. An override naming an unknown resource is rejected at startup.
resources/list_changed is not emitted: the resource set is registered once at startup and never changes for the life of the process. The capability is advertised by the SDK but no list-change notification is ever sent.
This is read-only state introspection. Resources never mutate, never accept arguments that change the database, and are not a second way to do what a tool does. If a consumer needs to change memory, that is a Memory* write tool, with its snapshot and its lock — not a resource.