Channels — send and receive messages
A channel is how the server talks to people: it announces every pause, a workflow’s send:
step posts through it, and — when it is inbound — replies come back through it to answer a pause
or start a run. It is configured once for the whole server and every workflow uses it.
wfnexus does not speak WhatsApp, email or anything else. A channel is a command the server
runs — an adapter script — with a JSON message on stdin. The contract is
docs/channels.md in the repository, with JSON Schemas under docs/schemas/; ADR 0023 says why.
Configure
Section titled “Configure”Register the channel in registries.json:
{ "channels": { "team-chat": { "kind": "command", "run": "node adapters/apl-whatsapp.js", "env": { "APL_WA_GROUP": "${TEAM_GROUP_JID}" }, "inbound": true, "receiveEvery": "1m", "allow": ["15550100001@s.whatsapp.net"], "sendGap": "10s" } }}Then make it the default in ~/.config/wfx/config.yaml (or set WFX_DEFAULT_CHANNEL, which
wins over the file):
channels: default: team-chatenv values may reference variables — ${TEAM_GROUP_JID} is read from the server’s own
environment when the adapter runs, so a group id or a token never sits in the file. run runs
from the directory that holds registries.json.
wfx channels # every channel, which is the default, inbound or notPauses go to the default channel automatically, alongside any webhook notifiers. The message says what is waiting, how to answer, and links to the run:
nightly-watch is waiting (approval) at step ship:step ship needs approval before it runs
Reply "#a7k2qp yes" or "#a7k2qp no <reason>".Open: http://wfx.example.com/runs/6f1c…A workflow sends with a send: step. channel defaults to the server’s default; text,
attachment, target and the dedup key are templates:
steps: - id: tell send: text: "disk {{ .Steps.check.percent }}% full on {{ .Input.host }}" attachment: report.txt # a path in the run's workspace, or an https:// URL dedup: key: "disk-{{ .Input.host }}" window: 6h # same key within 6h ⇒ skipped, logged on the runThe step’s output is {sent, skipped, channel, id, text}, so a later step can branch on it.
dedup is what lets a 15-minute schedule say a thing once per window. dry_run: true renders the
message and records “would send on channel …” on the run without running the adapter, and
wfx dryrun shows each send step’s channel and rendered text. A file attachment must be inside
the run’s workspace, and a URL must be https to a public address. A failed send fails the step with the
adapter’s error and is never retried — a duplicate in a chat is worse than an error.
An operator sends through the same path:
wfx send "deploy window opens in 10 minutes"wfx send --channel team-chat --file ./report.pdf "nightly report"Receive
Section titled “Receive”An inbound channel is polled every receiveEvery, each channel on its own. Each received
message is stored once and handled after it is stored, and the adapter’s cursor is kept in the
database, so a restart neither loses nor repeats messages. If receiving fails, or the adapter had
to reset a lost cursor, wfx channels and the System page say so.
wfx messages # newest first, with what each one didwfx messages --channel team-chatwfx channels receive team-chat # poll nowThe run page lists the replies a run got, and the System page lists channels and what came in.
Start a workflow from a message
Section titled “Start a workflow from a message”on: message: - channel: team-chat match: "(?i)^deploy\\s+\\S+" # or contains: "deploy" allow: ["15550100001@s.whatsapp.net"] # optional; default: the channel's allow listA message starts the run only if its sender is on the trigger’s allow list, or — when the
trigger has none — the channel’s. With both empty, no message starts anything; the refusal is
logged and the message shows as refused. The run’s input is
message: {channel, id, from, name, text, at, attachments}, and the run is attributed to the
sender, so prevent_self_approval stops them approving their own run from the chat. A reply
that answers a pause does not also trigger.
Answer a pause from chat: yes / no
Section titled “Answer a pause from chat: yes / no”A reply that quotes the pause’s code answers it — only if the sender is on that channel’s
allow list:
| pause | reply | effect |
|---|---|---|
| approval | #a7k2qp yes (or approve, ok, lgtm) |
approved |
| approval | #a7k2qp no not on a friday |
rejected, with the reason |
| question / input | #a7k2qp use the staging database |
answered with that text |
A code is six characters, matches only on the channel it was sent on, expires, and is retired
the moment its pause is answered by any path — so an old reply can never answer a newer pause.
Anyone else’s reply is recorded on the run and resolves nothing. The default allow list is
empty, so out of the box nothing resolves from chat. The answer is recorded as
<sender> (via channel:team-chat), and a step’s approvers: still applies to it.
The reference WhatsApp adapter
Section titled “The reference WhatsApp adapter”adapters/apl-whatsapp.js (plain Node 18+, no dependencies) sends to and reads one WhatsApp
group through the apl CLI (apl with whatsapp:<label> -- wacli …):
| env | meaning |
|---|---|
APL_WA_GROUP |
the group id (1203…@g.us); a send’s target overrides it |
APL_WA_LABEL |
the apl handle label; default biz |
APL_BIN |
the apl binary; default apl |
APL_WA_TIMEOUT_MS |
the whole invocation; default 120000, capped below the channel’s timeoutSec |
Run it on the machine that owns the WhatsApp account. There, wacli send hands the message to
the running syncer instead of opening the store a second time, so there is still one writer. The
adapter never runs a sync and never retries a send.
Email, and anything else
Section titled “Email, and anything else”Another transport is another adapter script and another channels entry — the envelope, the
engine and every workflow stay the same. Switching the default from chat to email is
channels.default: team-mail.