nxsflow
chat (nxc)

Channels

A channel is a declared group and, since the order of its members can be made binding, it is also the only workflow this system has. Both live in one file, .nxs-personas/channels.yaml, as a YAML list:

- name: review
  members: [general, integrity]
  description: the review quorum — one round asks both reviewers and hands back one verdict

name and members are the only required keys. Address it exactly as you address a persona:

nxc send --to review --ref nxf_ids=ab12.0008 "Judge the export change."

There is no nxc channels create, and there never will be. A channel that a verb minted lived in one machine's database, had no reviewable membership, and did not survive the run. A channel that is declared is a file in your repository — which is also why members: is the membership: there is nothing to join and nothing to leave.

What a send to a channel actually does

One call opens two levels, and knowing which is which makes every later reading of nxc status obvious:

  1. The channel thread — your conversation with the channel. It has exactly two ends: you, and the channel's supervisor. The supervisor is a reserved engine identity, <origin>/__channel__ — it has no session, no transcript, and nothing ever resumes it. It is machinery, not a participant, and no declaration can claim its name because a handle may not begin with __.
  2. One slot thread per step — the supervisor's conversation with each member, hung under the channel thread. That is the thread a member answers into, and its id is in the member's own trigger message.
$ NXC_ACTOR=alice nxc send --to build-and-ship --ref nxf_ids=ab12.0008 "Ship the export change." --json
{"thread_id":"m-00000000000000000000000003","message_id":"m-00000000000000000000000004","to":"build-and-ship","target":"channel","channel":"decl:build-and-ship","expects":["ab12/__channel__"],"spawned":true,"warnings":[],"refs_warning":null}

The expects on that receipt is the whole design in one field: you are waiting for the supervisor, and the supervisor is waiting for the members. You are woken once, when the round is done.

Parallel: the default

A channel that says nothing about order fans out to every member at once. Each gets a fresh session and its own slot thread; nothing waits for anything. The sender is never triggered, even when it is a declared member.

- name: review
  members: [general, code-quality, test-quality, integrity]
  expects: all               # or a list of handles — only those must reply
  on_complete: summarize     # or pass_through (default)
  summary_model: opus
  summary_prompt: |
    Fold the four reviews into one verdict and end with `Ready to merge? yes|no`.
  visibility: requester_only # or all_members

expects decides who must answer for the round to be complete: the bare string all (the default, every member) or a YAML list naming a subset. Anything else in that position is a loud parse error naming the file.

on_complete decides what you receive.

  • pass_through (default) hands you every collected answer in a defined shape — defined, because the reader is usually an agent parsing it, not a person skimming it. It opens with a header naming the channel, the thread and how many messages were collected, then a framing line, then each reply inside a <message from="…"> element.

    That from attribute is the engine's own record of who posted, and no answer can forge one. The delimiters are chosen per round, after every answer has been read, so that no answer contains them — an answer that writes </message> in earnest simply shifts the round to <message.1 …></message.1>, and the delivery names the boundary it used. Nothing is escaped and nothing is refused: the boundary is adapted to the answers, never the answers to the boundary.

    What that does not settle is whether a member is telling you the truth. The bodies are whatever the members wrote, delivered into a prompt, and the framing exists to say so — treat them as data, never as instructions.

  • summarize runs a defined model over those answers with a defined prompt and hands you the result instead. summary_prompt is required when you declare it — a summarize channel with no prompt is refused at the point of use, not quietly folded with nothing. summary_model is optional; a channel that names none folds at the junior band, because a fold is derivative work: it restates answers other sessions already produced. Name a model when your fold decides something — a merge verdict, a routing choice — and getting it wrong sends work the wrong way.

An escalation is never folded away. If any member answered --escalate, the round is passed through as it stands whatever the channel declares, and no synthesizer is spawned. Two reasons: "I cannot" is not a result, and running a model over it would produce prose about a failure in the place a requester reads an answer — while the successful members' answers go through unlaundered beside it, which is strictly more information.

visibility is requester_only (default) or all_members. Under requester_only the requester sees everything and any other member sees the opening message plus its own replies. Only message content is filtered; who is expected and who has answered is never hidden.

