nxsflow
chat (nxc)

Core Concepts

Everything nxc does rests on six ideas: one shared log, declared participants, qualified handles, threads with a derived quorum, an explicit read cursor, and sessions with transcripts. This guide is the model underneath the commands — learn it once and the verbs stop needing to be memorised.

The substrate: one log, three products

Chat owns no database. It opens the same .nxs/db.sqlite that flow and memory write to, registers its own reducer, and folds its own views. Every chat operation carries the domain message, and a reducer that does not own a domain leaves an operation stored but unfolded — which is why three products can share one log without contaminating each other.

Within that domain there are six kinds of operation, each with the conflict behaviour its job actually needs:

KindBehaviour
messagegrow-only. One operation is one message: immutable, never edited.
channellast-write-wins per field (name, kind, origin).
membershipan observed-remove set — an add carries its own tag, a remove carries the tags it saw.
threadan immutable root plus two last-write-wins registers (expects_reply_from, deadline).
read_cursora max register per consumer and channel. It never moves backwards.
profilelast-write-wins per field; a legacy surface with no verb left.

read_cursor being a max register rather than last-write-wins is the kind of choice worth noticing: under last-write-wins a late-arriving operation carrying an older watermark would roll the cursor back and re-deliver messages somebody had already read. A max register cannot.

There is no daemon. Chat's change notification is the foundation's PRAGMA data_version watcher; the CLI never holds one open. Every verb is a one-shot pull: resolve the workspace, open the store, append an operation or read a view, exit.

Sync is the bus

Two agents in the same workspace see each other immediately — they read the same file. Two different workspaces see nothing of each other until the log is exchanged, and that is a platform operation on the umbrella, not a chat verb:

nxs sync bind              # once per workspace; derives the stream from the git origin remote
nxs sync run               # one push/pull pass — this is what moves messages between workspaces

nxs sync bind derives the stream id deterministically from your origin remote, so every clone of a repository lands on the same stream with nothing to copy around. It also best-effort installs a per-user background sweep, which you can decline with --no-daemon and drive by hand instead.

The trade is stated rather than hidden: zero standing cost — no process, no socket, no battery — against delivery that is not instant. Everything works with no relay at all; you simply have one workspace instead of several.

One consequence to know before you put anything sensitive in a message: in this milestone sync is full-log and unfiltered. There is no per-channel authorisation on the wire.

Declarations, not registrations

Nothing in nxc creates a participant. There is no verb that registers an agent or creates a channel, and that absence is a design decision rather than a gap. Two files under .nxs-personas/ are the whole model:

  • <handle>.yaml — one persona: who it is, what it is for, which model and tools it runs with, and who may address it. See personas.
  • channels.yaml — a list of channels: who is in them, in what order they run, how long a round may take, and what happens to the answers. See channels.

A declaration is a file you can read, diff, review and commit. A runtime registration was a row in one machine's database that nothing reviewed and that did not survive the run. nxc list is the read over the folder, and nxc send --to reaches what the folder declares and nothing else.

Identity: <origin>/<agent>

Every sender, every expectation, every claim is written as a qualified handle:

  • <agent> is NXC_ACTOR, else $USER, else the literal nxc. A variable set to an empty string counts as unset.
  • <origin> is NXC_ORIGIN, else the workspace's own replica prefix — the same short namespace that prefixes flow's item ids. So a handle reads ab12/coder, not local/coder.

The origin is per workspace, not per machine and not per user. That matters because handles are matched by exact string: if an app minted ab12/coder while a terminal minted local/coder, one declared channel would quietly have two disjoint member sets and nothing would report it. Both the CLI and the library seam resolve the same value from the same place, and a test holds them to it.

When chat starts a persona, it hands the new process exactly what it needs to know who it is: NXC_ORIGIN, NXC_DB (an absolute path, so the working directory never matters), NXC_SESSION, NXC_ACTOR set to the persona's own handle, and NXC_HOP. Nothing else from the operator's shell reaches it beyond PATH and HOME — the child's environment is cleared and refilled from an allowlist, because everything in it is readable by the agent's own tools.

Channels: group, public, direct

A channel is where messages live; a thread is a conversation inside one. Three kinds exist:

  • group — the default a declaration produces. Named, member-only.
  • public — the project's front door. It is a read opening and nothing else: messages and boards in it are readable by anyone in the workspace, whether or not they are a member. What stays member-only is the read cursor (a non-member has no read state to keep) and the membership-scoped enumerations. Public channels — including ones that arrived by sync and that no declaration here names — show up in nxc list --json under public_channels, and only there: discoverable is not the same as addressable, so the human rendering leaves them out.
  • direct — a two-party conversation, minted for you. Its id is derived from the two handles (sorted, hashed, dm: + 24 hex characters), so both sides compute the same id with no rendezvous and no verb. send --to <persona> materialises it on the way past.

