Workflows often need to hand work off to other workflows without wiring direct calls between them. The sender knows it has produced a finding; it should not have to know which workflows want to react to that finding, or in what order. Jaiph addresses this with channels — a small, in-process message-passing model that lives inside the same workflow runtime as everything else.
This page explains the model: how channels behave, why the design is shaped the way it is, and how the pieces fit together. For the surface syntax see the Language and Grammar references; for the runtime implementation see Architecture — Channels and hooks in context.
Two patterns recur in real workflows:
Channels give workflows a publish/subscribe surface without leaving the process. The producer declares “this is a finding”; the channel declaration ties findings to one or more listeners; the runtime delivers them.
The most important property of the inbox model is that delivery is drain-driven. Sends do not “fire” routes the moment the <- line executes. Instead, each workflow frame owns an in-memory queue; a send enqueues on the nearest stack frame that declares routes for the channel (or on the sender’s frame when none do — see Routed vs unrouted sends). The runtime drains that frame’s queue only after that frame’s step list finishes — including the implicit join of any run async handles created in that step list (Spec: Async Handles). Only then does the runtime invoke each route target, sequentially, in declaration order.
This is intentional:
inotifywait, no fswatch, no polling loop. The inbox/NNN-<channel>.txt files under the run directory are an audit copy of routed sends, not a delivery mechanism — routing does not read them back.The trade-off is that channels are not a low-latency notification primitive. They are an end-of-step-list handoff. For tighter coordination, use a direct run call.
A channel declaration carries its targets inline:
channel findings -> analyst, reviewer
Routes are top-level static data on ChannelDef, not statements inside a workflow body. The design choice has two consequences worth understanding:
E_VALIDATE at compile time, not at dispatch time.findings” without scanning workflow bodies for ad-hoc wiring. Routing intent lives next to the channel it describes.The runtime registers routes only on the entry workflow frame when that workflow starts: it reads channel … -> declarations from that workflow’s module. Nested run frames always keep an empty map, so sends from callees walk the workflow stack outward to the orchestrator frame that registered the channel.
A channel <name> line without -> still defines the name for send validation but never enters the route map — sends on a bare channel are still queued (and INBOX_ENQUEUE is still recorded for the timeline), they just have no consumer.
For each queued message, route targets run strictly in declaration order, one at a time. The next message is not processed until every target for the current message has completed. There is no opt-in parallel mode; older builds exposed one and it has been removed.
The reason is failure semantics. With sequential dispatch:
A single frame’s drain pass is bounded (default 1000 messages; override with JAIPH_INBOX_MAX_DISPATCH) so circular send loops abort with E_INBOX_DISPATCH_LIMIT instead of running forever.
When concurrency matters, the right tool is run async inside a target body, not parallel dispatch across targets.
The same <- operator behaves slightly differently depending on whether any frame on the workflow stack has the channel in its route map:
alias. prefix is stripped before lookup). The runtime walks outward from the sender until it finds that frame, enqueues the payload on its queue, and writes inbox/NNN-<channel>.txt under the run directory as an audit copy.INBOX_ENQUEUE is still appended to run_summary.jsonl, but no audit file is written and the sender’s drain pass skips it (no targets to run).Unrouted sends are intentionally a silent drop, not an error. This lets optional subscribers be just that: a workflow can publish on metrics even if no one is listening today, and tomorrow a subscriber can be wired up without touching the producer. If a missing handler should be a hard failure, the right place to assert it is in a test or a rule check, not in the channel runtime.
A receiver workflow is a normal workflow, dispatched with three positional arguments bound to the parameters it declares:
| Position | Meaning |
|---|---|
| 1st parameter | The message payload (the string sent on <-) |
| 2nd parameter | The channel name (bare, e.g. findings) |
| 3rd parameter | The sender — the workflow name that performed the send |
The receiver picks its own parameter names. That is the entire contract: no environment plumbing, no special globals, no implicit context object. Targets that declare a different parameter count are rejected at compile time so receivers cannot drift away from the dispatch shape.
Channels are a deliberately small idea in Jaiph. They are an in-process, drain-driven, sequentially-dispatched, late-binding handoff between workflows — described once at the top of the module, validated at compile time, and recorded in run_summary.jsonl (every send) plus inbox/ audit files (routed sends only) for after-the-fact inspection. Anything more powerful (concurrency, brokers, retries, dead-letter queues) is intentionally out of scope: those problems belong to other tools, and Jaiph keeps channels small enough to reason about without leaving the runtime.
channel / send syntax surface.