Five assistants, five memories.
Each AI app remembered a different slice of me, and none of them could see my existing notes: a git repo of preferences, decisions and project facts that my coding agents already used on my laptop. Web and phone apps can't read files on a laptop.
Separate silos
Telling ChatGPT a preference didn't reach Claude. Claude on my phone couldn't see what Claude Code knew. I repeated myself everywhere.
One source of truth
Every app reads the same git-backed memory through one MCP endpoint, and recalls it automatically at the start of a conversation. New facts land as notes that every app sees next time.
Follow a request.
Press play to trace one tool call from an app on my phone to the Mac and back. Every hop adds one check, and nothing past the gateway is reachable from the internet.
The calls I made, and why.
It's a single-user server on a laptop in my apartment, so the bar was simple: nothing reachable that doesn't need to be, and nothing that needs me at a terminal to keep running.
01 · networkA gateway at the edge, not open ports
- Decision
- The Mac only makes an outbound tunnel connection. The single public entry point is a Cloudflare Worker, and Cloudflare Access admits only the Worker's service token to the tunnel.
- Why
- A home IP never appears anywhere, the router has nothing forwarded, and every request is authenticated twice before it can touch the Mac.
- Instead of
- Port forwarding or a public reverse proxy on the Mac.
02 · identityOAuth for apps, hashed tokens for agents
- Decision
- Claude and ChatGPT sign in with GitHub OAuth, and only my GitHub account is accepted. Coding agents and bots each get their own static token, stored only as a SHA-256 hash and compared in constant time.
- Why
- Each client is named in the logs and can be revoked alone. Adding a client never touches another's secret.
- Instead of
- One shared API key for everything.
03 · the gateAllowlists, not filters
- Decision
- The gateway accepts one JSON-RPC request at a time, on a fixed list of methods and tools, with capped bodies. It hides tools from
tools/listthat a caller isn't allowed, and a kill switch turns every route off. - Why
- New tools on the server stay invisible until I add them to the list, so a deploy can't widen access by accident.
- Instead of
- Passing traffic through and trusting the server.
04 · memoryMemory is a git repo
- Decision
- The server reads its own clone of my memory repo, kept in sync by a background job. The only write is a new dated note in an inbox folder, committed and pushed. A later session files it.
- Why
- Full history, easy review and rollback, and the same files my laptop agents already use. No database to back up.
- Instead of
- A vector database, or letting apps rewrite memory freely.
05 · operationsDeploys by asking in chat
- Decision
- A
deploy_requesttool only touches a trigger file. A LaunchDaemon sees it, pullsmasterand ships just what changed. Gateway tests must pass first, and a root-owned helper with a pinned hash swaps the server code. - Why
- I can merge and deploy from my phone. The worst a caller can do is redeploy what's already on
master. - Instead of
- A timer that pulls constantly, or SSH from the cloud.
06 · mailMail that can't send
- Decision
- Read-only IMAP tools for Yahoo and iCloud. They search, read and save drafts, and nothing else. Only Claude's own apps get them, decided by the OAuth client's registered redirect, not the name it claims.
- Why
- Email is text from strangers, so it's a prompt-injection channel. Every mail result warns models not to save its content to memory without my OK.
- Instead of
- Full mailbox access for every connected app.
07 · least privilegeA locked-down service user
- Decision
- The MCP server runs as a dedicated non-admin user, listens on 127.0.0.1 only, and runs under launchd so it restarts on failure and survives a closed lid.
- Why
- Even a bug in the server can't reach my login account's files or anything beyond its own folder.
- Instead of
- Running it as me, in a terminal tab.
08 · metricsMeasure at the one choke point
- Decision
- The gateway writes one row per request to Cloudflare D1: app, method, tool, latency and outcome. It never stores arguments or results, and the write happens after the response, so it can't slow a request.
- Why
- One place sees every call. A private dashboard breaks it down by app and tool; a public endpoint powers the numbers below.
- Instead of
- Separate logs on every server.
It's running right now.
These come straight from the gateway's public stats endpoint. It returns only totals, never app names, tool names or content, and caches them for a minute so this page can't load the Mac.
The live numbers can't be reached right now. The stats endpoint may be down, or the server may be offline for maintenance. Try again in a minute.
Proving the models actually use it.
Unit tests check the plumbing. The harder question is behavioral: when I state a preference in passing, does the model save it, and does the note really land in the repo? So I wrote evals for that.
gateway tests: auth, allowlists, body caps, mail grants, usage logging and the public stats.
memory server tests: path safety, inbox format, a full HTTP run that commits and pushes, deploys and mail.
chat scenarios run through real Claude models against a sandboxed copy of the memory repo with its own git remote.
case pref-branch-prefix user › Quick heads up: from now on I want all my git branches named with a pk/ prefix. Anyway, what's 17*23? expect a note reaches the remote and contains "pk/" results 85 / 85 trials passed across two Claude models also checked 8 cases that must NOT save: generic questions, a pasted secret, someone else's address known gap models often skip the recall step when a message states a preference. Recall is reported, not graded, and is the next thing to fix.
More capabilities, same front door.
The server will host more than memory. The plan keeps one gateway, one sign-in and one tunnel, and gives each capability area its own small server and its own URL path.
/memoryRecall, search and inbox notes. Every app.
/opsDeploys now; health, logs and restarts later.
/mailMoves out of the memory server into its own namespace.
Each path is its own OAuth resource, so a token issued for /memory is rejected at /ops, and each server is its own process, so a bad deploy of a new one can't take memory down. The migration takes four steps, and each one can be reverted on its own:
- Add the ops server beside memoryThe existing connector doesn't notice.
- Gateway serves several resourcesThe new paths go live while the old URL keeps working.
- Switch the apps overOne connector per namespace in each app.
- Retire the old URLOnly after the usage log shows two weeks with no traffic on it.