Under the hood: how Contexo remembers your project — without keeping your secrets
Second in the Under the hood series: a code-level walk through Contexo's data path. What actually crosses the wire when your agent pushes (a list of pages, not your transcript), why the raw session buffer never leaves your laptop, how your tokens are hashed, and the honest answer to 'does Contexo keep my API keys?'
Your AI agent has read your .env. It knows your database URL, maybe a Stripe key, maybe a JWT secret. Contexo’s whole pitch is that it remembers what your agent learns about your project — so a few people have asked the obvious, important question: does it now remember my secrets too?
The reassuring answer is “no.” The convincing answer is in the code — so this post is the code. We’ll follow your context from your laptop to the server and back, and you’ll see exactly where the dangerous stuff is, and where it isn’t.
(This is the second Under the hood post. The first built the distiller; this one is about the data path.)
What actually crosses the wire
Start with the question that matters most: when your agent pushes, what gets sent? Here’s the entire request type:
type PushRequest struct {
AuthorName string `json:"author_name"`
AuthorEmail string `json:"author_email"`
Message string `json:"message"`
Files []PushFile `json:"files"`
}
type PushFile struct {
Path string `json:"path"` // e.g. "wiki/concepts/billing-webhook.md"
Content string `json:"content"` // the distilled markdown page
ParentSHA string `json:"parent_sha,omitempty"`
}
That’s the whole thing. A push is a commit message and a list of pages — each a path and its markdown content. There is no transcript field. No buffer. No files_from_your_repo. The server can’t keep your raw session for the most basic reason there is: nothing in the payload can carry it.
What lands in Content is the page your agent wrote — prose about how your system works (“the billing webhook verifies the signature before trusting anything”), not the source that implements it and not the conversation that produced it.
The buffer never leaves the laptop
There is one artifact that holds raw conversation: the capture buffer. We built it in detail last time — a bounded JSONL summary of your session at .contexo/raw/sessions/_pending/<session-id>.jsonl. It’s the one place a key you pasted could physically appear.
Two mechanisms keep it put.
First, ctx init adds the whole .contexo/ directory to your project’s .gitignore, so it never rides along in your own commits:
const gitignoreHeader = "# Contexo local knowledge (synced via ctx push/pull, not git)\n"
Second, the buffer is consumed locally. When the distiller finishes — the handshake from the last post — the buffer is archived in place, not uploaded:
if buf, _ := capture.MostRecent(s.store.Root, 6*time.Hour); buf != nil {
_ = buf.Archive() // move _pending/<id>.jsonl → _pending/_archive/
}
Archive() moves the file from _pending/ into a local _archive/ folder. It’s housekeeping on your own disk. No code path sends it anywhere.
The one instruction that matters
So the only thing that can reach the server is whatever the agent typed into a page. Could a careless agent paste a key straight into the prose? In principle, yes — which is why the distill handshake spends one of its few lines on exactly that:
IMPORTANT: redact any API keys, tokens, passwords, or PII you encounter.
We won’t oversell this: it’s an instruction to the agent, not a regex firewall. Contexo isn’t a secret scanner. The hard guarantees are the structural ones above — the buffer stays local, and the push payload can only carry pages. This line is the belt to those suspenders: tell the smartest thing in the loop to keep secrets out of what it writes.
If you’d rather it capture nothing at all, one environment variable turns the whole thing off:
if os.Getenv("CONTEXO_CAPTURE_DISABLE") == "1" {
return nil
}
Your tokens, hashed
That covers your project’s secrets. What about the credentials Contexo issues you — your access token, your team’s invite keys? Those never sit in plaintext on the server. When you mint a personal access token, the raw value is shown once, and only its hash is stored:
func hashToken(raw string) string {
h := sha256.Sum256([]byte(raw))
return hex.EncodeToString(h[:])
}
The SQLite schema holds the hash, not the token:
CREATE TABLE personal_access_tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT UNIQUE NOT NULL, -- SHA-256, never the raw token
label TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
last_used_at INTEGER
);
Invite keys work the same way (key_hash). And the credentials cached on your own machine are written owner-only:
os.WriteFile(path, data, 0o600) // .contexo/credentials.json — owner read/write only
Or keep all of it in-house
Every guarantee so far holds on our hosted cloud. But the strongest version is the one where the question disappears: self-host. The core server and CLI are MIT-licensed. Pages live in an on-disk git repo; access metadata lives in SQLite; both sit on hardware you control. Point your team’s agents at your own box and no context — distilled or otherwise — ever crosses your network boundary.
Same CLI, same pages, same diffs. The only thing that changes is whose server it is.
The honest version
Put plainly:
- The push payload is a list of pages — it structurally cannot carry your transcript.
- The capture buffer is local, gitignored, and archived in place — never uploaded.
- The distiller is told to leave secrets out of what it writes.
- Your tokens are hashed at rest; local credentials are
0600. - And you can self-host, so none of it leaves your walls.
What we won’t claim is that Contexo scrubs secrets for you with some magic filter. It doesn’t. Treat context the way you treat code: keys live in your secret manager, not in the text you share with your team. Contexo’s job is to make sure the text you do share is the only thing that moves — and to give you the switches to control even that.
Read the real thing
None of this is a trust-me. The data path is a few hundred lines of Go you can read end to end: internal/sync/payloads.go for the wire format, internal/cli/init.go for the gitignore step, internal/capture for the buffer, and internal/userstore/pats.go for the token hashing.
ctx init
Point it at a project and watch exactly one thing leave your machine: a page you wrote. Free to start at contexo.live.
FAQ
If my agent has seen my API keys, does Contexo store them? +
No. The only artifact that holds raw conversation is a local buffer that's never uploaded — and the push payload is a list of distilled pages (path + content), with no field for a transcript. The distill handshake also instructs the agent to redact API keys, tokens, passwords, and PII from any page it writes. Self-host and nothing leaves your network at all.
What exactly gets sent to the server on a push? +
A PushRequest: author name and email, a commit message, and a Files array of {path, content, parent_sha} — one entry per markdown page. No buffer, no transcript, no source tree.
Where does the capture buffer live, and is it in my git history? +
Under .contexo/raw/sessions/. ctx init appends .contexo/ to your project's .gitignore, so it stays out of your repo. After a push the buffer is archived locally; it is never sent to the server.
How are my Contexo credentials stored? +
Personal access tokens and invite keys are stored server-side as SHA-256 hashes — the raw token is shown once and never persisted. Local CLI credentials are written owner-only (0600).
Can I keep everything in my own infrastructure? +
Yes. The core server and CLI are MIT-licensed and self-hostable — pages live in a git repo and access metadata in SQLite, both on your box. You can also set CONTEXO_CAPTURE_DISABLE=1 to capture nothing.