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 official deployment at https://sync.oharness.ai is a convenience, not a dependency — the server is packages/sync in this repository, under the same MIT licence as the rest, and pointing a harness at your own copy is two lines of configuration.

Nothing here is required. A harness with no destination configured never opens a socket, and the local session store is complete on its own.

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>"
  }
}

OHARNESS_SYNC_URL moves the destination for one run without editing a file, and OHARNESS_SYNC=0 turns sync off for one run or for all of CI.

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

Five endpoints. Everything but /v1/health requires Authorization: Bearer.

RoutePurpose
GET /v1/healthLiveness. Unauthenticated on purpose — a health check that needs a credential is a health check nobody wires up
GET /v1/projectsProject ids anything has been pushed to, under one owner
POST /v1/pushAppend records and session metadata; returns the new cursor and how many were new
GET /v1/pullRead forward from a cursor; wait=<seconds> long-polls
POST /v1/memory/*A different protocol with different requirements — see below

A feed is append-only and a cursor is a byte offset into it. 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 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_RECORDS500 entries per push
MAX_BODY_BYTES8 MiB per request
DEFAULT_PULL_LIMIT500 entries per pull
MAX_WAIT_SECONDS30s, 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.

Storage is S3-compatible, addressed as R2. Keys are users/<verified-user-id>/projects/<project-id>/memory/, with the user id taken from the verified token rather than from anything the client sends.

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  # 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.

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.