{"slug": "ctx-the-personal-context-engine-every-one-of-my-agents-shares", "title": "ctx: The Personal Context Engine Every One of My Agents Shares", "summary": "Zack Proser built ctx, a personal context engine that lives outside any single AI tool or vendor, to solve the problem of having to re-explain his life and work to every new agent. ctx is a single Postgres table behind a private MCP server at ctx.zackproser.com, reachable over MCP, REST, and OAuth, allowing any agent from any provider to connect with one line and immediately know his current context. Proser designed ctx so that memory outlives whichever tool is in fashion, inspired by Cerebras's internal knowledge base approach.", "body_md": "I use a lot of agents, and they keep changing shape. Claude Code in my terminal. Claude in the browser. Codex from OpenAI. pi. An agent on my phone. New ones show up every few weeks, in a new form factor, from a new provider. And every single one starts every conversation the same way: knowing nothing about me. So I re-explain. Who my clients are, how I like commits written, what's due this week, which repo is which, what I decided last month and why. The same context, typed again, into a different box, forever.\n\nThe thing that finally made me stop and build was noticing that the context never changes — only the wrapper does. Whether I'm in a terminal or a browser tab or a phone app, whether the model behind it is Anthropic's or OpenAI's, the facts about my life and work are identical. The agent is a stranger every time not because the information is hard, but because it lives in my head and in a dozen silos, and none of the silos talk to each other or to each other's vendors.\n\nBecause the per-tool memory features are silos, by design. Claude Code has its `CLAUDE.md`\n\n. Cursor has its rules file. Each chat product has its own memory feature, scoped to that product and that company. A fact I teach one is invisible to all the others, and it rots independently in each place. The form factors multiply and the providers multiply, and every combination is another island I have to re-provision by hand. I was maintaining the same context in a dozen places and still starting cold in every one.\n\nSo I built the memory as its own thing, outside any single tool or vendor, and let all of them plug into it. It's called ctx. It's a single Postgres table behind a private [MCP](https://modelcontextprotocol.io) server at `ctx.zackproser.com`\n\n, reachable over open protocols — MCP for the agents that speak it, plain REST for the ones that don't, OAuth for the browser. Any agent, any form factor, any provider — mine or a brand-new one I spin up tomorrow — connects with one line and immediately knows my current context. It is deliberately nobody's product. That's the point: the memory outlives whichever tool is in fashion this quarter. This post is the blueprint: the schema, the retriever, the security wall, and the specific failures that shaped all three. I built the whole thing in a day, but the day had structure, and the structure is the interesting part.\n\n## The shape of the problem\n\nFinding information about your own life is the same problem as finding information inside a company: it's scattered across tools that are each excellent at their one job and terrible at sharing. My todos live in Obsidian. My tickets live in Linear — two accounts, personal and work. My meetings live in cal.com and Granola. Decisions and preferences live in my head and in chat transcripts that evaporate. Nobody is going to consolidate all of that into one app, and they shouldn't; the tools are good because they're specialized.\n\nThe Cerebras team [wrote up how they built their internal knowledge base](https://www.cerebras.ai/blog/how-we-built-our-knowledge-base) around exactly this insight — meet the data where it lives, land everything in one embeddings table, and expose dumb-fast retrieval primitives that the calling agent orchestrates. Their corpus is millions of documents across a company. Mine is a few thousand items for one person. I stole the ideas that survive the scale-down and threw out the ones that don't.\n\nBefore I let Claude write a single line of code, I made it get the design straight. I ran the whole thing through [Nick Nisi's ideation plugin](https://github.com/nicknisi/ideation) — an evidence-gated interview that turns a brain dump into a contract and then into implementation specs, with adversarial plan critics stress-testing it along the way. The interview forced me to answer the questions I'd otherwise have discovered halfway through the build: what's owned versus mirrored, where the scope wall lives, what \"done\" actually means. Four plan critics — scope-creep, over-engineering, hidden-dependency, success-criteria — then attacked the contract before it became specs. Only once the specs were approved did any code get written. That front-loading is why a day was enough.\n\nThe north star was never corpus size or feature count. It was this: the right five items in the agent's context within 300 milliseconds, with a hard second constraint that some of my most private context — legal, financial, family — can never leak into a work-context surface. Retrieval accuracy is the goal. The wall is the thing I refuse to get wrong.\n\n## One table, one interface\n\nAt the center is a single Postgres table. Every source — an Obsidian note, a Linear ticket, a calendar event, a distilled fact from a chat — lands in it with the same shape. Anything in the table is retrievable through the same query. New sources are just new writers. That deliberate sameness is what keeps the system small enough for one person to hold in their head.\n\n```\ncreate table items (\n  id            uuid primary key default gen_random_uuid(),\n  scope         text not null check (scope in ('personal','work','shared')),\n  origin        text not null check (origin in ('mirror','memory')),\n  kind          text not null check (kind in\n                  ('fact','preference','decision','project','task',\n                   'episode','person','reference','event')),\n  title         text not null,\n  body          text not null,\n  entities      text[] not null default '{}',\n  source        text not null,\n  external_key  text,               -- '<source>:<id>#<fragment>' for mirrored rows\n  content_hash  text not null,      -- exact-dedup + change detection\n  status        text not null default 'active'\n                  check (status in ('active','superseded','deleted')),\n  observed_at   timestamptz not null default now(),\n  event_at      timestamptz,\n  due_at        timestamptz,\n  task_state    text check (task_state in ('open','done','cancelled')),\n  embedding     vector(1536),\n  fts           tsvector generated always as\n                  (to_tsvector('english', title || ' ' || body)) stored,\n  pinned        boolean not null default false,\n  supersedes    uuid references items(id)\n  -- (abridged; the real table carries version columns and sync bookkeeping)\n);\n```\n\nTwo fields carry most of the weight. `origin`\n\nsplits the world into **mirrors** and **memories**. A mirror is a re-derivable copy of something that lives authoritatively elsewhere — a Linear ticket, an Obsidian note — and mirrors are always safe to wipe and re-sync. A memory is knowledge the engine actually owns: a preference, a decision, a lesson from a session, something with no other home. Mirrors get updated by their connector; memories get corrected by supersession. Never confusing the two is what keeps the corpus honest.\n\n`status`\n\nis the single source of truth for whether a row is live. This sounds trivial. It is not, and it produced one of the sharpest bugs of the day, which I'll get to.\n\nEvery reader reaches that table through the same six tools. The shapes below are the live responses from `ctx.zackproser.com`\n\n, with my private values redacted — this is the whole interface:\n\n```\nsearch(query, kinds?, k=8)                → ranked items\nbriefing()                                → { pinned, open_tasks, upcoming_events, recently_learned }\nrecent(days=7, kinds?)                    → items, newest first\nremember(text, idempotency_key?, pinned?) → { id, title, kind, near_dups }\nforget(id)                                → soft-delete   (requires review grant)\ncorrect(id, new_body, new_title?)         → supersede     (requires review grant)\n```\n\n`search`\n\nreturns scored, distilled rows. This paraphrase query shares no vocabulary with the preference it lands — the vector retriever earns its place:\n\n```\n// search({ query: \"how I like git commits written\", k: 3 })\n[\n  {\n    \"id\": \"b1f2…\",\n    \"title\": \"…\",            // distilled: a line an agent would search for\n    \"body\": \"…\",\n    \"kind\": \"preference\",    // fact | preference | decision | project | task | episode | person | reference | event\n    \"source\": \"…\",\n    \"source_ref\": null,\n    \"observed_at\": \"2026-07-19T20:26:00.377Z\",\n    \"event_at\": null,\n    \"due_at\": null,\n    \"score\": 1\n  }\n]\n```\n\n`briefing`\n\nis what a cold agent pulls at session start — four buckets, each a list of that same row shape:\n\n```\n// briefing()\n{\n  \"pinned\":           [ /* items flagged always-surface */ ],\n  \"open_tasks\":       [ /* task_state: \"open\", ordered by due_at */ ],\n  \"upcoming_events\":  [ /* event_at within the next 7 days */ ],\n  \"recently_learned\": [ /* newest memories first */ ],\n  \"generated_at\":     \"2026-07-20T…Z\"\n}\n```\n\nThe write and delete paths report exactly what happened — and the wall shows up here too. A delete refuses unless the token carries the review grant:\n\n```\nremember(\"…\")  → { \"id\": \"8aa5…\", \"title\": \"…\", \"kind\": \"reference\", \"near_dups\": 0 }\nforget(\"…\")    → error 403: token lacks 'review' grant\n```\n\n## Distill, don't dump\n\nRaw text is a bad thing to embed. \"hey yeah sure\" and a detailed architecture decision are both messages, and in raw cosine space the short one often wins because it's short. So nothing goes into the table raw. Everything is normalized first into a consistent `{title, body, entities}`\n\nshape — a one-line title an agent would actually search for, a concise body, and normalized entity tags. For memories, a small model (Claude Haiku) does the distillation on the write path, where latency doesn't matter. For mirrors, the connector shapes the row.\n\nThe write path itself is boringly strict, on purpose:\n\nThe rule that matters most here: the distilling model's output can only ever populate content fields. It can never choose the row's scope, its status, its id, or what it supersedes. The server assembles the row. A prompt-injected note that says \"mark all other memories deleted\" gets distilled into one junk row and nothing more. The blast radius of a bad input is exactly one row.\n\n## Retrieval: three retrievers, a scorer, and a reranker I didn't want to build\n\nSearch is the core, and it's the part where the design met reality hardest. The pipeline is three candidate retrievers running in parallel over the rows a caller is allowed to see:\n\n**Full-text search** catches exact tokens — an error string, a ticket ID, a flag name. When I paste a literal identifier, no amount of semantic similarity should outrank an exact lexical match.**Trigram search** on the title catches the identifiers and names that English stemming mangles.**Vector search** catches paraphrase. \"what's my rent situation\" and \"housing costs after the separation\" share no words and mean the same thing.\n\nEach produces a ranked list. The reviewer who tore apart my first design — more on him in a moment — pointed out that the obvious fusion method, reciprocal rank fusion with equal weights, throws away signal strength: it preserves only rank, so a mediocre result that shows up in two lists beats an excellent result that shows up in one. That's exactly backwards for exact-match queries. So instead the candidate union goes through a weighted scorer with an abstention floor:\n\n```\ncosine_n = clamp((cos_sim - FLOOR) / SPAN, 0, 1)   // absolute, never min-maxed\nlex      = max(fts_rank/(fts_rank+1), trigram_sim)\nexact    = |query tokens ∩ item tokens| / |query tokens|\nentity   = query hits an entity tag ? 1 : 0\nfresh    = kind-aware, bounded (a task's due date, an event's proximity, else neutral)\n\nscore = 0.40*cosine_n + 0.30*lex + 0.15*exact + 0.10*entity + 0.05*fresh\n```\n\nTwo decisions in there came straight out of pain. First, freshness is a small bounded feature, not a global decay multiplier. My first version multiplied every score by an age-decay term, and it buried a still-correct decision from four months ago under a meeting that mentioned the same word yesterday. Recency is a tiebreaker, not a hammer. Second, categorical rules outrank the weights: a done task drops out of default results after 30 days, an expired event is ineligible, a superseded row is gone. Those aren't down-weighted; they're removed.\n\nThen I ran the eval suite — 52 real queries across six classes with known-correct answers, run against a seeded fixture — and it failed. Recall@5 was 0.84 against a 0.9 gate. The failures clustered exactly where the reviewer predicted: paraphrase queries that share no vocabulary with their target, and domain-adjacent negatives (\"kafka consumer rebalancing\" hitting my infra notes) that should return nothing. Those two populations overlap in score space. No single threshold separates \"true paraphrase match\" from \"same topic, wrong answer.\"\n\nThe design had already named the escape hatch: if linear-scorer tuning stalls, add a reranker over the top 20 — *and not before*. Tuning had stalled. So I added it. A Haiku pass takes the query and the top candidates and returns the ids that genuinely answer it, ranked, with an empty list as a valid answer. That last part is what makes negative queries abstain instead of returning the least-bad garbage. On any failure — timeout, bad output, provider hiccup — it falls back to the weighted order. Availability beats precision.\n\nRecall@5 went to 0.955 and every negative query came back clean. The reranker is the one place an LLM sits in the read path, and I resisted it until the evidence forced it. That restraint is the whole point: the design predicted the exact failure and pre-authorized the exact fix, so when the eval failed I wasn't improvising, I was executing a plan.\n\n## The wall: enforced by the database, not by prompts\n\nHere's the part I refuse to get wrong. ctx holds my most private context. It also has to be reachable from work-context surfaces — a Claude Code session on my work laptop, an agent drafting in a work Slack. Those surfaces must be *structurally incapable* of reading personal data. Not \"instructed not to.\" Incapable.\n\nThe temptation is to enforce this in application code: wrap every query with a `where scope = ...`\n\nclause derived from the caller's token. That is not a security boundary. It's a boundary until the one forgotten `where`\n\nclause in the one new endpoint, and then it's a breach. The reviewer's first critical finding was exactly this, and he was right.\n\nSo the wall lives in Postgres. Two database roles — `ctx_personal`\n\nand `ctx_work`\n\n— behind two separate connection credentials. `FORCE ROW LEVEL SECURITY`\n\non every table. Row-level policies with `WITH CHECK`\n\non every verb. Column-level revokes so a serving role can't rewrite a row's scope even if it wanted to.\n\n```\nalter table items enable row level security;\nalter table items force row level security;\n\ncreate policy work_select on items for select to ctx_work\n  using (scope in ('work','shared') and status = 'active');\ncreate policy work_insert on items for insert to ctx_work\n  with check (scope = 'work');\n\ncreate policy personal_select on items for select to ctx_personal\n  using (scope in ('personal','shared') and status = 'active');\n```\n\nThe work request path physically cannot acquire a personal-role connection, because the credential is chosen once, at authentication, from the token — there is no `SET ROLE`\n\nanywhere in the serving code. A forgotten `where`\n\nclause in any endpoint, any background job, any future admin script still returns nothing it shouldn't, because the database itself refuses.\n\nThe piece that took me a beat to see is how a request *becomes* one role or the other. There is no runtime switch. Each role is a **separate Postgres login with its own connection string**, stored as its own Worker secret — `DATABASE_URL_PERSONAL`\n\nand `DATABASE_URL_WORK`\n\n. A token's role is baked into its record when I mint it, and on every request the server reads the token, looks up its role, and picks the matching connection. `role: work`\n\nopens the `ctx_work`\n\nlogin, whose RLS lets it see only work and shared rows; `role: personal`\n\nopens the other. The token doesn't *ask* for a scope — it *is* a scope, because it maps to a database credential that can't reach past it.\n\nSo \"how do I specify which role a client gets\" is just a flag when I create its token:\n\n```\n# a read-only work surface: work login, can only ever see work + shared\nnode scripts/mint-token.mjs --client claude-code-work --role work --grants read\n\n# my main personal driver: personal login, full rights\nnode scripts/mint-token.mjs --client claude-code-mbp --role personal --grants read,write,review\n\n# the phone: personal login, but write-only — it can push, never read\nnode scripts/mint-token.mjs --client iphone --role personal --grants write\n```\n\n`--role`\n\ndecides the database login (and therefore the RLS universe); `--grants`\n\ndecides which tools the token may call — `read`\n\nfor search/briefing/recent, `write`\n\nfor remember, `review`\n\nfor forget/correct, `sync`\n\nfor the ingester. Two independent layers: grants gate the *tools* in application code, RLS gates the *rows* in the database. A bug in the first can't breach the second.\n\nAnd this is not asserted, it's tested. A canary suite plants distinctive personal marker rows, then probes the deployed server under a real work-role token across every read path — search, briefing, recent, every MCP tool — and asserts the leak count is exactly zero. Green canaries are the precondition for ever minting a work token. Today, they're the reason ctx runs personal-scope only: work data doesn't enter until the full sensitivity screen ships behind those same canaries. The wall exists before the thing it protects.\n\n## The failures that shaped it\n\nI want to be specific about what broke, because the bugs are more instructive than the design.\n\n**The database defended itself against me.** Soft-deleting an item — flipping `status`\n\nto `deleted`\n\n— failed with a row-level-security violation. The reason is beautiful in hindsight: the `deleted`\n\nrow is invisible to the select policy, so Postgres rejects the `UPDATE`\n\nbecause the *new* row would violate the policy. The very invisibility I wanted made the write illegal. The fix was to route status transitions through a `SECURITY DEFINER`\n\nfunction that runs as an admin role — the wall forcing me to make the privileged operation explicit instead of accidental. Exactly the property you want from a security boundary: it makes the dangerous thing hard on purpose.\n\n**The eval fixture poisoned production.** The first briefing I pulled looked great and was completely wrong. It confidently listed tasks I'd never written — \"rotate the R2 keys,\" a demo that didn't exist — blended right in with my real calendar. The 126-item *test* corpus had been seeded into my *production* personal scope and blended in. I purged it, then made the eval runner tear down its own fixture after every run. A test that pollutes the system it measures is worse than no test.\n\n**Task extraction found 3,000 todos I didn't have.** My Obsidian vault has checkboxes everywhere — study plans, journals, meeting notes from years ago. My first ingester treated every checkbox as a todo. The fix was to scope extraction to the notes that are actually todo lists, and — the detail that made it click — to parse my filename convention. My daily notes are named `month-day-year`\n\n, and that date is the single best signal for what I was worried about on a given day. So an unchecked box in `7-17-2026.md`\n\ncarries July 17 as its rank, and the briefing now puts my vault's most recent unchecked worries *above* even Linear. The thing I scribble in a dated note at 6am reflects what's actually on my mind better than any ticket system does.\n\nNone of these were design flaws in the abstract. They were the specific, physical ways a clean design meets a real vault, a real database, and a real day.\n\n## Bootstrapping a new agent\n\nThe whole promise is that a new agent connects in one step. For most form factors it genuinely is one line. An MCP-speaking terminal agent — Claude Code, pi — gets pointed at the server with a bearer token:\n\n```\nclaude mcp add --transport http ctx https://ctx.zackproser.com/mcp \\\n  --header \"Authorization: Bearer <token>\"\n```\n\nA REST client — Codex, a bot, a shell script — sends the same token as a header to `/api/search`\n\nor `/api/remember`\n\n. That's the entire onboarding: a token scoped to what that surface should do (read, write, both), revocable on its own, and the standing instruction block pasted into the agent's prompt so it knows to call `briefing`\n\nat the start and `remember`\n\nwhen it learns something. New provider, new tool, same two lines.\n\nThe browser is where that breaks, and it taught me the most about how the ecosystem actually works. Custom connectors in the browser don't accept a bearer token or an API key the way my terminal agents do — the client mandates a full OAuth handshake, and it runs that handshake whether you configure auth or not. There is no \"just use a header\" path. My token-in-a-header and token-in-the-URL tricks both bounced off the same wall.\n\nSo I built a minimal OAuth 2.1 shim, and it is itself a bootstrap sequence — the automatic negotiation a browser agent runs to go from \"I know a URL\" to \"I hold a scoped token,\" with exactly one human step in the middle. Here is the whole handshake:\n\nWalk it top to bottom. The agent hits the server cold and gets a `401`\n\nwhose `WWW-Authenticate`\n\nheader points at the discovery document — that header is what tells an OAuth-capable client \"start here.\" It reads the two well-known documents to learn where to register and that PKCE is mandatory. It registers itself, and the server hands back a `client_id`\n\nbut only after checking the redirect URIs are Anthropic-hosted — a stranger can't register a callback that points at their own server. Then the one human step: the agent opens the authorize page in my browser, and I paste an existing ctx token. That token isn't sent onward — it only *proves* I'm authorized, in my browser, out of band from the agent. On success the server mints a **fresh, independently revocable** token, binds it to a one-time code, and redirects back. The agent exchanges that code — with the PKCE verifier that proves it's the same client that started the flow — for the real token, and only then can it call the tools.\n\nThe property that matters: the secret is entered in *my* browser and never handed to the agent to relay, the token the agent ends up with is new and scoped and revocable on its own, and the redirect allowlist plus PKCE mean nobody can hijack the flow midway. The shim wraps the bearer model; it doesn't weaken it. Same wall, new front door — and the front door negotiates itself.\n\n## Three rounds with a hostile reviewer\n\nThe single best decision of the whole build was refusing to trust my own design. Before writing a line of code, I ran the design document through three rounds of adversarial review with a separate frontier model — GPT-5.6 — with one instruction: attack this, find what fails in practice, no praise.\n\nIt earned its keep immediately. Round one killed prompt-level scope enforcement in favor of database RLS, killed cosine-threshold auto-merge as a \"data-poisoning machine\" (two contradictory facts embed almost identically — auto-merging them silently destroys history), and killed the equal-weight rank fusion. Round two caught that my sensitivity screen's own diversion logic violated the RLS I'd just added, and that the whole thing wasn't actually buildable in a day as specced — forcing the honest phasing that made a one-day ship real. Round three caught invalid SQL and an unhardened function before they ever ran.\n\nEvery finding is in the repository's design doc with its resolution. The pattern generalizes past this project: **the cheapest bug to fix is the one an adversary finds in your plan before you've written the code.** A design review by something that doesn't want to be nice to you is worth more than a week of your own careful thought, because you are constitutionally unable to attack your own ideas as hard as a stranger will.\n\n## Getting context in: the ingester and the phone\n\nReading is only half of it. Context has to get *into* the table, and the two sources that matter most day to day are the ones I don't want to think about: my Obsidian vault and my phone.\n\nThe vault is the harder one. It's the todo tracker of record — a few thousand notes, years of daily files — and it lives on disk, not behind an API. So the ingester is a small zero-dependency Node script that walks the vault, respects a `.ctxignore`\n\n, chunks each note by heading into mirror rows, and sends them to the server in batches through a `sync`\n\nendpoint. Mirrors, remember, are re-derivable: a full re-sync is always safe, so the ingester doesn't have to be clever about incremental diffs to be correct.\n\nTwo details make it actually useful rather than noisy. First, task extraction is scoped — only notes under my `TODO/`\n\nfolder and `NEXT ACTIONS.md`\n\nbecome tasks, so the stray checkboxes in old study plans and meeting notes stay ordinary reference text. Second, it reads my filename convention. My daily notes are named `month-day-year`\n\n, and that date becomes the task's rank, so an unchecked box in `7-17-2026.md`\n\ncarries July 17. The briefing then floats my most recent unchecked worries above even Linear — because the thing I scribbled in a dated note this morning is a truer signal of what's on my mind than any ticket.\n\nThe ingester now runs unattended on a `launchd`\n\ntimer that wakes every thirty minutes, on both the work and personal MacBooks at once. The thing I cared about most here was that a sync utility running on a loop, on machines I'm not watching, must be constitutionally unable to spin out of control — not \"carefully written so it won't,\" but built so it can't. So the timer never runs the ingester directly. It runs a wrapper whose entire job is to say no, and almost every wake is a sub-second no-op that says no and exits.\n\nThe wrapper is a stack of guards that each fail fast and cheap, in order. A kill-switch file lets me pause everything with a `touch`\n\n. A single-runner lock — an atomic `mkdir`\n\n, reaping only a confirmed-dead PID — means a slow run never overlaps the next wake. A per-hour rate cap and a failure-backoff cooldown bound how often it can act and back it off entirely after repeated errors. A debounce and a change-detection pass (`find -newer`\n\nagainst a marker) skip the run outright when the vault hasn't changed, which is most of the time. A battery-level guard and a reachability preflight keep it from working when I'm low or offline. Only if every guard passes does the actual sync run — and it runs under a hard process-group timeout that sends `SIGTERM`\n\nthen `SIGKILL`\n\nto the entire tree, so a wedged network call or a runaway child can't outlive its five-minute budget. State is read with a parser that never sources the file as shell and written atomically; the clock is treated as untrusted so a rollback can't confuse the debounce. Two laptops can run the same timer because a cross-machine lock on the server (a `409`\n\nfrom the sync endpoint) makes the second one stand down. Two laptops, one clean corpus, no coordination on my part.\n\nNone of this is bespoke to one laptop. The wrapper, the `launchd`\n\njob, the ingester, the schema, the Worker, and every connector live in one private git repo, `zackproser/ctx`\n\n. Setting up a new machine is a checkout and one install script that writes a `0600`\n\nenv file and loads the timer; there is nothing hand-tuned living only on a disk somewhere. Because it's all versioned in a repo I own — not a managed feature inside someone's product — the whole system is reproducible, auditable, and mine. It survives me neglecting it, and it survives any single vendor deciding to change the deal.\n\nThe phone is the opposite problem: no daemon, no disk access, just me wanting to throw a thought into the system while walking. That's an iOS Shortcut wired to the share sheet and to Siri. It takes whatever I say or share and does a single `POST /api/remember`\n\nwith a **write-only** token — the same write path any agent uses, distilled and embedded server-side. The token can push but can't read a thing, so the phone in my pocket can add to my context without being able to pull any of it back out if it's lost. \"Hey Siri, remember that…\" and it's in the table, retrievable from every other surface a moment later.\n\n## What's next: memory that writes itself\n\nThe version running today is the read side done well and the write side done manually. The interesting frontier is closing the loop so the system feeds itself.\n\nThe first step is already built: a **writeback skill**. It's a small, reusable capability I drop into every coding agent so that when it discovers something worth keeping — a gotcha about a library, a decision we just made, the reason a fix works — it calls `remember`\n\non its own, mid-session, without me prompting it. I used to be the one who noticed \"that's worth saving\"; now the agent has that habit everywhere it runs. The payoff is cross-machine and cross-tool: a lesson Claude Code learns debugging on my work laptop at noon is in the table when Codex on my personal machine hits the same wall at night. I built the skill by pointing it at its own construction — the session that wrote it also used it to preserve what it learned, and every one of those learnings came back on a paraphrased search. The corpus stops being something I maintain and becomes a byproduct of the work itself, every session leaving the next one a little less ignorant.\n\nThe second piece is live too: a small **admin panel** at `/admin`\n\n, reachable only by me. It lists every minted token with its role and grants, shows pending device requests, and revokes anything with one click, writing each revocation to the audit log. The access control is the same belt-and-suspenders the rest of the system uses: Cloudflare Access sits in front and gates the route to my email alone with an identity check at the edge, and the Worker independently verifies the signed Access token — team domain, audience, and email claim — before serving a byte. It fails closed; an unauthenticated or forged request never reaches the console. This is the one surface built for a human rather than an agent, and it's the mirror image of the wall: the wall keeps agents out of the wrong scope, the panel keeps everyone but me out of the controls.\n\nTwo things are still staged behind the same wall. The **sensitivity screen** is the gate that lets work data in at all: a classifier on the work-write path that diverts anything personal-shaped into a quarantine for review, so that work context can finally be captured without risking a leak, and it ships only once the canary suite is green against it. The **device-authorization flow** — already designed and adversarially reviewed — will replace \"paste a token\" with a phone approval, so onboarding a brand-new agent is a tap instead of a copy-paste. Each is the same principle the whole system runs on: make the safe path the easy one, and let the database, not my vigilance, be the thing that says no.\n\n## What it cost and what it's worth\n\nThe stack is deliberately boring: a Cloudflare Worker, Neon Postgres with pgvector, OpenAI embeddings, Claude Haiku for distillation and reranking, GitHub Actions for encrypted nightly backups, Healthchecks for dead-man's switches. Nothing exotic, everything managed, all of it chosen so the system survives two years of me not touching it. The most complex thing in the build is the discipline, not the infrastructure.\n\nWhat it's worth is simple to state. Every agent I use now starts warm, no matter its shape or its vendor. A fresh Claude Code session in an empty directory, given nothing but the connection URL, can tell me what's on my plate this week — my dated worries from the vault, my open tickets, my calendar — before I've typed a second sentence. So can Claude in a browser tab, through the OAuth flow. So can Codex over REST. The same memory, the same answer, whichever door I walk in through.\n\nI stopped being the integration layer between my tools. The context lives in one place, the agents draw from it, and the wall makes sure the private half stays private no matter which agent is asking.\n\nThe agents will keep changing. New form factors, new providers, new tools I haven't heard of yet — that churn is the one thing I'm certain of. What I don't want to keep re-doing is teaching each new one who I am from scratch. So the memory doesn't live inside any of them. It lives in one place they all reach, over open protocols, behind a wall the database enforces. The tools are interchangeable now. The context isn't, and it finally stopped being trapped in whichever one I happened to open.\n\nAnything on this sheet still unclear — or anything you were too polite to ask out loud? File an RFI. Answers come from the drawing itself and cite their sheet numbers, and every question is recorded in the drawing log so the next revision can answer it in print.", "url": "https://wpnews.pro/news/ctx-the-personal-context-engine-every-one-of-my-agents-shares", "canonical_source": "https://zackproser.com/blog/ctx-personal-context-engine", "published_at": "2026-07-20 00:00:00+00:00", "updated_at": "2026-07-20 13:57:49.014089+00:00", "lang": "en", "topics": ["ai-tools", "ai-infrastructure", "developer-tools"], "entities": ["ctx", "Zack Proser", "Claude Code", "OpenAI", "Anthropic", "Cursor", "Obsidian", "Linear"], "alternates": {"html": "https://wpnews.pro/news/ctx-the-personal-context-engine-every-one-of-my-agents-shares", "markdown": "https://wpnews.pro/news/ctx-the-personal-context-engine-every-one-of-my-agents-shares.md", "text": "https://wpnews.pro/news/ctx-the-personal-context-engine-every-one-of-my-agents-shares.txt", "jsonld": "https://wpnews.pro/news/ctx-the-personal-context-engine-every-one-of-my-agents-shares.jsonld"}}