Who else may read: whoever opened the OPERATION. An operation crosses channels — a human asks a planner, the planner commissions a builder in #coding, the builder commissions a round in #review — and neither of those channels names the human in its members:. nxc status has always shown that whole tree from its root down, across every channel border; since then the messages follow. Whoever opened the operation reads any thread in it, whatever channel it sits in.

It does not widen requester_only for anyone else: that rule is between the participants of one round, and a member still never sees its neighbour's answer. The operation's opener is not in the round — it is the party the whole chain answers to, and every consolidation is delivered towards it anyway.

And members: is genuinely the membership, in both directions: an edit to the file reaches the door on the next read, not on the next send. Add a handle and it can read at once; remove one and it cannot, without anything having to be re-sent in between.

Nothing on the CHANNEL declares when a member is resumed, and nothing needs to: a fan-out always starts a member's turn on a fresh session, and a nxc reply --thread into that member's own thread resumes the session it already has. A member_session: fresh | resume key stood here until it was removed — it steered nothing and refused its own second value — and a channels.yaml that still sets it loads unchanged, the key ignored.

The one place a declaration does decide is a step, which has a resume: of its own — see Continuing a session, or starting a fresh one. Per step rather than per channel, for the reason the removed key failed: what a round should carry across depends on which edge it arrived over, and one answer for a whole channel cannot say that.

Sequential: the order is the flow

- name: build-and-ship
  members: [coder, review]
  flow: sequential
  description: the declared order a work order runs through
  timeout: 2h

One field turns a group into a workflow. flow: sequential says the order of members: binds: one target at a time, each started only once the one before it has settled.

flow: sequential has no step list of its own, deliberately. A second field naming its own sequence of targets would be a second membership list beside members, with its own referential rules and its own way of disagreeing with the first. members is already an ordered list; what was missing was not an order but a declaration that the order binds. So that is all this field says, and everything else about such a flow is read off members.

That also fixes its ceiling: a flat list is a straight line. When you need a cycle — work, check, back to work — declare steps: instead, which is the next section.

A step's target is addressed exactly as send --to addresses one: a declared persona handle, or a declared channel name. That is how a review round becomes a step rather than something somebody opens by hand — and what a channel step contributes is its own consolidated answer.

How a step moves on. The member answers its slot thread with nxc reply --thread <id>. That reply is the whole mechanism: the supervisor runs inside that same write path, sees the set is settled, and opens the next step. There is no "step done" verb, no branching token, and nothing to poll. If a member ends its session without replying, the operation stands still until the declared timeout strikes.

"Settled" means complete || stale — a step that answered and a step whose window ran out with nothing in it both release the next one. A step that stayed silent is reported, never absorbed: the call that moved the flow past it carries a step_unanswered warning naming the silent thread, and so does the consolidation that carries no answer from it.

A step that escalated releases nothing — it ends the chain. nxc reply --escalate says "I cannot carry out this task", and a task that was not carried out must not commission the work that was to follow it. So the successor is not started, the round is handed up as it stands with the escalation on it, and whoever commissioned the channel decides what happens next. On a parallel channel nothing changes: everyone there was asked at once, so there is no successor to withhold.

One thing that has no spelling on a flow: sequential channel, and it is worth knowing before you need it: there is no way for a step to say "not yet". reply has two answers there, and both of them end the turn. A step that has commissioned a round of its own — a review, say — and wants to keep the flow standing until that round comes back should simply wait and answer once it has the result. Reaching for --escalate to hold the flow works, but it costs you the rest of the chain and tells your caller the work will not arrive.

A stepped channel does have a third answer, and it is the reason the next section exists.

…and on working_tree: exclusive, the step's SESSION has to be over too. A reply is a message, and the process that wrote it can keep working: measured in a real run, a coder answered at 00:16:33 and went on editing until 00:34:27, while the next step had already started at 00:16:36 — into the very checkout the channel declared it needs alone. So a channel that claims the working copy waits for both facts. What supplies the second one is the session announcing its own end as it tears down (nxc session ended, which the sidecar calls for you); if that never arrives, the runtime's own process check answers the next time anything asks — and something asks on a clock, see below. A shared channel is untouched by any of this — two live sessions there are not a defect.

