Self-hosted sync
Sync is the optional half of OHarness: it stores conversations so a session started on one machine can be resumed on another. The client reserves https://sync.oharness.ai as its official default, but that service is a convenience rather than a dependency — the server is the @oharness/sync npm package, published under the MIT licence, and pointing a harness at your own copy is two lines of configuration.
Nothing here is required. A signed-out harness with no self-hosted token never opens a sync connection, and the local session store is complete on its own. Signing in enables sync by default and uses the official destination unless the user overrides it or turns sync off.
What the server is, and what it is not
It stores opaque blobs and hands them back in order. It never runs a model, never executes anything, and never looks inside what it stores — body on the wire is untouched by the server (packages/engine/src/sync/protocol.ts).
That is the property to protect rather than a description of the current implementation. It is what makes the server safe to leave running, what lets the message format change without a redeploy, and what would make an end-to-end encrypted mode a client-side change alone. A feature that needs this service to parse a transcript is a feature that belongs somewhere else.
Two things it is deliberately not:
It is not an account system. There is no registration endpoint, because a sync server that can create accounts is a sync server with a registration endpoint to defend. Tokens are issued out of band — see below.
It is not the agent server. That one holds a shell and is single-tenant by construction. See railway.agent.toml.
Quick start
npx @oharness/sync token alice # printed once, stored as a hash
npx @oharness/sync --port 4483 # serve
Then, in ~/.oharness/config.json:
{
"sync": {
"url": "https://sync.example.com",
"token": "<the token printed above>"
}
}
When user config has no sync.url, OHARNESS_SYNC_URL moves the destination without editing a file. User config deliberately wins when both are present. OHARNESS_SYNC=0 turns sync off for one run or for all of CI. Restart OHarness after changing the destination.
The URL is validated on the client: HTTPS is required, with plain HTTP allowed only for loopback development. Embedded credentials, a query or a fragment are refused rather than stripped.
The wire
Session sync has four endpoints. Memory adds three optional endpoints. Everything but /v1/health requires Authorization: Bearer.
| Route | Purpose |
|---|---|
GET /v1/health | Liveness. Unauthenticated on purpose — a health check that needs a credential is a health check nobody wires up |
GET /v1/projects | Project ids anything has been pushed to, under one owner |
POST /v1/push | Append records and session metadata; returns the new cursor and how many were new |
GET /v1/pull | Read forward from a cursor; wait=<seconds> long-polls |
POST /v1/memory/identity | Optional Memory identity and quota check |
POST /v1/memory/snapshot | Optional Memory manifest and changed note bodies |
POST /v1/memory/commit | Optional Memory compare-and-swap update |
A feed is append-only and a cursor is an opaque, monotonic server-issued integer. The bundled JSONL store uses an event number and separately maintains byte-offset checkpoints; another implementation can use a database sequence. Pushes dedupe on (session, id), so a retry after a dropped response is free rather than a duplicated turn. Sessions resolve last-writer-wins by generation, and a deletion travels as a tombstone — without one, the other device restores what you deleted.
The complete interoperability contract, including JSON request and response shapes, cursor semantics, errors, Memory hashing and an implementation checklist, is Sync protocol. Read that page if you are building a compatible service instead of deploying @oharness/sync.
The limits are in packages/engine/src/sync/protocol.ts and are shared by both halves, because a client and a server that each own their idea of the wire drift, and the drift shows up as records that silently fail to merge:
MAX_PUSH_RECORDS | 500 entries per push |
MAX_BODY_BYTES | 8 MiB per request |
DEFAULT_PULL_LIMIT | 500 entries per pull |
MAX_WAIT_SECONDS | 30s, below any sensible proxy idle timeout |
An over-sized push is refused with 413 rather than truncated: accepting half a batch and reporting success would lose turns the client then considers delivered.
Feeds are written to <root>/feed/<kind>-<owner>/<project>.jsonl, where --root defaults to ~/.oharness/sync. On a platform that gives containers an ephemeral filesystem, put that on a mounted volume. Losing it does not merely clear a cache — every client is left holding a cursor into a feed that no longer exists.
Two ways to hold a token
Locally issued. oharness-sync token <user> writes a users.json beside the feeds holding the user id and a SHA-256 of the token; the token itself is never stored and is printed once. One entry is the single-user case and costs nothing. This needs no other service and is the mode to use unless you have an account system already.
Signed by something else. Set OHARNESS_SYNC_JWT_SECRET (HS256) or OHARNESS_SYNC_JWKS_URL (asymmetric) and the server also accepts tokens your auth system signed, verified by signature rather than by asking that system — so nothing on the request path depends on another service being up. sub becomes the owner id. OHARNESS_SYNC_JWT_ISSUER and OHARNESS_SYNC_JWT_AUDIENCE are enforced when set.
The two forms are told apart by shape: a locally issued token is 32 random bytes in base64url and can never contain a dot. Enabling JWT does not disable users.json; both keep working.
Teams
A team is a shared feed. Everyone issued a token carrying the same team id reads and writes one history per project, which is what lets two people continue each other's sessions.
npx @oharness/sync token bob --team platform
With signed tokens the ids come from a teams claim, renameable with OHARNESS_SYNC_TEAMS_CLAIM. Membership is recorded against the token rather than asked for per request — the alternative puts someone else's availability in front of every write, on the endpoint that exists to make transcripts durable.
Naming a team the token does not hold is refused with 403 rather than quietly answered with the caller's own feed. That substitution reads to the user as "the team has nothing in it", which is the one wrong answer they would act on.
Memory needs more than the feed does
/v1/memory/* is served by the same process but is not the feed protocol: a manifest updated by compare-and-swap plus immutable Markdown objects in a private bucket, with three actions — identity, snapshot and commit.
It has requirements the feed protocol does not, and they are worth knowing before you plan around them:
OHARNESS_SYNC_AUTH_URL Supabase project origin, bare — no path or query
OHARNESS_SYNC_AUTH_KEY publishable/anon key, never a service role
R2_ACCOUNT_ID Cloudflare account id, 32 hex characters
R2_MEMORY_BUCKET private bucket, public access disabled
R2_ACCESS_KEY_ID bucket-scoped Object Read & Write credential
R2_SECRET_ACCESS_KEY matching secret
Auth is Supabase-shaped, not generic. The feed protocol accepts any JWKS you point it at; Memory validates a token by calling /auth/v1/user on the configured origin, which is Supabase's API and not an OIDC standard. If you are self-hosting against some other identity provider, session feeds will work and Memory will not.
The distinction also applies to locally issued credentials: oharness-sync token <user> is enough for session feeds, but the current Memory client sends its Cloud account token rather than sync.token or OHARNESS_SYNC_TOKEN. A self-hosted Memory deployment therefore needs a Cloud login and an identity verifier that resolve that token to the same principal.
Storage is S3-compatible, addressed as R2. Keys are users/<verified-user-id>/projects/<project-id>/memory/ for project notes and users/<verified-user-id>/memory/ for a user's own notes, with the user id taken from the verified token rather than from anything the client sends.
Current adapter limitation. The protocol and Memory API support both prefixes, but the bundled createR2MemoryStore key allowlist in this source revision still accepts only the project prefix. Extend that allowlist and add a real-adapter test before enabling user-scope Memory on R2; an independent implementation should support both prefixes described by the protocol.
Missing configuration is quiet. The storage client is built on the first Memory request rather than at startup, so a deploy that lost one variable looks entirely healthy — the port opens, /v1/health answers, feeds sync, and Memory returns 503 in the user's client, which is the one place nobody deploying this is watching. The server prints one line on startup naming whichever variables are absent:
memory off (no Memory environment) # nothing set: the ordinary case
! memory off — missing R2_SECRET_ACCESS_KEY # some set: a mistake worth finding
memory my-bucket · auth project.supabase.co · 5000 notes · 90d tombstones # configured
Setting none of the six is the ordinary self-hosted case and says so quietly. Setting some of them is the case worth a mark in the margin. Check the deploy log; memory off there is the whole warning.
Quotas are yours to set. Every one is optional. Responses announce the four client-actionable limits; request and response byte limits remain server-side safety bounds:
OHARNESS_SYNC_MEMORY_MAX_NOTES live notes per project and per user (5000)
OHARNESS_SYNC_MEMORY_MAX_CHANGES changes per commit (500)
OHARNESS_SYNC_MEMORY_MAX_NOTE_BYTES bytes per note body (131072)
OHARNESS_SYNC_MEMORY_MAX_RESPONSE_BYTES note bytes per response (16777216)
OHARNESS_SYNC_MEMORY_MAX_REQUEST_BYTES request body bytes (4194304)
OHARNESS_SYNC_MEMORY_TOMBSTONE_DAYS days a deletion is kept (90)
A quota blocks growth, never deletion, so lowering one cannot trap a user above it. A rate-limited request gets 429 with Retry-After, the seconds left in the account's one-minute window.
Plans narrow the quota. Free accounts get the limits in packages/sync/src/memory-entitlements.json, read from the ent claim that the official Cloud's access-token hook writes. If your Auth writes no such claim, every account looks free; set this to give every account paid limits instead:
OHARNESS_SYNC_MEMORY_ENTITLEMENTS=off
A value that is not a positive integer is ignored and named on the startup line. Deploy a new server before new clients: older servers reject the fields newer clients send for user-level and typed notes.
The client-side behaviour of Memory — local-first notes, the per-directory sync switch, three-way reconciliation and conflict handling — is its own page.
What a project cannot decide
sync.url, sync.token and sync.team are discarded from project configuration on merge, and that is not an oversight: naming the sync server inside a repository means a repository can send your transcripts somewhere else. They are honoured only from user config (~/.oharness/config.json) and the environment.
The practical consequence when self-hosting for a team: you cannot ship your server's address in a checked-in oharness.config.json and have everyone pick it up. Each person sets it once in their user config, or you distribute OHARNESS_SYNC_URL through whatever already configures their shell.
A project can still turn sync off, add to exclude, and add to deny — tightening is allowed, loosening is not. The logic is projectSafe() in packages/engine/src/config.ts.
Operational notes
Terminate TLS in front of it. The server speaks plain HTTP and authenticates with a bearer token. A token sent in the clear on a network you do not control is a token everyone on that path now holds. Bind wider than loopback (--host 0.0.0.0) only behind a proxy you own.
A tenant boundary here is a key prefix, not a container. Multi-tenancy is one process isolating users by feed directory. That is sound for a team that already trusts each other and is not the model to reach for if your tenants do not.
Restarts are cheap. The server holds nothing in memory a client cannot re-derive from its cursor, and a dropped long poll is retried. Restart freely; just do not lose --root.
It reads no configuration file. Whatever deploys it — a container's environment, a systemd unit, a platform's variable store — is the only source of configuration, because a sync server that reads a file from the working directory is a sync server whose destination a repository can change.
railway.sync.toml at the repository root is a worked example of all of the above as one deployment.