Storage and Memory¶
Entrabot has two separate things that could be called "memory," and they are owned by different systems:
- Operational state — the agent's own bookkeeping. Interaction logs, daily summaries, chat cursors, promises, the watched-chat list, and the email cursor all route through
MemoryBackendand may use local files or opt-in Azure Blob Storage. - Persona / mind memory — personality, relationships, running context, behavioral rules. When a
persona-satiMCP server is attached, the host calls its tools (write_memory_file,read_memory_file,bootstrap_session, and related) for this. Entrabot is not that system; it does not read or write persona content as part of its normal operation.
The MemoryBackend protocol¶
src/entrabot/storage/backend.py defines a small sync protocol that hides where a piece of operational state actually lives:
class MemoryBackend(Protocol):
def read_text(self, key: str) -> str | None: ...
def read_text_with_etag(self, key: str) -> tuple[str | None, str | None]: ...
def write_text(
self, key: str, content: str, *, if_match: str | None = None, if_absent: bool = False
) -> str | None: ...
def append_text(self, key: str, content: str) -> None: ...
def exists(self, key: str) -> bool: ...
def list(self, prefix: str = "") -> list[str]: ...
Keys are forward-slash-separated paths, e.g. "interactions/2026-04-17.jsonl". Two implementations ship:
LocalBackend— reads and writes files undercfg.data_dir(~/.entrabot/databy default). ETags are synthesized as a SHA-256 hash of the file's content, so identical content always produces the same ETag and any change produces a different one.BlobBackend— wraps the asyncBlobStoreclient (storage/blob.py) for these sync call sites via a smallasyncio.runshim that falls back to a worker thread when called from inside an already-running event loop (so it can't raiseasyncio.run() cannot be called from a running event loop).
Resolution order¶
get_backend() resolves which implementation to use, and it resolves this on every call, not once at import time — so a config change (or a test fixture) takes effect immediately without restarting anything but the process that reads the new environment:
ENTRABOT_KEEP_MEMORY_LOCAL=true→ alwaysLocalBackend. This is an explicit escape hatch and takes priority over everything else.- Both
ENTRABOT_BLOB_ENDPOINTandENTRABOT_BLOB_CONTAINERset →BlobBackend, wrapping aBlobStorewhose token provider isacquire_agent_user_storage_token(). - Neither set →
LocalBackend(the default). - Exactly one of
ENTRABOT_BLOB_ENDPOINT/ENTRABOT_BLOB_CONTAINERset → raisesBackendMisconfiguredError. This is deliberate: a half-configured cloud environment must fail loud rather than silently fall back to an empty local store — a fleet instance that quietly re-bootstraps against a fresh local store would look "up" while replaying every chat from scratch.
assert_backend_config() calls get_backend() during MCP initialization to surface a half-config error and log which backend resolved (BlobBackend + endpoint/container, or LocalBackend + root path). This validation runs after authentication and before poll setup, which reads through the backend, so a misconfigured instance starts no background tasks.
What actually routes through MemoryBackend¶
Modules that call get_backend():
tools/interaction_log.py— appends to and readsinteractions/<YYYY-MM-DD>.jsonl.tools/daily_summary.py— reads the day's interaction log to build the 17:00 (fixed UTC-7) summary.tools/promises.py— reads/writes the single keypromises.jsonl.tools/chat_cursors.py— one key per chat underchat_cursors/<url-quoted chat_id>.json.tools/poll_state.py— the watched-chat list and the email cursor, described next.
.singleton.lock deliberately stays local under cfg.data_dir: it is a per-machine process lock, not shared state.
Poll state¶
tools/poll_state.py owns the two values that decide what the background polls deliver:
| Value | Key | Format |
|---|---|---|
| Watched-chat list | watched_chats |
One chat ID per line, sorted |
| Email cursor | poll_state/email_cursor.txt |
One RFC 3339 timestamp, then the IDs of the last 32 claims, newest first, one per line |
With a cloud backend, neither value touches local disk. Instances on one container share them, and a machine moved to another agent's container can't carry over the old agent's chats or a stale cursor. If data_dir/watched_chats or data_dir/email_cursor.txt exist, they are never read or written; startup logs one info line saying they are being ignored.
With a local backend, little changes. watched_chats is the same file at the same path. The email cursor moves to the new key, and the first run adopts the value in the legacy email_cursor.txt once, so there is no gap in delivery. The legacy file stays in place.
The email cursor has a new key on purpose. Existing cloud containers hold a stale email_cursor.txt that setup's migration copied once and nothing has updated since, because runtime never read it. Reading that key would replay up to 50 old emails on upgrade, and replayed imperative messages are a security problem, not a nuisance (see DESIGN-multi-instance-cursor-consistency.md). So on a cloud backend, a missing cursor starts at "now", and neither the old key nor any local file is consulted. That happens only when the key is first initialized, on a container's first boot or after someone deletes it. Mail from before then isn't pushed, but stays readable through the read_email tool. Once the key exists, a restart reuses it, so qualifying mail that arrives while no instance runs is fetched and pushed as normal (one poll fetches the 50 newest).
The rules that make this fleet-safe:
- Reads classify as
PRESENT,ABSENT, orUNRESOLVED, like chat cursors. A failed read is never treated as absent: an unreadable list starts with no chats and retries, and an unreadable email cursor skips the cycle. Neither falls back to a local copy. - Every write is a compare-and-swap, including the first. Adding a chat merges into the list, so it can't remove another instance's chats. A failed write stays in a dirty set and is retried on the next discovery sweep, because discovery only registers chats that aren't in memory yet.
- A value with no ETag is treated as a failure, never overwritten unconditionally.
- All of this is blocking network I/O, so async code runs it in a worker thread. A past bug that did blob reads on the event loop starved the MCP
initializehandshake past the host's 30-second timeout.
Blob authentication¶
BlobBackend authenticates every request with an Agent-User-scoped OAuth token. acquire_agent_user_storage_token() (in tools/teams.py) is the same three-hop Blueprint → Agent Identity → Agent User flow used for Graph, with Hop 3's scope swapped to https://storage.azure.com/.default instead of Graph — Hops 1 and 2 are unchanged. See Identity and Token Flow for the full three-hop mechanics.
BlobStore (storage/blob.py) checks every response for HTTP 401 before doing anything else and raises TokenExpiredError when it sees one. Storage call sites are not universally wrapped in _with_token_retry(), so this mapping does not imply that every storage operation is automatically refreshed and retried.
Optimistic concurrency¶
write_text(key, content, if_match=etag) is a compare-and-swap: it succeeds only if the key's current ETag equals if_match, and raises ConcurrencyError otherwise. LocalBackend and BlobBackend both honor this — Local via the content-hash ETag, Blob via Azure's real ETag header (and its native If-Match precondition, surfaced as HTTP 412).
write_text(key, content, if_absent=True) is the same guarantee for a key that doesn't exist yet: it creates the key and raises ConcurrencyError if another writer got there first. if_match=None alone is an unconditional overwrite, so without this two instances that both read "absent" would both write, and the last one would win. LocalBackend hard-links a fully written temp file into place, which is atomic and fails if the key exists (falling back to an exclusive create where hard links aren't available); BlobBackend sends If-None-Match: *, and BlobStore treats both 412 and 409 as a conflict on that path, since Azure's docs list both.
Three call sites build real logic on top of this:
chat_cursors.claim_delivery()— the fleet-safe delivery ledger for inbound Teams messages. It reads the shared cursor with its ETag, computes which candidate message IDs aren't already recorded as delivered, and writes the merged cursor back withif_match=etag. OnConcurrencyErrorit re-reads and retries, up toCLAIM_MAX_ATTEMPTS(4) times; exhausting the retries returns an empty claim (push nothing) rather than risk double-delivering a message across multiple polling instances.promises.py— the same read-with-etag / write-with-if_match / retry-once-on-ConcurrencyErrorshape for the shared promises ledger.poll_state.py— merges into the watched-chat list with bounded retries, and claims each window of email by advancing the cursor before pushing it. See Messaging and Delivery.
Fail-closed cursor reads¶
chat_cursors.resolve_cursor() classifies every cursor read into one of three outcomes, and the distinction is load-bearing: ABSENT (the read succeeded and no cursor exists — only this case allows a poll to treat a chat as brand-new), PRESENT (a cursor exists and parsed, fresh or stale), or UNRESOLVED (the read raised, or the payload was corrupt or the wrong shape). UNRESOLVED is treated the same as "do not push anything this cycle" — an ambiguous read (a transient Blob 401, a timeout, a partial write) must never be mistaken for "this is a genuinely new chat," because that would replay old messages as if they were new.
Migration¶
src/entrabot/storage/migration.py provides migrate_local_to_backend(sources, target), where sources is an iterable of (local_root, blob_prefix) pairs and target is the destination MemoryBackend:
def migrate_local_to_backend(
sources: Iterable[tuple[Path, str]],
target: MemoryBackend,
) -> MigrationReport
Behavior:
- Source-preserving. Files are copied, never deleted — the local copy remains as a rollback path if
ENTRABOT_KEEP_MEMORY_LOCALis set afterward. - Local poll state is not copied. A machine's copies may be stale or belong to another agent. The migration skips
poll_state/email_cursor.txtand the legacyemail_cursor.txt, so the cloud cursor starts at "now", and it skipswatched_chats, because the shared list never shrinks and discovery rebuilds it from Graph about two minutes after boot. It also skips the legacychat_idkey and everything underchat_cursors/. A copied cursor is read back as present, which bypasses the 24-hour first-sight gate, so two agents sharing a group chat could replay old messages; a chat with no cursor takes the first-sight path instead. These exclusions match keys at the container root only, so a source copied under a prefix such asclaude_memory/keeps every file. The process-lock files are skipped too. - Idempotent. A key already present at the target is skipped, so re-running the migration only copies what's missing.
- Per-file error report, not an abort. A file that fails to read or write is recorded in
report.errorsas(key, message); one bad file does not stop the rest of the migration. - Missing source roots are skipped silently — a user with no Claude Code persona-memory directory still migrates their agent data cleanly.
scripts/setup.sh --use-cloud-memory calls this after provisioning Blob Storage, and treats any non-empty report.errors as a hard failure: the setup script prints the errors and exits non-zero, rather than reporting success while cloud memory is silently out of sync with local disk.
For what changes when an existing deployment picks this up, see Migrations and upgrades.
Persona memory: PersonaBackend¶
storage/persona.py's PersonaBackend is a thin wrapper around an existing MemoryBackend, scoped to the claude_memory/ key prefix:
class PersonaBackend:
def __init__(self, backend: MemoryBackend, *, local_root: Path) -> None: ...
def push_one(self, path: Path) -> None: ...
def push_all(self) -> PersonaReport: ...
def pull_all(self) -> PersonaReport: ...
It adds directory-level operations the per-key MemoryBackend protocol doesn't have: push_all uploads every local file not already present in the cloud, pull_all downloads every blob under claude_memory/ back to disk (cloud is authoritative on pull, so this overwrites local), and push_one uploads a single file, rejecting any path that resolves outside local_root.
PersonaBackend and its CLI wrapper, scripts/claude_memory_sync.py, are manual migration and compatibility utilities — a one-off way to move persona-memory files into or out of the same Blob container the operational backend uses, and a fallback for hosts without a persona-sati MCP server. Neither runs automatically as part of normal operation: there is no hook or background task that keeps claude_memory/ in sync on every write. When persona-sati is attached, it owns persona memory directly through its own MCP tools, independent of this path.
See also¶
- Storage Configuration guide — how to choose and configure a backend.
- Storage Backends reference — full API surface.
- MCP Runtime — how backend resolution fits into server boot.
- Messaging and Delivery — how chat cursors and delivery claims fit into the poll loop.
- Security Boundaries — the fail-closed principle applied across storage, cursors, and auth.