One consequence worth knowing before you see it: on such a channel the next step starts a moment after the answer rather than in the same instant, because it starts when the previous session exits.

A clock of its own watches this wait. timeout: below cannot: it is a deadline for an answer, and a member that has replied has answered. So a decline whose only remaining blocker is a live session schedules a re-check of that same board a minute later, and keeps doing so for as long as the process is still there. In practice the announcement arrives a second later and you never see any of it. When it does not — a sidecar killed before its teardown, an nxc older than this feature, a host runtime that never calls Engine::session_ended — the re-check finds the process gone and the flow moves on by itself, about a minute late.

You can always ask by hand, and the answer is a diagnosis rather than a shrug:

$ nxc tick --thread <the channel thread>
tick declined on thread …: the set is settled, but a session behind it is still running.

To see which session and what became of it, nxc session state --thread <the step's thread>.

A process that hangs rather than exiting is the one case the clock cannot resolve: it keeps looking and keeps finding it alive, which is the same wait as before — but a visible one, with something looking at it. If you are building a host runtime, wiring Engine::session_ended keeps you out of all of this.

Two things are refused on a sequential channel, both at the point of use and both with nothing started:

  • expects: <subset> — the fan-out returns a subset verbatim, so it would be a second list quietly deciding the order. The refusal names both lists.
  • the same target twice in members, and any set of channels that can reach itself through its members. A cyclic catalogue is refused outright when the declarations are loaded.

The second refusal is not tidiness: on such a channel the engine works out which step a thread serves by matching its target against members, so a repeat would make two different steps indistinguishable. steps: below lifts it by giving every step a name.

Steps: when the flow has to come back

- name: coding
  members: [coder, review, finisher]
  working_tree: exclusive
  steps:
    - id: build
      target: coder
      next: check
    - id: check
      target: review            # a persona, or a whole channel
      on_needs_rework: build    # the way BACK
      max_passes: 3             # attempts IN TOTAL — the original plus two reworks
      next: ship
    - id: ship
      target: finisher

steps: is a small state machine. members: keeps meaning what it always meant — who belongs to this channel — and steps: says what happens in which order. A channel that declares steps: may not also declare flow: sequential: that would be two lists deciding one thing.

  • id names the step, and it is what makes a cycle possible. Two steps may address the same persona, and the same step may run twice in one round, because the engine keys a step's thread on its id rather than on its target's name.
  • next is the normal way on. A step with no next is the end of the round: the channel consolidates there and answers whoever asked.
  • on_needs_rework is the way back, and the only branch there is.
  • max_passes counts attempts in total, not reworks: the original run is pass 1, so max_passes: 3 allows two trips back. Anything below 2 is refused — it would declare a back edge that can never be taken, and a step that should never send work back simply declares no on_needs_rework. It sits on the back edge, never on the channel: a channel may have several cycles and one counter would add them together. It is required wherever the edge is — a back edge with no ceiling is a loop nothing ends.
  • resume decides whether the step CONTINUES the session its target already ran in this round, or starts a fresh one. Leave it out and the edge decides, which is what you want almost always — see below.

Continuing a session, or starting a fresh one

A step reached over on_needs_rework: is the same party being handed its own work back. It is woken with a notice that says "what you handed over" and "your claim on the working copy is still yours" — sentences that are only true of the session that did hand it over. So a back edge continues that session: the findings arrive as its next turn, with everything it built still in its context, and it does not have to rediscover its own work from the checkout.

A step reached over next: is the next piece of work. That it happens to name the same persona is not by itself a reason to carry a session across, so a forward edge starts a fresh session.

That is the default, and resume: on the step overrides it either way — the same two steps as above, with both defaults turned round:

    - id: build
      target: coder
      next: check
      resume: false             # a rework goes back to a CLEAN session every time
    - id: check
      target: review
      on_needs_rework: build
      max_passes: 3
      next: ship
      resume: true              # …while the reviewer keeps its own session across the passes

A reviewer that keeps its session is not a curiosity: on its second look it remembers what it asked for, so it can judge whether it was done.