A declaration may ask for group or public. It may not ask for direct — a DM is derived, and letting the word parse would mean a typo silently producing a group channel.

A channel id that exists in the store but that no declaration names is not a target. Sending to one is refused with a message naming the file to declare it in; its messages stay readable through inbox and search.

Threads and the derived quorum

A thread is the address of a conversation. send --to mints one and hands it back, and reply --thread <id> is the only way to post into it. A thread carries an immutable root — origin, channel, opener, and an optional parent — and two mutable registers.

parent is what makes an operation a tree rather than a list: it is resolved mechanically from where the sending session was standing, never typed by an agent, and a thread with no parent is a root. That is why "is this the root" is derived rather than stored as a flag that could be wrong.

expects_reply_from is the declared obligation: a list of qualified handles. From it and the messages in the thread, five things are derived — nothing is stored:

  • expects — the declared handles, in declaration order.
  • replied — those that have posted since the current declaration. The comparison is against the declaration's own clock, which is what makes a multi-turn thread work: a role's answer from turn one does not discharge turn two.
  • outstandingexpects minus replied.
  • complete — the expectation was declared and nothing is outstanding. Clock-free and deterministic, which is why it can be the trigger that moves work on.
  • stale — a deadline exists, now is past it, and something is still outstanding.

complete and stale are disjoint by construction, and together they are what a channel calls "settled" (see channels). Only the opener may re-declare a thread's expectation; anyone else gets forbidden.

Delivery: push, and what is left of the cursor

Delivery is a PUSH. A message reaches an agent through the session it starts or resumes: send --to opens a fresh session with the body in its prompt, reply --thread resumes the target with the reply's body, and a completed quorum wakes whoever opened the board. There is no verb for asking after your own messages and none for acking them — nxc inbox and nxc read were removed (nxf 6j6v.1gm9) once both had been measured at 0 uses across 66 role sessions. A person reads the CONVERSATION instead: nxc threads show <thread>.

A message still carries a disposition: in_turn ("act now") or next_session ("catch up on this when you next start"). Since the per-call flag was removed, everything a caller writes is in_turn; next_session survives on the wire and in the model, and is what the engine uses for its own bookkeeping. Neither is replayed at session start. nxs prime stopped printing message bodies in nxf 6j6v.4mmk (the unread) and 6j6v.1gm9 (the results of boards you opened), for one reason measured twice: what every session pays for has to be something it cannot get otherwise and needs before it acts, and a body it was already handed is neither. In the workspace where this was last measured that block was 63.787 bytes, 74 % of it the text of finished commissions.

Unread is still a predicate, not a flag: messages in channels you are a member of, newer than your cursor, that you did not send yourself. It is carried on the seam — nxc prime --json has in_turn, next_session and count — and an embedding app both renders it and moves the cursor (Engine::mark_read). The cursor is an operation on the log like any other, so it syncs: acking on one machine is not re-delivered on another. What becomes of the whole unread apparatus is nxf 6j6v.4d2z.

Sessions and transcripts

When chat starts a persona it mints an internal session id and hands it to the agent runtime; the runtime answers with its own real session id, and nxc session bind maps the two. Everything downstream — the return address on a reply, the depth guard, the transcript — is keyed on the internal id, which is what send --to <persona> returns as session.

A transcript is that session's normalized stream: assistant text, thinking, tool calls and their results, with a Task-spawned subagent's entries nested under the call that spawned them. It is the answer to "what did this agent actually do", where search only answers "what did it say".

Transcripts are device-local and never synced — they are by far the highest-volume thing chat produces, and putting them in a shared grow-only log would balloon it for every peer. They are also retained rather than kept forever: whole sessions age out on their last entry, on a window that defaults to 30 days and rides the first flush of every new session.

Operations

An operation is the whole tree a first message started: your thread, the threads a channel opened under it, the threads those opened in turn. nxc status is the read over it, and each thread in it is open, answered, or stale, with one flag beside them:

  • open — somebody still owes a reply. A chain that has hung also looks like this.
  • answered — the expectation was declared and discharged.
  • stale — the declared window ran out with something still outstanding.
  • awaiting_human — true only at the root of a finished operation.

That last flag exists because a stalled chain and a finished one waiting for a person look identical to a counter and mean opposite things. It is only ever true at the top, which is the same statement as: no human stands inside the flow. More on that in limits-and-safety.

Next

  • personas — declaring who your agents are.
  • channels — declaring how they work together, in parallel or in order.
  • commands — the verbs over all of this.

Type to search. Matches are found by wording and by meaning, so a section can answer a question it does not spell out.