Multi-step automation often splits work across several workflows: one stage produces a result, another stage should run only after that result exists. You could glue stages together with temporary files and shell glue, but that is easy to get wrong (races, stale paths, unclear ownership).
Jaiph instead offers a first-class inbox: an in-process channel between
workflows. One workflow sends a message (<-); another is dispatched
when that message is processed (->). The runtime owns routing and ordering;
there are no file watchers, no polling, and no external brokers.
One workflow produces output with the send operator (<-), another
reacts to it via a route declaration (->). The runtime handles
dispatch — no file watchers, no polling, no external message brokers.
Send (<-), routes (->), and related parsing rules are specified in
Grammar — Parse and runtime semantics
(items 11–12). Restrictions on $(...) and bare shell calls also apply to
the RHS of a send; see Grammar — Managed calls vs command substitution.
channel findings
workflow researcher {
findings <- echo '## analysis results'
}
workflow analyst {
echo "Received: $1"
}
workflow default {
run researcher
findings -> analyst
}
In this example, researcher sends data to the findings channel.
The default workflow routes findings messages to analyst, which
receives $1=message, $2=channel, $3=sender (see Trigger contract).
inotifywait, no fswatch, no polling.run.inbox_parallel = true or JAIPH_INBOX_PARALLEL=true (see
Parallel dispatch below).channel <name>Declare channels at top level, one per line:
channel findings
channel report
workflow default { ... }
Every channel used by send (<-) or route declarations (->) 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> <- <command>The channel reference is always on the left side of the <- operator.
Valid forms:
findingsshared.findingsThe send operator captures the command’s stdout, writes it to the next inbox slot, and signals the runtime to dispatch.
channel findings
workflow researcher {
findings <- echo '## findings'
}
If no command follows <-, the workflow’s $1 argument is forwarded:
channel findings
workflow forwarder {
findings <-
}
The <- operator is only recognized when it appears outside of quoted
strings. The parser tracks quote state to avoid false matches inside
shell commands.
Transpilation:
| Jaiph | Bash (generated in the workflow ::impl) |
|---|---|
ch <- echo "foo" |
jaiph::send 'ch' "$(echo "foo")" '<workflow>' |
ch <- |
jaiph::send 'ch' "$1" '<workflow>' |
(<workflow> is the name of the workflow that contains the send step.)
<channel_ref> -> <workflow>Tells the runtime: when a message arrives on that channel, call each listed
workflow with positional args $1=message, $2=channel, $3=sender.
Targets must be workflows (local or imported as alias.name). Rules
and functions 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 function "…" cannot be called with run.
A name that is not a valid alias.name / name pattern fails at parse time as
E_PARSE invalid workflow reference in route: "…".
channel findings
channel summary
workflow default {
run researcher
findings -> analyst
summary -> reviewer
}
Multiple targets are supported (comma-separated). Sequential dispatch
(default): each target runs in list order for that message. Parallel
dispatch (run.inbox_parallel / JAIPH_INBOX_PARALLEL): all targets for
the messages being drained in the current loop iteration may run concurrently;
see Parallel dispatch.
findings -> analyst, reviewer
If you declare the same channel more than once (several findings -> …
lines), jaiph::register_route merges targets onto that channel in
source order.
Route declarations are static routing rules, not executable statements.
They are stored in routes on the workflow definition, not in steps.
The compiler validates that all target workflow references exist (see above).
# E_PARSE: capture and send cannot be combined; use separate steps
name = channel <- cmd
Use two steps instead:
name = cmd
channel <- echo "$name"
.jaiph/runs/<YYYY-MM-DD>/<HH-MM-SS>-<source-file>/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 monotonic counter scoped to the run.
Only the workflow that declares route rules gets the inbox infrastructure.
The compiler emits jaiph::inbox_init and jaiph::register_route calls
at the top of that workflow’s implementation, and jaiph::drain_queue at
the end. Any child workflow called via run (or via dispatch) inherits
the inbox environment and can call jaiph::send.
1. jaiph::inbox_init creates the inbox directory and resets state.
2. jaiph::register_route populates the routing table.
3. Execute the workflow steps top-to-bottom.
4. When <- is executed: write message to inbox dir, append to queue file.
5. After all steps complete, jaiph::drain_queue processes the queue in a loop:
a. Read all lines from the current cursor through the end of `.queue` into memory (a snapshot); if there are none, stop.
b. Walk each line (and bump the shared cursor / depth counter per line). Resolve the channel, load the message body from `NNN-<channel>.txt`, look up the route.
c. If there is no route, skip that line (message file remains on disk).
d. If there is a route, invoke each target with $1=message, $2=channel, $3=sender — **sequentially** in target-list order by default, or **all targets for all lines in the snapshot as background jobs, then one shared wait** when `JAIPH_INBOX_PARALLEL=true` (see [Ordering guarantees](#ordering-guarantees)).
e. Targets may call jaiph::send, appending new lines after the cursor; the next outer-loop iteration reads them.
f. Repeat from (a) until a read finds no new lines, or until the dispatch depth limit is exceeded (`E_DISPATCH_DEPTH`).
6. Run ends.
Routes are stored as a newline-delimited list (channel<TAB>targets) instead
of bash associative arrays, avoiding known bugs in bash 3.2 where reading a
non-existent key can return the last inserted value.
The dispatch queue (inbox/.queue) uses channel:NNN:sender entries
(e.g. findings:001:researcher). The sequence counter (inbox/.seq)
is also file-backed.
Both are files rather than shell variables so that increments and enqueues
performed inside subshells (e.g. run_step pipelines) survive back into the
parent process.
When run.inbox_parallel = true is set in a config block (module or
workflow scope) or the environment sets JAIPH_INBOX_PARALLEL=true, route
targets are launched as concurrent background jobs instead of one-at-a-time
calls.
Precedence matches the rest of Jaiph agent/run settings: an explicit environment value wins over in-file config. See Configuration — Defaults and precedence.
config {
run.inbox_parallel = true
}
workflow default {
run producer
findings -> analyst, reviewer # analyst and reviewer run in parallel
}
jaiph::drain_queue runs in a loop. Each iteration reads every queue line
from the current cursor through the end of the file (one snapshot), drains
that snapshot, then repeats if new lines were appended while dispatch ran.
mkdir-based, portable across macOS and Linux) protect the inbox
sequence counter (.seq) and the step sequence counter so that
concurrent sends and step registrations remain correct.Parallel dispatch introduces synchronous file locks around three shared-state files:
| Lock target | Protects | When held |
|---|---|---|
inbox/.seq.lock |
Inbox sequence counter + queue append | During jaiph::send |
.seq.lock (run dir) |
Step sequence counter | During jaiph::next_step_id |
run_summary.jsonl.lock |
Any append to run_summary.jsonl |
During each summary line write (all event types) |
Locks use mkdir (atomic on POSIX). They are only acquired when
JAIPH_INBOX_PARALLEL=true; sequential mode has zero lock overhead.
If any background target exits non-zero, drain_queue waits for all
other background jobs from the same parallel snapshot to finish,
then exits with status 1. The owning workflow still fails when any
dispatched target fails; only the moment of exit differs from strict
sequential fail-fast.
To revert to sequential dispatch, remove run.inbox_parallel = true
from config or set JAIPH_INBOX_PARALLEL=false in the environment.
Sequential mode is the default and requires no locks.
Channel "<name>" is not defined.set -e sees the
failure. In parallel mode the runtime waits for the rest of the
jobs from the current snapshot, then exits non-zero — same overall rule
(any failed target fails the run), slightly different timing.drain_queue skips that line (silent drop). This is intentional
for optional subscribers; use a dedicated workflow if missing handlers
should be an error.E_DISPATCH_DEPTH). The default
limit is 100 and can be overridden with JAIPH_INBOX_MAX_DISPATCH_DEPTH.Routed receivers get three positional arguments:
| Arg | Value |
|---|---|
$1 |
Message payload (content sent to the channel) |
$2 |
Channel name (e.g. findings) |
$3 |
Sender name (workflow that produced the message) |
JAIPH_DISPATCH_CHANNEL
and JAIPH_DISPATCH_SENDER environment variables respectively.$1, $2, $3) are passed through
jaiph::run_step like any other workflow invocation, so the progress
tree shows them with the usual numbered keys (e.g.
workflow analyst (1="…", 2="findings", 3="scanner")).JAIPH_DISPATCH_CHANNEL is also used by the event system to tag JSONL
events with "dispatched":true, "channel":"…", and "sender":"…" metadata.INBOX_ENQUEUE, INBOX_DISPATCH_START, and INBOX_DISPATCH_COMPLETE
lines to run_summary.jsonl (see CLI — Run summary).
Large message bodies appear as a safe payload_preview plus payload_ref
pointing at the inbox/NNN-<channel>.txt file under the run directory.
E2E e2e/tests/88_run_summary_event_contract.sh locks inbox-related summary
lines and ordering together with the rest of the persisted event contract under
parallel dispatch.jaiph run analyst "some content".
When called directly, $2 and $3 are unset.STEP_START/STEP_END events with
dispatched: true and channel: "<channel>" metadata.▸ workflow analyst (1="…", 2="findings", 3="scanner").log within
the dispatched workflow to show output in the tree. The runtime embeds
stdout content in the STEP_END event (out_content field) for error
reporting, with the same RFC 8259 JSON string escaping as other runs so
embedded logs (tabs, ANSI, control bytes) cannot break event parsing.
.out files under .jaiph/runs/ contain the full output for debugging.jaiph run. For a browsable history of past runs and step trees,
use jaiph report (see Reporting server).workflow default
▸ workflow scanner
✓ workflow scanner (0s)
▸ workflow analyst (1="Found 3 issues in auth module", 2="findings", 3="scanner")
✓ workflow analyst (0s)
▸ workflow reviewer (1="Summary: Found 3 issues in auth ...", 2="report", 3="analyst")
✓ workflow reviewer (0s)
✓ PASS workflow default (0.1s)