Either way the step gets a fresh thread, which is what the pass counter counts. Continuing is about the session, not about the slot. A round that has never run this target has nothing to continue and simply starts it — that is the first pass, not an error. And a step whose target is a whole channel cannot be continued at all: a channel has no one session behind it, its members each have their own, so resume: true there is reported as a bad declaration when the team is loaded.

The third answer

A step whose declaration names an on_needs_rework: edge gives the party serving it a third way to end its turn:

nxc reply --thread <id> "<your result>"                      # done
nxc reply --thread <id> --needs-rework "<what must be fixed>" # somebody else's work goes back
nxc reply --thread <id> --escalate "<what is in the way>"     # I cannot; the round goes up

The three are a trichotomy over done, again, cannot, and there is no fourth case. "Good but incomplete" is rework. "I need X first" is escalation. "Fine" is the normal way on. Note which work each is about: --escalate is a statement about your own work, --needs-rework about somebody else's. Setting both is refused.

A step that declares no back edge is never offered the bit at all — the session is told about two endings, not three. That is deliberate: if the flow shows no reaction to a verdict, then asking for one was asking for an opinion, not for a decision. Set it there anyway (you found it in --help) and it falls away, with a verdict_dropped warning on the reply's own receipt saying that this channel declares no transition for it.

If the step's target is a whole channel, any one member is enough. A reviewer who found something is not outvoted by three who did not, and the channel's own on_complete: summarize still folds the answers into the one judgement it was declared to produce.

What comes back with the work

The party sent back is woken with the verdict itself — the findings are the task — under a notice that says what happened, which attempt this is, and that its claim on the working copy is still its own:

NEEDS REWORK — this is NOT an approval. What you handed over was reviewed and did not meet the
standard of the party that reviewed it; their reasons are in the body below, and they are the work.
This is pass 2 of 3. Your claim on the working copy is STILL YOURS and is held across this round — do
not acquire it again. …

Declare rework_notice: on the channel to say it in your own words; {pass} and {max} are filled in for you.

When the ceiling is reached

The round is escalated upward, not stopped quietly: no further attempt is started, every answer of the round travels up to whoever commissioned the channel, and the delivery says which ceiling was reached. That party decides. Commissioning the round again is a fresh round — reply into the channel thread — and a fresh round starts its count at one, so an approval from above is all the "reset" there is.

What the round reports at the end

A round that went round a back edge hands over two answers from one step that contradict each other on purpose — "the lock order is wrong" and "good now". So the answers do not arrive as a flat list: every one of them carries the step it served and which pass of that step it was, and the one that carried the verdict is marked as such.

<message from="local/coder" step="build" pass="1">
first cut is in: added the cache
</message>
<message from="local/review" step="check" pass="1" needs_rework="true">
the lock order is wrong in two places
</message>
<message from="local/coder" step="build" pass="2">
lock order fixed
</message>

Above the block, the delivery says the one thing nobody can work out from the messages themselves: where one step appears more than once, the later pass supersedes the earlier one — the earlier passes are how the round got here, not findings that still stand.

It also states, in the same place, how many times each step really ran — counted by the engine from its own record. That is the one thing a reader cannot work out by reading: how many passes there were to expect, which is what makes "the later pass supersedes" a rule about a known number of things. The marks themselves are the engine's own, and no answer can write one — see pass_through above for the boundary that guarantees it.

Both output forms carry it. on_complete: pass_through renders it for whoever asked; on_complete: summarize hands the same structure to the session that writes the closing report, so what comes back is built out of the round rather than passed along. Your summary_prompt: says what the report is for; the run's shape is supplied for you.

A channel that declares no steps: has no passes, and its delivery is exactly what it always was.

Timeouts

timeout: is how long a round may wait. It is declared here or nowhere — the per-call --deadline override is gone, because a declared window and a caller's override are two answers to one question.

timeout: 20m                      # a window: 20 minutes of silence
timeout: 2026-08-16T10:30:00Z     # an instant: this moment, whatever happens

