# relay2 - the scripted relay channel One principle, and every design choice below follows from it: > Intelligence should not be responsible for transporting intelligence. A role decides **what to say and to whom**. Everything else - transport, validation, routing, indexing, retries, receipts, backpressure, escalation, digests - is deterministic software that never calls a model. ``` SEND relay2 send --to ROLE --subject "..." --body "..." READ relay2 inbox relay2 read ID RESPOND relay2 reply ID --body "..." CLOSE relay2 ack ID you have taken responsibility relay2 resolve ID the work is done or closed ``` That is the whole surface most sessions need. `--help` on any subcommand carries the rest. ## Install ```bash ./install.sh export RELAY_ROLE= # or pass --from / --role relay2 register --role # optional; reports your backlog ``` `install.sh` needs `python3` 3.9 or newer. It creates a private Python environment in `~/.relay2/venv`, installs `pyyaml` into it, and puts a two-line `relay2` launcher in `~/bin` that runs this directory's code with that environment, so nothing is installed into your system Python. The launcher points back into this directory, so keep it where it is. `RELAY2_PREFIX` moves the launcher and `RELAY2_PYTHON` picks the interpreter. `./install.sh --uninstall` removes the launcher and the environment and leaves your messages alone. With pipx instead, from the same folder: ```bash pipx install . export RELAY2_CONFIG="$PWD/config" # the example policy files; omit for built-in defaults ``` Data root is `~/.relay2`, overridable with `RELAY2_ROOT`. It is created on first use. Config is `config/*.yaml` next to the source, overridable with `RELAY2_CONFIG`; a missing file falls back to built-in defaults. Nothing else needs installing: no daemon manager, no service file, no dependency beyond `pyyaml`. Run the watcher, which is the only long-lived process: ```bash relay2 watch --interval 20 # foreground nohup relay2 watch --interval 20 >> ~/.relay2/logs/watch.log 2>&1 & ``` One watcher at a time. A second one refuses the lock rather than doubling every ping. ### Environment, and what is optional Everything below is optional. Unset, the relay accepts messages, stores them durably, files them into inboxes, indexes them, ages them and dead-letters them. What it does not do unset is announce anything into a terminal, because nothing on a stock machine knows how to do that. | variable | default | what it buys | |---|---|---| | `RELAY2_ROOT` | `~/.relay2` | where the whole tree lives | | `RELAY2_CONFIG` | `config/` beside the source | policy files | | `RELAY2_ROLES_DB` | `$RELAY2_ROOT/registry.db` | a SQLite database with a `roles` table and a `role_name` column. Present, unknown recipients are refused at send. Absent, they are accepted with a note, because a registry that cannot be read must never reject every message in the system | | `RELAY2_SURFACE_REGISTRY` | `$RELAY2_ROOT/sessions.jsonl` | JSONL rows carrying `chat` and `role`, so a window name resolves to a role | | `RELAY2_IDENTITY_CMD` | unset | a command printing a JSON array of `{role, window, current}` for the sessions live right now. Unset means the roster is unknown, which is treated as unknown and never as nobody home | | `RELAY2_ATTACHMENT_ROOTS` | unset | extra absolute roots an attachment path may sit under, `PATH`-separated. The relay root is always allowed | `delivery.command` in `config/relay.yaml` is the announcement transport, argv with `{role}` and `{text}` substituted, never a shell string. It ships empty. The three `config/*.yaml` files ship as a worked example with placeholder roles (`builder`, `verifier`, `author`, `labeler`, `triage`, `security`, `hub` and a few more) and routing rules. Replace them with yours; nothing in the code knows any of those strings. ## What a message is A JSON document, written once, never mutated. It lives in `messages/originals/YYYY/MM/.json` and everything else in the tree points at it. Ids are ULIDs: sortable by creation time, filesystem safe, minted by software, never reused. Prefix matching works when it is unambiguous and refuses when it is not. Delivery and notification are different things. A message is durably in a recipient's inbox whether or not anything was printed into that recipient's terminal. The ping only says: something is here, roughly this, open it with this. It is capped at three lines and 300 characters. The lifecycle is eight states and they are not collapsed into "sent": ``` created accepted delivered notified read acknowledged resolved archived ``` Read is not acknowledged. A role can read something without taking responsibility for it, and take responsibility without having finished. ## The floor: nothing waits forever The SLA clock starts at **accepted**, never at the ping. This is the one design decision worth reading twice, because the other way round fails invisibly. A sweep that starts from the ping timestamp has to skip any message that has none, and the ping timestamp is only written when a ping was delivered. So a recipient that is not running is never pinged, its messages can never escalate, and every instrument still reports healthy while they wait. Reachability is a property of the recipient. The obligation is a property of the message. So here: | age since accepted | what happens | |---|---| | `escalate_after_minutes` (30) | urgent and high escalate to the hub, once | | `floor_minutes` (60) | **any** priority escalates to the hub, once, whatever the recipient's state | | 2x the SLA | marked stalled | | `max_wait_minutes` (1440) | moved to `dead-letter/undeliverable`, hub told, canonical record kept | **When it breaks, does it fail toward holding or toward delivering?** It fails toward holding - a message is never dropped to make a queue look clean, and suppression only ever changes what is said, never what is kept. But holding is bounded. Holding with no end makes a held message and a lost one look identical. Here every hold has a deadline and the deadline routes to a role that can act. ## Addressing A message goes to a **role**, resolved at send time. Three things get confused with each other: - `ops` is a role. It owns an inbox. - `ops-4` is an instance title. It addresses a terminal, and no role by that name will ever exist. - a conversation id is identity, and it changes when the session changes. `relay2 send --to ops-4` resolves through the name, the name with a trailing `-N` removed, and then the surface registry, and **rejects at send** if none of those is a role. Accepting that address unresolved leaves the message nothing to do but age out. Resolving an address when someone finally reads it is resolving it too late to reject it. If the role registry cannot be read, the recipient is accepted unverified and the run says so. A broken instrument must never become a system-wide rejection. ## Backpressure Per-recipient state is computed from the queue, not declared by the recipient: ``` normal notify normally busy 6+ unread: urgent, high and normal announced; low batched congested 15+ unread, or 20 in five minutes: only urgent announced critical 40+ unread: only urgent and high offline inbox only, no terminal injection, waiting_for_session ``` Nothing is dropped in any mode. Durable delivery is never rate limited; only the noise is. One ping per recipient per cycle, never one per message. Measured, 2000 messages from 42 roles across 10 projects all aimed at one coordinator (`tests/regression42.py`): ``` 2000 accepted, 0 lost, 0 duplicated, 0 left unprocessed 1,262,120 body bytes carried 1 terminal ping for the coordinator, 1,112 characters injected in total injected / carried = 0.00088 ``` That ratio is the number this architecture is judged on. ## Projection Rules in `config/routing.yaml` add recipients, and every added recipient carries the rule that added it: ```yaml projection_rules: - name: validation_failure_to_triage match: {type: validation_result, validation_status: failed} add_recipients: [triage] ``` Projected recipients land in their own bucket, never in the sender's list, so nothing a rule chose can later be read as something the sender chose. Depth is capped at 1, so a rule that matches its own output cannot fan out. A rule with an empty match block matches nothing - the config nobody has finished writing must not be the one that projects everything. Broadcast requires explicit permission and `allowed_senders: []` means nobody. ## Types and payloads A type may set defaults and require payload fields (`config/routing.yaml`). A default applies only where the sender left the field alone; a type policy never overrides an explicit choice. A message missing a required field is rejected with that field named. Prefer structure over prose where the information is structural: ```bash relay2 send --to builder --type validation_result --priority high \ --subject "checkout suite failed 5 of 144" \ --payload '{"status":"failed","checks":144,"failed":5}' \ --body-file detail.md ``` Nobody should have to reverse-engineer prose to discover `failed = 5`. ## Messages are events, not knowledge Send the change, the exception, the decision, the action. Point at canonical memory for the rest: ```bash relay2 send --to verifier --ref docs/architecture/README.md \ --subject "Retry policy changed" \ --body "Now bounded exponential backoff. Canonical file in --ref." ``` Do not turn an inbox into a second memory system. ## Digests and metrics Both are arithmetic over the index. No model call. ```bash relay2 digest --since 15m # counts, lifecycle, threads, open requests relay2 metrics --since 24h # transported versus consumed relay2 status --role self # what is still open before you stop ``` `metrics` reports `body_bytes_carried` against `body_bytes_opened`, and `bodies_never_read`. A 12 KB report nobody opened is visible as one. ## Search ```bash relay2 search "AMBIGUOUS_ROUTE" relay2 search --sender verifier --after 2h relay2 search --tag security --unresolved relay2 thread onboarding-144 --last 5 ``` FTS5 over subject, preview and body, plus structured filters. Nobody should be running `grep -R` across the relay tree. ## Repair The SQLite index is derived and holds nothing that is not recoverable. ```bash relay2 repair # replay anything a crash left in processing/ relay2 repair --rebuild # delete nothing, rebuild every index from the files relay2 dead list relay2 dead inspect relay2 dead retry ``` The test suite deletes the database outright and rebuilds it, which is the only way to know that claim is true rather than intended. ## Layout ``` relay2/ outbox/{pending,processing,sent,rejected}/ messages/originals/YYYY/MM/.json canonical, immutable inbox//{unread,read,archived}/ pointers, not copies receipts///.json one history per recipient dead-letter/{invalid,undeliverable,exhausted}/ state/{index.db,agents.json,watcher.json,relay.lock} logs/relay.jsonl ``` A 20 KB message is stored once. Six recipients get six pointers. ## Tests ```bash python3 tests/test_relay2.py # 42 tests: lifecycle, atomicity, dedup, # supersession, projection, depth, congestion, # traversal, rebuild, concurrency, the floor python3 tests/loadtest.py # 5000 messages, 50 senders, kill at each stage python3 tests/regression42.py # the 42-role coordinator regression ``` Run them with a Python that has `pyyaml`; after `./install.sh` that is `~/.relay2/venv/bin/python`. Everything runs against a throwaway root with a stubbed terminal. Nothing touches a live inbox, nothing is sent to a window, and no model is called. Three defects were found by these harnesses rather than by review, and each is now pinned by its own test: - a rejected message was left in `processing/` and re-rejected on every pass; - a watcher killed between the index write and the pointer write left a message indexed, undelivered, and permanently skipped by replay, because "already indexed" was read as "nothing to do"; - a UTC timestamp read through `time.mktime` was an hour out under DST, so every message was past the floor the moment it was accepted. ## Requirements Python 3.9 or newer (tested on 3.9.6 and 3.14), and `pyyaml`, which the installer puts in its own environment. Nothing else: no daemon manager, no service file, no database server, no network access after install, no account anywhere. Written and tested on macOS. The design is POSIX and the one platform assumption is that `os.rename` is atomic within a single filesystem, which is where every crash-safety guarantee here comes from. ## What it does not do **It is not a transport.** It never opens a socket, never talks to a terminal and never raises a window. Announcing a message is one configured argv, `delivery.command` in `config/relay.yaml`, and relay2 reimplements none of what sits behind it. The text is passed as a single argument and never interpolated into a shell string, because a subject is sender-controlled data and must not become a command. Ship it empty and messages still arrive; they are just never announced. **It is not a chat system.** A message is an event with a lifecycle, written once and never mutated. Nothing here renders a conversation or holds a session open. **It calls no model.** Routing, projection, backpressure, digests, SLA and escalation are arithmetic over the index. That is the design claim rather than an optimization: the numbers are already in the database, and spending a model call to recover them from prose is the waste this exists to remove. **It is one machine and one filesystem.** Every guarantee rests on an atomic rename within a single filesystem. A relay root on a network mount is outside what the tests cover. **It does not know who your roles are.** The three `config/*.yaml` files ship as a worked example with placeholder roles; nothing in the code knows any of those strings. Replace them. --- MIT licensed, see `LICENSE`. Part of the [auto.relay70](https://auto.relay70.dev/) automation registry.