
How I built a Supabase-backed sync layer for my AI coding agent's memory, skills, and configs — the schema, the auth trick, optimistic concurrency, tombstoned deletes, and the real bugs a second device exposed.
I work off two machines — a Linux desktop and a Mac mini — and I use Claude Code as my day-to-day coding agent on both. The problem is that everything an agent like this learns over time — memory files, custom skills, hooks, configuration — lives in a local config directory on whichever machine you happened to be sitting at when it learned it. Switch machines, and the agent forgets everything. It has no idea it's the same "you."
I spent a day building a proper fix: a Supabase-backed sync layer that keeps memory, skills, and configs consistent across every device, in near real time, with conflict handling instead of silent overwrites. This is the write-up — architecture, the decisions that mattered, and the bugs that only showed up once a second real machine was in the loop.
The problem, precisely
An AI coding agent's local state isn't one file — it's a scattered tree: free-form memory notes it writes about your projects, custom skills you've installed, hook scripts, credentials-adjacent config, session state. Some of it is safe to sync everywhere. Some of it (logs, caches, plugin installs, session transcripts) is either too large, too disposable, or actively shouldn't leave the machine.
So the goal wasn't "sync a folder." It was: sync the right subset, incrementally, with real conflict detection, in a way that survives two machines with genuinely different filesystem layouts.
I ended up syncing seven categories of state — the memory notes the agent writes per project, my agent brains' own memory, those brains' configuration, globally installed skills, a canonical skills store, global config, and a folder of personal system docs — 502 files in total. Deliberately excluded: installed plugins (over a gigabyte, and trivially reinstallable), session transcripts (multiple gigabytes of conversation history), logs, caches, and anything that looked like a credentials file. A secret scan runs before every push and aborts on any hit, so even an accidental .env sitting in a synced directory can't leave the machine.
Why not just Git, Dropbox, or iCloud
The honest first question was whether I needed a database at all. A git repo of dotfiles would get me versioning almost for free. But it fails on the two things that actually mattered here: structured conflict resolution (git merge conflicts on machine-generated memory files are useless — there's no human paged in to resolve them) and partial, field-level sync (I needed to track a version number and an executable bit per file, not just blob content). Dropbox/iCloud-style file sync gives you neither — it's last-write-wins, full stop, and that is exactly the failure mode that quietly corrupts memory: two machines both learn something in the same session window, and one throws the other's knowledge away without telling anyone.
A small Postgres schema behind a purpose-built API gave me both for a modest amount of extra setup.
Architecture at a glance
The shape of the system:
- A private Postgres schema, not exposed through PostgREST directly. Four thin
public-schema wrapper functions are the entire external API surface — push a file, pull changes since a cursor, and two small helpers. Hit the underlying tables directly with the anon key and you get a 404; call an RPC without a valid session and you get a 401. - A lightweight CLI client that scans the local filesystem, diffs against last-known state, and talks to those four functions over plain PostgREST — no bulk SQL, just batched HTTP.
- Row-level security keyed to the authenticated user, verified by literally creating a second auth user and confirming zero rows are visible to it anywhere.
- A session hook that makes the whole thing invisible: pull on session start, push on session stop.
create schema claude_sync;
create sequence claude_sync.change_seq;
create table claude_sync.files (
id uuid primary key default gen_random_uuid(),
owner_id uuid not null references auth.users(id),
bucket text not null,
rel_path text not null,
content_b64 text,
content_sha256 text not null,
symlink_target text,
version int not null default 1,
exec_bit boolean not null default false,
deleted boolean not null default false,
seq bigint not null default nextval('claude_sync.change_seq'),
updated_at timestamptz not null default now()
);
create table claude_sync.file_versions (
file_id uuid references claude_sync.files(id),
version int not null,
content_b64 text,
created_at timestamptz not null default now()
);
-- the only publicly exposed entry points (plus two small helpers):
-- claude_sync_push_file(...), claude_sync_pull_changes(...)Authentication without a real identity
Every device needs to authenticate as something. The tempting shortcut is tying sync to a personal account — but that couples a general-purpose infrastructure tool to a specific login, which is exactly the kind of thing that becomes a headache later (shared devices, revocation, "wait, which account is this synced under?").
So the sync identity is a single, disposable, non-personal auth user, deliberately unrelated to any real account. Creating it cleanly was more fiddly than expected: Supabase's signup API rejects obviously-fake domains outright, and the "clever" workaround of using an unregistered-but-real-looking domain backfired — it actually tried to send a confirmation email to a domain I don't own, and got throttled for it.
The fix was to skip the signup flow entirely: generate a password locally, hash it with htpasswd's bcrypt mode, normalize the hash prefix (Go's bcrypt implementation only accepts 2a/2b, and some tools emit 2y), and insert directly into auth.users with email_confirmed_at already set. No email ever gets dispatched; only the hash is ever visible.
That path has its own trap: hand-inserting into auth.users breaks sign-in with an opaque 500 Database error querying schema unless every one of the token columns (confirmation_token, recovery_token, the email_change*/phone_change* family) is set to an empty string rather than left NULL. The auth server scans them straight into non-nullable Go strings, and a NULL blows that up before you ever see a useful error.
Each device then holds its own rotating refresh token on disk (locked-down permissions, no plaintext password after the initial login), which means a compromised or retired device can be revoked individually without touching the others.
Optimistic concurrency, not last-write-wins
This was the non-negotiable design choice. Every push carries the base_version the client last saw for that file. If the server's current version has moved on and the content actually differs, the push is rejected outright with a conflict response — it never silently picks a winner. The client backs up its local copy, tells you to pull first, and lets you decide. I verified this by intentionally racing two edits against the same file; the loser gets stopped, not overwritten.
Reads work off a single global, ever-increasing sequence number. Each device just remembers the last sequence it has seen and asks for everything after it — a trivial, cheap "what's new" query that scales fine regardless of total history size.
Deletes are tombstones, never hard deletes. A removed file gets deleted = true and its row stays. That's what makes deletion propagate — a hard delete has nothing left to sync — and it means the full version history for a file survives even after it's gone.
Inside the sync function: scan, push, pull
The CLI itself is a single script with a handful of subcommands — login, status, push, pull, sync (push then pull), bootstrap (first run on a brand-new machine), and export/import (a full dump-and-restore pair for migrating between projects). Everything else described so far — the schema, the conflict handling, the tombstones — exists to serve three functions: scan, push, and pull. The interesting decisions live at this level, not in the schema, so it's worth walking through what they actually do.
Scan: turning a filesystem into a diffable list
Every push and every status check starts the same way — walk each synced bucket, and for each file compute a content hash, a size, and whether the executable bit is set:
const add = (bucket, rel, file) => {
if (file.link !== null) {
rows.push({
bucket,
rel_path: rel,
content: null,
symlink_target: file.link,
content_sha256: sha256("link:" + file.link),
})
return
}
const st = statSync(file.path)
if (st.size > MAX_BYTES) return // skip anything over 1MB
const content = readFileSync(file.path, "utf8")
if (content.includes("\0")) return // binary — a NUL byte can't appear in real text
rows.push({
bucket,
rel_path: rel,
content,
content_sha256: sha256(content),
exec_bit: (st.mode & 0o111) !== 0,
frontmatter: rel.endsWith(".md") ? parseFrontmatter(content) : null,
})
}Two choices worth calling out. Binary detection isn't an extension whitelist — it's just "does this file contain a NUL byte." Real text never does; almost everything else does, so a two-line check quietly handles images and compiled binaries without a list to keep up to date. And for markdown memory files specifically, scan parses just the YAML frontmatter — name, description, type — into its own column server-side, which is what makes it possible to search across every memory ever written, from any device, without pulling full file contents first.
The gate before anything leaves the machine
Before a single row is sent anywhere, every file's content is tested against a short list of recognizable secret formats:
const SECRETS = [
[/\bsk-ant-[A-Za-z0-9_-]{20,}/, "anthropic-key"],
[/\bghp_[A-Za-z0-9]{36}/, "github-pat"],
[/\bsbp_[a-f0-9]{40}/, "supabase-pat"],
[/\bAKIA[0-9A-Z]{16}\b/, "aws-key"],
[/-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----/, "private-key"],
]A single match anywhere in the changeset aborts the entire push, not just that one file, and prints exactly which bucket and path tripped it. It's a circuit breaker, not a linter — better to fail loudly and force a fix at the source (usually: that file shouldn't be in a synced directory at all) than push 501 clean files and quietly swallow the 502nd.
Push: diff, batch, and let the server say no
push calls scan, then diffs the result in memory against a small local state file that remembers the last known { version, sha, exec } for every file this device has ever synced:
for (const r of rows) {
const known = state.files[key(r.bucket, r.rel_path)]
if (known && known.sha === r.content_sha256 && known.exec === r.exec_bit)
continue // unchanged
changed.push({ ...r, base_version: known?.version ?? null })
}
// anything the state file remembers that scan() no longer sees locally = a delete
for (const k of Object.keys(state.files)) {
if (!seen.has(k))
deletes.push({ ...tombstoneFor(k), base_version: state.files[k].version })
}A local cache plus a server-side cursor means running push on an unmodified machine costs one round trip that returns nothing — not a full re-upload. Real changes and deletes get batched into requests capped around 400KB and applied one row at a time, server-side, by push_file:
if not found then
insert into claude_sync.files (...) returning * into cur;
return jsonb_build_object('status', 'created', 'version', cur.version);
end if;
if cur.content_sha256 = p_sha256 and cur.deleted = p_deleted then
return jsonb_build_object('status', 'unchanged', 'version', cur.version);
end if;
if p_base_version is not null and p_base_version <> cur.version then
return jsonb_build_object('status', 'conflict', 'server_version', cur.version, 'client_base', p_base_version);
end if;
update claude_sync.files
set content_b64 = p_content_b64, version = cur.version + 1,
seq = nextval('claude_sync.change_seq')
where id = cur.id
returning * into cur;
return jsonb_build_object('status', 'updated', 'version', cur.version);Four possible outcomes per file, and the client only updates its local cache for the three that aren't conflict. A conflict deliberately leaves the cache untouched — the next push offers the same base_version again until you actually pull and resolve it. What that looks like from the terminal:
1 CONFLICT(S) — remote changed since this device last synced:
global-config/hooks/pre-commit.sh (server v4)
run: claude-sync pull then re-push (remote wins on pull; your local copy is backed up)Pull: apply changes without trusting the filesystem
pull asks for everything with seq > cursor, then walks the results applying each one locally — and this is where most of the edge-case handling described earlier actually lives. Before writing anything, it precomputes every local path that a live (non-deleted) row in this batch will claim, specifically so a tombstone for an old, since-superseded row can never delete a file a live row in the same batch just wrote:
const claimed = new Set(
active.filter((r) => !r.deleted).map((r) => localPath(r.bucket, r.rel_path))
)
for (const r of active) {
const abs = localPath(r.bucket, r.rel_path)
if (r.deleted) {
if (!claimed.has(abs) && existsSync(abs)) {
rmSync(abs)
pruneEmptyDirs(abs, bucketRoot(r.bucket))
}
continue
}
// edited locally since last sync AND changed remotely — don't guess, surface it
const known = state.files[key(r.bucket, r.rel_path)]
if (
known &&
localSha(abs) !== known.sha &&
localSha(abs) !== r.content_sha256
) {
localEdits.push(`${r.bucket}/${r.rel_path}`)
continue
}
if (isDirectory(abs)) backupThenRemove(abs) // a dir here, a file/symlink at the source
if (r.symlink_target) symlinkSync(r.symlink_target, abs)
else {
writeFileSync(abs, r.content ?? "")
chmodSync(abs, r.exec_bit ? 0o755 : 0o644)
}
state.files[key(r.bucket, r.rel_path)] = {
version: r.version,
sha: r.content_sha256,
exec: r.exec_bit,
}
state.lastSeq = Math.max(state.lastSeq, r.seq)
}Every branch there exists because of a real failure mode: the claimed-path check stops a stale tombstone from erasing a live file, the local-edit check stops a pull from silently overwriting work that hasn't been pushed yet, the directory check stops a crash when the same path is a symlink on one machine and a real directory on another, and the explicit chmodSync is the fix for hook scripts that used to arrive non-executable. None of it is exotic — all of it was necessary.
The server side of pull, by comparison, is close to the simplest function in the whole system:
select id, bucket, rel_path, version, exec_bit, deleted, seq,
symlink_target, content_sha256,
convert_from(decode(content_b64, 'base64'), 'UTF8') as content
from claude_sync.files
where owner_id = auth.uid() and seq > p_since
and (p_buckets is null or bucket = any(p_buckets))
order by seq;The real work is entirely in what the client does with the rows it gets back. And every insert or update on files fires a trigger that appends an immutable copy into file_versions before the row is ever returned to anyone — so even a bad push is recoverable by walking history, not by hoping the client cached the right thing.
The hardest bug: path portability
This is the one that actually took the most thought, and it's specific to how coding agents like this one organize per-project memory: each project's memory directory is named after the absolute path to that project, with every / replaced by -.
That scheme is lossy. A directory named -home-me-dev-self-ranjan-portfolio could just as easily be /home/me/dev/self/ranjan-portfolio as /home/me/dev/self-ranjan/portfolio — a real hyphen in a folder name is indistinguishable from the separator once it's been flattened. Trying to reconstruct the original path by splitting on - is a guessing game you will eventually lose.
The fix was to never invert the slug at all. Instead, build the map forward on each machine independently: walk the local project root, slugify every real directory exactly the way the agent does, and index slug → true relative path. The true path is what gets stored and synced — not the slug — so each device just re-derives its own slug from the true path using its own root, and the two never have to agree on directory layout.
const map = new Map()
walk(projectRoot, (absPath) => {
const slug = absPath.replaceAll("/", "-") // exactly how the agent names it
map.set(slug, path.relative(projectRoot, absPath))
})One more edge case: a project that existed on the device where a memory was written might not exist locally yet. Rather than drop those memories, they fall back to a raw _slug:<original> key so they still round-trip — this alone rescued a handful of files that belonged to a repo that had since been removed from disk.
What a firewall taught me about my own traffic
Midway through testing, a push started dying partway through, returning an HTML error page where JSON should have been. The cause: a WAF sitting in front of the database's REST layer was reading synced shell scripts and SQL snippets in the request body and flagging them as an injection attempt — not unreasonably, since raw source code and injection payloads look similar to a pattern matcher.
The fix wasn't a security workaround, just an encoding change: send file content as base64 (content_b64) and decode it server-side. That makes the request body opaque to a WAF doing text pattern matching, without changing anything about who can read the data — reads were never affected, only the shape of push bodies.
Testing with one device lies to you
Everything above worked cleanly with a single machine talking to itself. The moment a second, genuinely different machine joined — different home directory layout, different OS — it surfaced a run of real bugs that one-machine testing had no way to catch, because a single client only ever validates against its own assumptions.
A few worth calling out:
A naive path split broke on nested project keys. The pull logic split each stored path on the first / to separate "project key" from "file within it." That's correct for a flat key, but wrong the moment a project key itself contains a slash (a project nested a few directories deep). Files landed in the wrong location entirely — and worse, it looked completely fine, because the tool re-read its own wrong output and reported zero drift. The fix was an explicit // delimiter between the project key and the filename, which no legitimate path can ever contain naturally; old rows written before the fix still needed the legacy split path preserved so their history and tombstones kept resolving correctly.
A stale tombstone could erase a live file. When a memory got re-organized under a new key, the old row's eventual tombstone and the new row's live content could resolve to the same local path. Applied strictly in sequence order, the higher-numbered tombstone landed after the live write and deleted a file that had just been correctly created. The fix: precompute every path claimed by a currently-live row first, and let a live claim always beat a tombstone for the same path, regardless of ordering.
The same path was a symlink on one machine and a real directory on the other. One synced skill had been hand-copied as a directory on one machine, while the setup script on the other had created it as a symlink. A plain file read on a symlink-to-a-directory throws EISDIR, which the pull path hadn't accounted for. Fixed by checking the link status explicitly before touching it, copying the whole tree aside as a backup, and only then replacing it.
File permissions weren't part of the sync payload at all. Push captured content but not the executable bit, so a pulled hook script arrived on the second machine as a plain, non-executable file — and the hook that was supposed to run sync silently did nothing, forever, with no error. Fixed by threading an exec_bit column through push and pull, and treating a permissions-only change as a real change worth syncing even when the bytes are identical.
Sync direction mattered more than expected. The original flow was pull-then-push, which is backwards: it meant a fresh pull could silently overwrite an edit you'd just made locally before that edit ever got a chance to push. It now pushes first, then pulls — and pull refuses to blindly take the remote version of a file that's been edited locally and changed remotely since the last sync; it flags that case explicitly instead of guessing.
One platform's assumptions don't hold on the other. The locking primitive the hook relied on to prevent two sessions racing each other doesn't exist on macOS at all. The guard was written as "take the lock, or exit quietly" — the intended behavior when another session already holds it. With the lock command missing, that first half failed on every single invocation, the "exit quietly" fired, and the hook returned success without doing anything, forever. A guard designed to fail silently will fail silently for reasons you never intended. Replaced with an atomic directory-creation lock plus a staleness timeout, which works identically on both platforms. A related date-formatting flag used for log timestamps also isn't supported by the BSD version of date bundled with macOS — different flag, same underlying idea, needed per-platform handling.
Deleting a file left an empty shell behind. Pull removed files correctly but never cleaned up directories that were now empty, so a deleted skill left a hollow directory tree lying around on other devices — invisible to the agent, but very visible to anyone poking at the filesystem. Pull now walks upward from every removed file, pruning empty directories until it hits the top of that sync category.
Every single one of these was invisible with one machine and obvious within minutes of a second, real machine joining. If you're building anything that claims to sync across environments, the lesson is blunt: one environment cannot validate a sync system, full stop — you need at least two, and they need to actually differ.
The hook: invisible by design
The entire point of building this was to never think about it. The integration point is two lifecycle hooks: pull when a session starts, push when it stops.
{
"hooks": {
"SessionStart": [{ "hooks": [{ "command": "claude-sync-hook.sh pull" }] }],
"Stop": [{ "hooks": [{ "command": "claude-sync-hook.sh push" }] }]
}
}A few properties made this actually disappear rather than becoming background friction:
- It's fully detached and adds roughly 2ms of session startup latency — the sync itself runs out-of-band.
- It's lock-guarded, so two sessions starting at once can't race each other into a corrupted push.
- When nothing local has changed, it exits before making any network call at all — a no-op costs nothing.
- Its own log file self-truncates instead of growing forever.
Bootstrapping a machine that has nothing
There's a chicken-and-egg at the bottom of all this: the sync client can't sync itself. It lives outside every bucket it manages, so a brand-new machine needs the script and its config copied across by hand — scp, USB, whatever's easiest — before it can pull a single byte. Two files deliberately don't come along: the session token and the local state cache. Both are per-device, and copying them makes two machines claim one identity and share one change cursor, which means each quietly skips the other's updates.
Only one thing in that config is machine-specific: the roots. The server stores every path relative to them, which is the whole reason the same memory lands in the right place on a filesystem laid out differently — one machine keeps its repos in ~/development, the other in ~/dev, and neither has to know that about the other.
After that, bootstrap is just pull with the cursor reset to zero and a backup directory forced on:
state.lastSeq = 0
saveState(state)
await pull({ since: 0, backupDir })The forced backup is the part that matters. A machine that's "new" to sync usually isn't empty — mine had months of hand-copied skills sitting at exactly the paths the pull was about to write to. Every local file that differs from the remote gets copied into a timestamped directory before it's replaced, and that path gets printed at the end. Nothing is destroyed silently, even on a first run that touches 502 files.
The honest cost of living outside the buckets is that the client can't update itself either. Change it on one machine and the others keep running the old version until you copy it over — which is exactly the kind of silent drift the rest of this system exists to prevent. I've been bitten by it once already.
Migrating without losing a byte
The first version of this lived in a throwaway project — good enough to prove the design, not something to build a habit on top of. Moving to a real, permanent project meant a full export from the old database and import into the new one: 502 live rows re-created, then a verification pass confirming zero drift between source and destination before the old project was decommissioned. Cutover, not migration-in-place — safer to prove correctness on a copy first than to mutate the thing you're relying on.
What's still open
Two honest loose ends: a security advisory flagging that leaked-password protection isn't enabled at the project level yet (a dashboard toggle, not a code fix), and no retry handling for network failures — a dropped Wi-Fi connection mid-push is a "when," not an "if," and the client still treats every sync as though it will succeed.
The actual takeaway
The interesting problems here weren't the ones I expected going in. I expected the hard part to be "design a sync protocol." It wasn't — optimistic concurrency and a sequence cursor are well-worn patterns. The hard part was everywhere the real world disagreed with my assumptions: a WAF that reads code as an attack, an auth table that silently rejects NULL in the wrong column, a path-encoding scheme that's lossy in a way you won't notice until two directories collide, a locking primitive that doesn't exist on half your fleet.
Single-machine testing will tell you your protocol is correct. It will tell you nothing about whether your system survives contact with an environment you didn't build it in. That gap is where almost every bug above was hiding, and it's the reason the second device — annoying as it was to debug against — was worth setting up before calling this done.