The parser tries RFC3339 first, so both forms are expressible in one field — and the difference is real. A duration is a per-member, resettable window: each member's clock is restarted by every write to its own session transcript, so a member that is working is never struck for taking a while. What it protects against is a member that went silent. An absolute instant arms no clock and nothing moves it.

A value the grammar cannot read is a validation error naming the field and the value, refused before anything is persisted — never a silent "then there is no cap". Units are s, m, h, d, w.

When a window is declared, chat arms a deadline for the moment it first falls due. When that moment arrives, a hidden verb runs — nxc tick --thread <id> — which re-checks the thread and, if it is due, routes it through the channel's policy. Because the window moves as members show signs of life, the chain re-arms itself: a tick that finds nothing due arms the next one. It is idempotent — a thread already handled is a clean no-op, never a second wake.

Nobody types tick. It is named here so that finding it in a log or a process list is not a mystery.

The deadline is kept by the nexus-flow background service

Arming a window writes one line into the workspace's own book, .nxs/timers.json. The nexus-flow background service — one process, the same one that keeps your workspaces in sync — reads that book on every pass and runs the check when the moment comes.

By default it is called nexus-flow, and on a Mac that is the name you will find in System Settings › General › Login Items. Set it up once:

nxs sync daemon install          # macOS: a launchd agent that starts it at login
nxs sync daemon                  # anywhere: run it in the foreground under your own supervisor

Its log is ~/.nexusflow/logs/service.log. nexus-flow is also a command — it is nxs sync daemon — so nexus-flow status answers "is it running, and what did it last do".

It keeps the machine awake while a run is working

A run that works unattended for hours does not survive the Mac falling asleep. While any session is alive in any workspace the service attends, it holds an idle-sleep assertion, and it gives it back the moment the last one ends. You can see it, by name, in pmset -g assertions — it says which instance is holding it.

It prevents idle sleep only: your display still sleeps, a closed lid still sleeps, and choosing Sleep from the menu still works. And it is held by the service process itself, so if the service is killed the assertion goes with it — a crash cannot leave your Mac permanently awake.

More than one service on one machine

If you develop against several checkouts at once, you do not want them sharing a clock: whoever installed last would own it, for all of them. Give a checkout its own instance name and it gets its own everything — its own launchd agent, its own ~/.nexusflow-<name> directory, and therefore its own registry, lock, heartbeat and log:

export NXS_SERVICE_INSTANCE=nexus-flow-dev    # in your .envrc, once per checkout
nxs sync daemon install                       # installs *that* instance
nxs sync daemon status                        # says which instance it is answering for

A name is nexus-flow or nexus-flow-<something>. The instance's name is also the command: nexus-flow-dev status is that service's own status, and it is how the running service knows which one it is — the launchd agent runs a link with that name, and the name is the whole instruction.

Two instances cannot get in each other's way: separate directories mean separate locks, so both run side by side and installing one never removes the other. The one thing they can share is a workspace — nothing stops you registering the same checkout with both — and then both attend it, both read its deadline book, and a window that comes due can be started twice. Register a workspace with exactly one instance; nxs sync bind, nxs sync daemon status and the running service all say so if you have not.

That answer also says which binary it is running: the program the live service started from, its version, and what the nexus-flow link points at now. The two can differ — nxs self-update writes a new binary and deliberately does not move the link, so which build keeps your machine's time stays a choice — and status says so when they have drifted apart. The link pointing at a file that is gone is the one failure launchd cannot report at all (its only symptom is a service that never starts), so that case is not left to status: every nxs, nxf, nxm and nxc invocation says it, names the dead target, and gives you the one command that repairs it.

What this buys, compared to asking the operating system for a job per deadline (which is what happened before):

  • It is honoured to the second. Nothing rounds up to the next whole minute.
  • A board closed early leaves nothing behind. The line is overwritten or removed; there is no agent left registered in your login session for a window that no longer exists.
  • The same mechanism everywhere. macOS, Linux, and anything else the service runs on read the same book.

This changed, and it is worth knowing if you worked around it. nxc used to schedule through at — and macOS ships atrun disabled, so at accepted the job, exited 0, printed a job id, and nothing ever ran it. A stock Mac accumulated a queue of jobs whose due date was long past, and a declared timeout: never fired there: a member that went silent held its round indefinitely, because nothing ever made the board stale. If you enabled atrun by hand for that, you no longer need to. NXC_TIMER=at still selects the at backend explicitly, on any platform, if you want it.

