All guides
Running a relay
You have been working locally for a while. Now you want your board somewhere other than this laptop — as a backup, or so a colleague (or another of your machines) can see the same work.
That needs a relay: a small server that holds the op log and hands it to every replica that asks. There is no hosted one; you run it. The good news is that you already have it.
Read this first: the relay has no authentication yet. Anyone who can reach its address can read and write the whole stream. Run it on a private network, a VPN, or behind something that does the authenticating — not on a public address. This is a known gap, not a configuration you can fix from the outside. What it can no longer do is forge an instruction: every op is signed by the machine that wrote it, and an agent acts only on ops signed by a key you trust — see Whom a machine believes below.
You already have the binary
Every release tarball ships two programs: nxs (the CLI you already use) and nxf-relay. The
installer keeps the relay out of your PATH by default, because most people never run one:
NXF_INSTALL_RELAY=1 curl -fsSL https://nxsflow.com/nxs/install.sh | shIf nxs is already installed, re-running the installer that way adds the relay beside it.
The smallest thing that works
NXF_RELAY_ADDR=127.0.0.1:8787 NXF_RELAY_DB=/var/lib/nxf/relay.sqlite nxf-relayThat is the whole server. It stores everything in one SQLite file — which is also the whole backup story: stop it (or snapshot the volume), copy the file, done. For one person with a few machines, or a small team on one box, this is not a compromise; it is the right answer.
Point your workspaces at it and bind a stream:
nxs sync endpoint http://relay.internal:8787 # once per machine
nxs sync bind # once per workspace
nxs sync run # push + pullIn a git repo, bind needs no arguments: the stream id is derived from the origin remote, so
every clone lands on the same stream. See migration for the other binding modes.
A relay behind a gateway that wants a key takes it in the URL,
https://<user>:<password>@relay.example. nxs sends it as a Basic Authorization header and never
repeats it: a failed pass names the relay by host, port and path only — in the service's log, its
status and nxs sync machines alike — and nxs sync bind and nxs sync endpoint show the URL
masked. The files that store it (.nxs/sync.toml, ~/.nexusflow/config.toml) are readable by you
alone.
Which machines sync a workspace
Once more than one machine syncs a workspace, the relay can tell you which ones do, and which of them are online right now:
$ nxs sync machines
stream stream-01j8… on http://relay.internal:8787 — 2 machines, 1 online
online Mac mini last seen 42s ago (this machine)
offline MacBook last seen 3h 5m agoEach machine's background service tells the relay "I am here" after every pass that synced. A
manual nxs sync run does not: "online" promises that something on that machine is attending the
workspace, and only the service keeps that promise. So a machine appears in the list once its
service (nxs sync daemon install) has synced the workspace.
Online means the machine's last announcement is at most twice the cadence its service promised,
plus one minute. The cadence is the service's interval, but never less than its 5-minute retry
backoff — so it is 11 minutes for the default interval and for anything shorter. The second
cadence absorbs one failed pass, so a live machine does not flicker; the price is that a machine you
shut down still reads as online for up to 11 minutes. --json gives each machine's last_seen_at,
age_secs and the window it was judged by (online_within_secs).
A machine is one service home. On an ordinary computer that is the computer itself. A
development instance (~/.nexusflow-dev) is a machine of its own — it is a separate service — and
its default name says so: studio (dev).
The name defaults to the computer's host name, and nxs sync bind tells you which name that is
before the service first announces it. See and change it with:
nxs sync machine # this machine's name and id
nxs sync machine "Mac mini" # rename it; the id staysA name has to read as what it is: up to 64 characters, no invisible or direction-changing characters, no whitespace but spaces.
The relay has no authentication, and that shapes three things:
- Anyone who can read the stream can read the machine names, along with when each was last seen. If your host name carries your own name, rename the machine. Nothing else is sent: an id minted on the machine (a ULID — its first characters say when it was minted), the name, and the service's cadence. No paths, no user, no hardware id.
- A listing is a claim, not proof. Anyone who can reach the relay can announce a machine, or keep a stopped one looking online — for up to about two hours, the longest cadence a relay accepts. Treat the list as a guide to which machines exist and are awake, never as permission.
- It is bounded. A machine nobody has heard from for 30 days is forgotten, and reappears the
moment its service announces again. One answer lists at most 100 machines; if the relay holds more
(a flood of invented ones, typically),
nxs sync machinessays the list is not complete.
Presence is not part of the op log. The relay keeps one entry per machine per stream and
overwrites it with every announcement: SQLite and Postgres in a machine_presence table the relay
creates on boot, DynamoDB as a third kind of item (machine#<id>) in the registry table you already
have — no new table. On DynamoDB the relay's role now also needs dynamodb:Query on that table,
and you may enable TTL on its expires_at attribute to have forgotten machines removed (without it
they are only hidden). A relay built before this existed simply does not record presence:
nxs sync machines says so, and syncing carries on unaffected. A relay behind a gateway has to
route GET and POST on /streams/{id}/machines as well.
Whom a machine believes
Every op a workspace writes is signed by it, and every op it receives is checked. The check decides one thing only: whether an agent action may follow the op here. The board itself never asks — every op is kept and shown whoever signed it, so all machines still end up with the same board.
An action follows an op when this workspace wrote it, or when its signature checks out against a key on this workspace's trust list. Everything else — an op from a key you have not trusted, one whose signature does not check out, one with no signature at all — is shown and acts nowhere: a reply in it answers no round, names no session to wake, is collected as nobody's answer.
The key belongs to the workspace on that machine: its private half is .nxs/signing.key, readable
by you alone, created the first time the workspace is opened. To let your desk act on what your
laptop starts, in the same workspace on each:
nxs sync key # on the laptop: prints ed25519:…
nxs sync trust add ed25519:… --name laptop # on the deskCompare that string over a channel you trust — read it out, paste it into a chat you know is
yours — never through the relay: the relay is exactly the party the check exists to distrust.
nxs sync trust list helps you find the right key: it shows the keys you trust and the keys that
signed ops here without being trusted, with the names their ops claim. The names are claims; the
key is what you compare.
nxs sync trust list # whom this workspace believes
nxs sync verify <op-or-message-id> # who signed one op, and whether it may act
nxs sync trust remove ed25519:… # stop believing a key, at onceThe list is local: it is never synced, a snapshot does not carry it, and each machine decides for itself whom it believes. Removing a key takes effect immediately, for that key's past ops as well. There is no MCP tool for any of this, on purpose: an agent's instruction must not be able to widen whom your machine believes.
What you see when something is not trusted:
- A message in
nxc threads showreads(unvouched — no agent action follows it), and carries"unvouched": truein--json. - A thread whose opening or whose "who owes an answer, until when" came from an op you do not
trust is held: it says so in
nxc threads show, andnxc tickanswersheldinstead of acting. - A sync pass warns when signatures went missing on the way — ops this machine holds signed that
came back without their signature, or unsigned ops from machines that otherwise sign. A relay older
than nxs 0.58 drops them: upgrade it. The ops are kept either way; they just act nowhere. And
nxs sync trust listshows how many ops each trusted key has signed here: a machine you trust whose changes arrive while that count stays at zero is behind such a relay.
Workspaces written before signing existed keep working: what this machine wrote before is its own, and ops from machines that do not sign yet are shown and act nowhere.
Which machine runs a chat
A chat with a persona runs on exactly one machine; every other machine that syncs the workspace
shows it and starts nothing. Which one is decided when the chat starts — nxc send --to <persona> --machine <m>, else the persona's machine:, else the machine that starts it — and written into the
chat, so a chat started on your phone can run on the Mac mini, and a reply from anywhere reaches the
persona there. The details are in the chat guide (personas).
What makes it work across machines is what this page already sets up:
- The designated machine's service picks the chat up. It asks the relay every 15 seconds whether anything is new — one small request per workspace, a full pass only when the answer is yes — so a chat written elsewhere starts there within about half a minute.
- Only from a machine it trusts. The order and the designation both have to come from a key on its trust list (see above). An order from anywhere else is shown in the thread and starts nothing.
- Once. Each message is taken once per machine, even when that machine holds two clones of the same repository.
- "Online" decides whether you are asked. A chat for a machine that is not online is not sent:
you get the machines that are, and choose. The list is the one
nxs sync machinesshows — a guide, never a permission.
A new machine from a snapshot
A new machine folds the whole history the first time it syncs: every op the board ever had, one at a time. For a board a few thousand ops long that is a second or two; it grows with the board. Instead, a machine that already syncs the board can hand over a snapshot — its op log, the board folded from it, and the relay position the two reach — and the new machine starts from that and pulls only what came after:
nxs sync run # on a machine that syncs the board: push first
nxs sync snapshot board.snapshot # write the snapshot
nxs init # on the new machine, in the new clone
nxs sync bind --snapshot board.snapshot --endpoint http://relay.internal:8787bind --snapshot joins the stream the snapshot was taken from, takes the snapshot, and only then
registers the workspace with the background service — whose first pass pulls just the rest. The new
machine then shows exactly the board the source shows: the promise is that a snapshot plus the rest
folds to the same state as the whole history, late edits written into the past and ops the relay
stores twice included.
What it checks, and refuses by name:
- The source has pushed everything.
nxs sync snapshotrefuses while this machine holds ops the relay has not seen: a snapshot promises that everything in it is reachable from the relay. - The new workspace is empty. A snapshot starts a fresh replica; a workspace that already holds
ops binds without
--snapshotand syncs the ordinary way. A refused bind leaves it unbound and empty; one interrupted midway can leave the log loaded but unbound — bind it again without--snapshot, and it syncs from there. - The machine's id prefix is its own. If another replica on the stream already holds the prefix the new workspace was given, the relay hands it a fresh one before the import, so the ids that arrive stay as they are.
- The position is this relay's. Taking a snapshot asks the relay which op it holds at the position the machine has synced to, refuses if the machine does not have that op, and writes its id into the snapshot. Before anything is written, the new machine asks the relay for the op at the same position and requires the same one. A snapshot taken against another relay, or before a relay's log was rebuilt or reordered, fails that check — and the machine then pulls from the start instead, skipping every op it already has. Slower, never short. (It checks the numbering at one position, which a different log almost never matches by accident; it is not a proof that the whole history before it is the same list.)
- The version. A snapshot another nxs version wrote is folded again from the log it carries,
and
bindsays so; one from an nxs whose database this one cannot read is refused.--jsonreports which (snapshot.views:takenorrefolded, with the reason).
Where a snapshot lives is up to you — a file you copy, a bucket an app keeps. The relay does not hold snapshots: it cannot fold, and while it authenticates nobody, a snapshot left there would be a folded board anyone could plant and nobody could check against its log. A snapshot carries the op log and the board folded from it, so it reveals what the stream itself reveals to anyone who can read it — and none of what nxs keeps only for this machine (agent transcripts, sessions, leases). nxs writes it readable by you alone. And a snapshot's board is taken as given: it is as trustworthy as the machine it came from, so hand over only files from machines you control. Its log is checked like any op that arrives: every signature is verified again on the new machine, which starts with a trust list of its own — so a snapshot cannot make anything act there. One the import could not read back — a log with a value of the wrong type, a view cell that does not fit its column — is refused whole, before anything is written.
An app that embeds nxs — a hosted board that starts cold — does the same through
nxs_sync::snapshot::{export, import}: export from a replica that is in sync (the export confirms
the position with the relay), store the bytes where the app trusts them, and on the next cold start
import them into an empty store and resume syncing from the position import returns. A replica that
will write registers its id prefix first, as it would before any first sync.
Sync to a Postgres, for example Supabase
When you would rather have a managed database keep the data — someone else's backups, someone else's disks — point the relay at Postgres instead. Any Postgres works: Supabase, Neon, RDS, or your own.
export NXF_RELAY_BACKEND=postgres
export NXF_RELAY_PG_URL='postgres://user:pass@db.example.com:5432/nxf?sslmode=require'
nxf-relayThe relay creates its own tables on first boot, so there is no migration step to run.
A few things worth knowing:
sslmode=requireis what you want for anything reachable over the internet; every managed provider requires it. The default (prefer) negotiates TLS when the server offers it and falls back to plaintext when it does not, which is right for a database on your own private network and wrong for one on the public internet — so sayrequireand mean it. If you point the relay at a non-local host and leave it onprefer, it says so at startup rather than quietly sending your op log in the clear.- A private or self-signed CA is trusted by pointing
NXF_RELAY_PG_CA_FILEat its PEM file. Public certificate authorities are built in and need no setup — but do not assume a managed provider uses one. Supabase signs its Postgres and pooler certificates with its own root, so a relay pointed there fails the handshake withinvalid peer certificate: UnknownIssueruntil you download that certificate (Project Settings → Database → SSL Configuration) and setNXF_RELAY_PG_CA_FILE=/path/to/prod-ca-2021.crt. This is the one extra step Supabase needs, and it applies to any provider with its own CA. - The pool is
NXF_RELAY_PG_POOL_MAX_SIZE(default 16) andNXF_RELAY_PG_ACQUIRE_TIMEOUT_SECS(default 30).
Switching backends changes nothing a client can see: same HTTP surface, same convergence, same stream ids. You can move from SQLite to Postgres by standing up a second relay and re-binding.
DynamoDB: build it yourself
The relay also has a DynamoDB backend, for serverless deployments where there is no disk to own.
It is not in the released binary, on purpose — it drags a native cryptography toolchain
(cmake, a C compiler) into every build to add ~19 MB to every download, for something almost
nobody runs. It is a supported configuration, just a source-built one:
git clone https://github.com/nxsflow/nexus-flow.git && cd nexus-flow
git checkout v0.55.0 # pin an exact release
cargo build --release -p nxs-server --bin nxf-relay --features dynamodbYou need cmake and a C toolchain on the build host, and you must create the two tables
yourself — the relay never creates or migrates them. Their required shape is documented at the
top of crates/server/src/store_ddb.rs.
The relay's IAM role needs, on the op table, dynamodb:Query, dynamodb:PutItem,
dynamodb:UpdateItem and dynamodb:ConditionCheckItem, and on the registry table
dynamodb:GetItem, dynamodb:PutItem and dynamodb:Query. ConditionCheckItem is new in
v0.210.1: each write now also checks the op one position below it, so that a
reader is never handed a position before every lower one is written. Grant it before you deploy
that version — a relay without it refuses every push with AccessDeniedException (a 500 to the
client) while reads keep working.
A push may write for at most 20 seconds; past that the relay stops and answers 503, and the client
pushes again on its next pass. That keeps a push on a heavily contended stream inside the ~30 s
that API Gateway or CloudFront in front of a serverless relay allow. Behind a shorter timeout, set
NXF_RELAY_PUSH_DEADLINE_SECS below it.
If you point a released binary at NXF_RELAY_BACKEND=dynamodb, it will refuse to start and
tell you exactly this: the backend is a build-time feature, and this build does not carry it.
Removing a stream
nxf-relay purge removes everything the relay holds for one stream — its ops, its prefix claims
and the machines announced for it — and nothing else. It never guesses where the store is: every
variable is required (NXF_RELAY_BACKEND, and NXF_RELAY_DB plus NXF_RELAY_REGISTRY_DB on
SQLite, NXF_RELAY_PG_URL on Postgres). Three steps, each one JSON report on stdout:
nxf-relay purge <stream-id> --dry-run # what would go, and a plan digest
nxf-relay purge <stream-id> --yes --plan <plan_digest> # delete exactly that plan
nxf-relay purge <stream-id> --dry-run --expect-empty # confirm nothing is left (exit 0)If anything was written to the stream since the dry run, --yes deletes nothing and exits 4: take
a fresh dry run. Record the report's store.identity_digest once and pass it as
--expect-identity on every run — it refuses any other store, and the confirm after a store's last
stream needs it. On SQLite, stop the relay first when the dry run says "large": true, and run
VACUUM once on a file an older release wrote. The relay authenticates nobody, so stop it or block
the stream at your proxy while you purge. The full procedure, every exit code and the account-level
sequence are in docs/runbooks/relay-purge.md in the repository.
What is not here yet
- Authentication. See the warning at the top. Until it ships, treat relay reachability as the access control, because it is. Signatures stop a relay from forging an instruction; they do not stop anyone who can reach it from reading the stream, writing ops that act nowhere, or holding ops back.
- A container image.
docker run …/nxf-relayis the obvious way to run this, and it is specified — but publishing a one-command way to stand up an unauthenticated shared board is the wrong order to do things in. It follows authentication.