Pipelines often split work across workflows that hand off a payload: one stage produces output, a later stage reacts to it. A generic way to do that without a separate broker is an in-module channel: a named queue the runtime can drain after a caller finishes its steps, driving receiver workflows in order.
Jaiph’s model is a small orchestration feature on top of that idea: a
channel is declared with optional -> routes to workflow targets; a send
uses <- to enqueue a string payload. NodeWorkflowRuntime keeps the queue
and route map in memory, writes a matching file under the run for audit, and
dispatches targets when the entry workflow’s step list completes (plus
any implicit run async join) — not when a separate -> “fires”; the ->
in source code is static routing on the channel line, not a runtime
operator.
NodeWorkflowRuntime attaches an in-memory queue and route map to each
WorkflowContext (one per run/inbox nesting level; channel-level
-> rows populate the map only on the entry context — see
Who registers routes and who drains). Each send also writes a durable copy to
inbox/NNN-<channel>.txt under the run directory for audit and reporting —
channel transport is queue-based, not filesystem-driven. There are no directory
watchers, no polling loops, and no third-party brokers.
channel findings -> analyst
workflow researcher() {
findings <- "## analysis results"
}
workflow analyst(message, chan, sender) {
log "Received: ${message}"
}
workflow default() {
run researcher()
}
researcher sends data to the findings channel. The channel findings -> analyst
declaration routes findings messages to analyst, which receives the message,
channel name, and sender bound to its declared parameters message, chan, and
sender (see Trigger contract).
inotifywait, no fswatch, no polling for new files.channel line), one completion at a time.inbox/
directory; they are not a separate mailbox outside .jaiph/runs.send RHS forms are E_PARSE / E_VALIDATE from
validateReferences in the build path; buildRuntimeGraph() only parses
modules and does not repeat that pass (see Architecture — Summary).channel <name> [-> <workflow>, ...]Declare channels at top level, one per line. Optionally declare inline routes
with ->:
channel findings -> analyst
channel report
channel events -> handler_a, handler_b
workflow default() { ... }
Every channel used by send (<-) must be defined in the current module or
imported from another module (e.g. shared.findings). Undefined channels fail
validation with:
Channel "<name>" is not defined<channel_ref> <- <rhs>The channel reference is always on the left side of the <- operator. Valid
channel forms:
findingsshared.findingsThe send step resolves the message from the RHS, writes the payload to the next inbox file on disk, and appends to the in-memory queue of the workflow context selected by the routing rule (innermost matching route on the stack, or the sender’s own context if none match — see Runtime dispatch).
Valid RHS forms:
| RHS form | Example | Behavior |
|---|---|---|
| Double-quoted literal | findings <- "## results" |
Interpolated string |
| Triple-quoted block | findings <- """line1\n ${x}""" |
Multiline string; margin rules match other """ steps (see Grammar) |
| Variable expansion | findings <- ${var} or $name |
Value of the variable |
run capture |
findings <- run build_msg() |
Return value or trimmed stdout of the workflow/script |
The RHS does not accept raw shell commands or bare workflow/rule/script
names (use a string, $ / ${…}, or run ref(…) — see
Grammar — send and
Grammar — channel routing).
channel findings
workflow researcher() {
findings <- "## findings"
}
An explicit RHS is always required — bare channel <- (without a value) is invalid.
The <- operator is only recognized when it appears outside of quoted strings
on the surrounding line so channel names and literals are not misread as send
syntax.
Send and route syntax, plus compile-time checks, are summarized under
Grammar — send and
Grammar — channel routing; the EBNF and
validation list live at the end of Grammar.
channel <name> -> <workflow>Routes are declared inline on channel declarations at the top level, not inside workflow bodies. When a message arrives on that channel, the runtime calls each listed workflow that must declare exactly 3 parameters. The runtime binds the dispatch values (message, channel, sender) to whatever names the target declares.
Targets must be workflows (local or imported as alias.name). Rules
and scripts are not valid route targets — the compiler uses workflow-only
reference checks, so a bad target is E_VALIDATE with messages such as
unknown local workflow reference "…", imported workflow "…" does not exist,
rule "…" must be called with ensure, or script "…" cannot be called with run
(see Grammar — channel routing for a short
version of the same rules). A name that is not a valid
alias.name / name pattern fails at parse time as E_PARSE
invalid workflow reference in channel route: "…". The wrong parameter
count on a resolved workflow is
E_VALIDATE: inbox route target "…" must declare exactly 3 parameters (message, channel, sender), but declares N.
channel findings -> analyst
channel summary -> reviewer
workflow default() {
run researcher()
}
Multiple targets on one declaration are comma-separated — they share one route and dispatch in declaration order, sequentially:
channel findings -> analyst, reviewer
Route declarations are static routing rules stored on ChannelDef, not on
workflow definitions or steps. The compiler validates that all target workflow
references exist and declare exactly 3 parameters.
A -> route inside a workflow body is a parse error with guidance:
route declarations belong at the top level: channel <name> -> <targets>.
# E_PARSE: capture and send cannot be combined; use separate steps
name = channel <- cmd
Use two steps instead:
const payload = run build_message()
channel <- "${payload}"
Under the run directory (see Architecture — Durable artifact layout):
.jaiph/runs/<YYYY-MM-DD>/<HH-MM-SS>-<source-basename>/inbox/
001-findings.txt
002-summary.txt
003-findings.txt
...
Each message is a file named NNN-<channel>.txt where NNN is a zero-padded
sequence for that run (monotonic on the runtime instance via inboxSeq). The
orchestration queue itself is in memory; these files are the durable copy
of the payload.
Every entered workflow gets a WorkflowContext: a route map and a message
queue. Channel-level route declarations are registered on the entry workflow
(the outermost workflow invoked by jaiph run). After the workflow’s steps
finish (including the implicit join for run async branches), the runtime runs
drainWorkflowQueue for that context.
Nested workflows invoked with run share a workflow context stack. On
send, the runtime looks for a route for that channel starting at the
sending workflow (innermost on the stack) and moving outward toward the
entry workflow. Since channel-level routes are registered only on the entry
workflow, sends from nested workflows bubble up to the orchestrator for dispatch,
preserving the expected progress tree nesting. If no workflow on the stack
declares a route, the message is queued on the sender’s context; when that
context is drained, there are no targets and the message is skipped (see
Error semantics).
Implementation: src/runtime/kernel/node-workflow-runtime.ts — send step
handling and drainWorkflowQueue.
WorkflowContext (route map, empty queue).<-: resolve payload, allocate the next sequence id from inboxSeq,
append InboxMsg to the selected context’s queue, write
inbox/NNN-<channel>.txt, and append INBOX_ENQUEUE to run_summary.jsonl.run async joins) complete,
drainWorkflowQueue:
while (cursor < queue.length) — new sends during dispatch append to the
same queue and are processed in subsequent iterations.channel on that workflow’s
context. If there is no route, skip (silent drop).There is no E_DISPATCH_DEPTH / JAIPH_INBOX_MAX_DISPATCH_DEPTH check in
NodeWorkflowRuntime’s drain loop. Avoid unbounded circular sends in orchestration.
-> declarations) and the pending queue are
in-memory on WorkflowContext. Message files under inbox/ are written
on send for audit; routing uses the queue.researcher), stable across modules.Messages are handled one at a time in queue order (FIFO). For each message,
targets run strictly in list order on the channel line; the next message is
not processed until all targets for the current message have finished (success, or
fail-fast on the first non-zero exit).
inboxSeq); message
filenames use the same padded counter.Channel "<name>" is not defined.NodeWorkflowRuntime. Avoid circular sends that grow the queue without bound.Routed receivers get three dispatch values bound to their declared parameters:
| Param position | Dispatch value |
|---|---|
| 1st declared parameter | Message payload (content sent to the channel) |
| 2nd declared parameter | Channel name (e.g. findings) |
| 3rd declared parameter | Sender name (the workflow name that performed the send) |
Receivers get channel and sender via their declared parameter names — no environment-variable plumbing.
run_summary.jsonl: NodeWorkflowRuntime appends INBOX_ENQUEUE,
INBOX_DISPATCH_START, and INBOX_DISPATCH_COMPLETE via
appendRunSummaryLine (see CLI — Run summary).
INBOX_DISPATCH_COMPLETE includes elapsed_ms. For INBOX_ENQUEUE
from jaiph run, the line includes channel, sender, and
inbox_seq. When a route consumes the channel, the full message body
is also written to inbox/NNN-<channel>.txt for audit; sends to
unrouted channels stay in the JSONL summary only.jaiph run only starts
the file’s default workflow; extra CLI arguments are passed to default
(see CLI — jaiph run). There is no `jaiph run
run steps, with dispatch values
shown as named parameters (e.g.
workflow analyst (message="…", chan="findings", sender="scanner")). The Node runtime does
not add a separate dispatched flag to STEP_START/STEP_END payloads
for inbox routing.log inside the receiver to surface lines in the tree. The runtime
embeds stdout in STEP_END (out_content) with the same JSON escaping
rules as other steps.run_summary.jsonl provide a browsable history of past runs
(see CLI — Run artifacts).Illustrative progress tree for a pipeline where researcher sends on
findings, analyst sends on report, and default routes both channels:
workflow default
▸ workflow researcher
✓ workflow researcher (0s)
▸ workflow analyst (message="Found 3 issues in auth module", chan="findings", sender="researcher")
✓ workflow analyst (0s)
▸ workflow reviewer (message="Summary: Found 3 issues in auth ...", chan="report", sender="analyst")
✓ workflow reviewer (0s)
✓ PASS workflow default (0.1s)