And what it costs, said out loud

There is no clock without the service. at was self-carrying — submit the job and the operating system owns it, whether anything of ours is running or not. This is not, and nxc does not pretend otherwise: arming a window in a workspace no service attends, or while no service is running, still records the deadline (a service that starts later honours it) and still reports it on the receipt, as a tick_unscheduled entry in warnings, naming the board and what to do. It is deliberately not a non-zero exit: the board is open and its members are running; what is missing is the safety net.

The two things it can tell you apart, because they need different answers:

  • this workspace is not registered with the service — register it (nxs sync bind, or from your app), then nxs sync daemon install;
  • no service is running — start it.

When you do see it, nxc tick --thread <id> is the same verb the service would have run, idempotent and callable at any time. Nothing is lost — the window is simply checked when somebody asks rather than on its own.

If a round must not be able to hang and you cannot run the service, do not rely on timeout: for it — treat the fuse as absent and check the board.

Exclusive use of the working copy

- name: review
  members: [general, code-quality, test-quality, integrity]
  flow: sequential
  working_tree: exclusive

Declared on the channel when it is the round that needs the repository, rather than any one member on its own account — a review quorum where every member runs the project's own build and test is exactly that shape. It also works the other way: if a declared member says working_tree: exclusive for itself, the whole channel counts as needing it and this line is not repeated. That inheritance is one hop only — a channel does not catch it from another channel through a shared member.

What it protects against, and what it does not: it protects this round from other chains, which wait instead of running their own build against the same target directory. It does not separate the members of one round from each other — they are one claim area, the first acquires and the rest inherit, so they all run at once. The unit of exclusion is the area, not the session.

That is why the example above also says flow: sequential, and it is not decoration. Four members that each run the project's build in the same checkout are four builds in one target directory. Two of the same shape merely take the build tool's own lock and wait — correct, and several times as slow. Two of different shapes overwrite each other's artifacts, and what you then read is a broken build that is really a shot artifact. working_tree: cannot separate them; the order can, because the next step of an ordered flow opens only once the previous one has answered and its session is over. So: a quorum whose members only read may fan out and give you four opinions at once; a quorum whose members BUILD wants both lines. The price of the second is wall-clock, plus the ordered-flow rule that an escalating reply ends the chain — a member that cannot run stops the round instead of leaving the others' verdicts behind, which is usually what you want when they all share one checkout anyway.

And the area is the OPERATION — the whole thread tree the work belongs to, the same unit nxc status groups by, not just the branch the exclusive step happened to start in. The claim still arises at the first exclusive step, so a chain that never reaches one holds nothing; but once it is taken it is given back when the operation is finished. That is what keeps a planner's second coding round from having a gap in front of it. If you want the copy held from the operation's first move — through the planning too — declare working_tree: exclusive on the channel that starts it.

Here is what that looks like when a round is parked behind a conversation that is already holding the copy:

$ NXC_ACTOR=alice nxc threads list
m-00000000000000000000000003  decl:build-and-ship  0/1 in  [waiting]  · waiting for working tree (#1)
m-00000000000000000000000004  decl:build-and-ship  0/1 in  [waiting]  · waiting for working tree (#1)
m-00000000000000000000000001  dm:9445dbc93dfdad401585c42b  1/1 in  [complete]
m-00000000000000000000000002  dm:9445dbc93dfdad401585c42b  0/1 in  [waiting]  · holding working tree

Note what the send receipt did not say: a channel send reports spawned: true even when every member is parked, because it is one receipt for the whole fan-out. The board above is where a requester finds out. Full mechanics in limits-and-safety.

Preconditions: what must hold before a step starts

- name: coding
  members: [coder, finisher]
  flow: sequential
  working_tree: exclusive
  preconditions:
    - name: remote-not-ahead
      run: git rev-list --count HEAD..@{u}
      expect: "0"
    - name: tree-clean
      run: git status --porcelain
      expect: ""

A precondition is a hurdle the supervisor puts in front of every step of this channel. It runs in the working copy the sessions run in, before the step's session is started — after the start the session is there and whatever it was going to do is under way with it.

A hurdle passes when its command exits zero, and — if the declaration names an expect: — when its trimmed standard output is exactly that. Omit expect: for a command that is already a predicate (test ! -f .nxs/build.lock); write expect: "" for one that must print nothing. The exit status is always checked, so a command that fails and happens to print the expected string is still refused: git rev-list --count HEAD..@{u} with no upstream configured fails and prints nothing, and reading that as "zero commits behind" is exactly the wrong answer.

Everything else refuses too. A command that does not exist, crashes, or does not finish within its bound stops the step — and a hurdle that runs out of time is killed with everything it started (it runs in its own process group), so nothing of it is left behind in the working copy the next step wants. A hurdle that lets a doubt through is not a hurdle — and a runtime that cannot run commands at all refuses every declared hurdle rather than skipping them, which is the same rule seen from the other end.

The hurdles are asked in the order they are declared and the first refusal wins — nothing after it is run, and what comes back names one hurdle and shows its output. Where the refusal appears depends on which step it stopped: a nxc send --to <channel> that cannot open its first step fails outright, with the hurdle named; a step further along the flow is reported in the warnings of the reply that would have started it, under the class precondition_refused and distinct from step_skipped, which means something broke rather than a rule said no. Nothing retries a refused step: a hurdle refuses because the world is not ready, so asking again is your move.

What you do not have to declare. Some things hold whether or not you write anything, because they are properties of the machinery rather than of your project — chiefly that no session of an earlier step of the same channel is still running when the next one is about to start, on a channel that claims the working copy. Those are deliberately not expressible here: a rule you have to remember to ask for is a rule half the workspaces will not have.

What it costs, said out loud: one process per declared hurdle per step. A quorum of four checkers with two hurdles is eight extra processes per round. Keep hurdles to fast, read-only questions; they run inside the call that would have started the step, and while they run nothing else can use the workspace.

So a step's hurdles share ONE time budget — a minute, all of them together. It is checked before each hurdle, so a hurdle is never started once the budget is gone; it comes back refused, saying that the ones before it used the budget up. That is why there is no limit on how many hurdles you may declare: the number was never the thing worth capping, the time was.

And they are frozen with the rest of the declaration. A hurdle is executable code in a file the declared agents can edit, so an operation checks against the declarations it opened under: editing preconditions: while a chain is running does not switch it off for that chain, and the change takes effect in the next operation. That is a real limit as well as a guarantee — an agent that slips between two operations is not caught by it.

Front doors

- name: front-desk
  kind: public
  members: [triage]

kind: is group (the default) or public. A public channel is the project's front door: its messages and boards are readable without membership, and it is discoverable across workspaces that share a sync stream. It is a read opening and nothing else — the read cursor and the membership-scoped listings stay member-only, and writing was never membership-gated for any kind.

direct is deliberately not a declarable kind: a direct conversation is derived from two handles and minted for you, so declaring one is not something that could be honoured — and letting the word parse would mean a typo silently producing a group channel.

What happened to nxc workflow

There was a workflow verb group — start, step, status, tick, done, bind, append, show, expect — with a run record behind it. All of it is gone, and this page is its replacement, not its documentation.

The reason is one sentence from the design: a channel is a work sequence; a "real" workflow is the same term with a fixed order and defined write permission; there is no second term for it. A fixed order of a role step followed by a channel step — the shape every declared workflow actually had — is flow: sequential. And "defined write permission" needed no field at all: every thread has exactly two ends, only a thread's opener may re-declare its expectation, and the supervisor identity is reserved as a class. Who may write what, and where, is settled by the data model rather than by a declaration that could disagree with it.

So the mapping is:

WasIs
workflow startnxc send --to <declared channel>
workflow step donenxc reply --thread <slot> — the supervisor decides what comes next
workflow status / listnxc status --thread <id>, or bare nxc status
workflow expectthe channel's expects:, declared
workflow tickets add--ref nxf_ids=<id> on the send that opens the round

Next

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