relay
Session operating system for owner-gated, agent-executed development. Fail-closed session gates, an append-only paper trail, and one protocol across many projects.
npm install --save-dev @atlanticus-ai/relay
npx relay init # scaffold: slash commands, config, session docs (never overwrites)
npx relay verify-open # every open — the first one detects itself, no flag
npx relay selftest # did the install work? one line per check, exit 0/1
Then every session: npx relay verify-open → work → npx relay verify-close.
Why
An agent that can commit, push and release needs gates that fail closed and a paper trail it cannot quietly skip. Relay is that: a session opens against a machine-checked handoff, closes against a second gate, and records what actually happened — verbatim gate output, scored objectives, token cost — in files that live in your repo, not in a chat log.
Owner gates are absolute. The agent may implement, commit and propose; git push, git tag, version
bumps and marking a ticket done belong to a human, every time.
Commands
relay verify-open session-start gate (fail-closed) relay site build the static site
relay verify-close session-close gate relay compile compile paper-trail fragments
relay tokens token accounting for the handoff relay doctor --container container probe
relay session-log append a session lifecycle record relay docs-check documentation debt
relay log append a dispatch/footer record relay init scaffold into a repo
relay cp copy the handoff block to the clipboard
Everything is configured by one relay.config.json in the target repo. relay --help lists the rest.
Layout
RELAY-CORE.md— the protocol (15-min read; new adopters start here). Ships with the package, so a consumer always has the version of the protocol its tooling implements.kit/— the tooling. Zero project-specific strings; every project fact comes fromrelay.config.json. Node ≥18, one runtime dependency (micromatch).db/·query/·ui/— the optional Relay index: Postgres projection of the paper trail and a local read-only dashboard. Not packaged —npm installgets you the gates, not a database.attic/sync-relay.sh <target>— the legacy file-copy vendoring path, moved toattic/at RL-D014 and kept as an INERT BACKUP (it is the revert if a migration fails), used until the existing consumers migrate to the package. It translatesnpx relay …back to vendored script paths on the way out.
Running the tests
npm test the full suite — relay-site fixtures, gate smoke, kit fixtures, d13
npm run test:fast the per-commit subset (never writes or clears the pass stamp)
npm run test:e2e the browser suite — Playwright, Chromium, two viewports
⛔ test:fast and npm test are not interchangeable, and the difference is the stamp. Only the full
run writes test/LAST-PASS.json, which verify-close's tests_fresh assert reads — so a close backed by
fast alone is a claim about a suite that did not run. Run fast after each commit inside a dispatch and
npm test once, before composing the handoff.
The browser suite
npm run test:e2e builds ui/, serves it, and drives real Chromium. No database is opened — every
/api/* response is served from a fixture by test/e2e/mock-api.ts, which is strictly stronger than
merely avoiding relay_live.
Every spec runs once per viewport, from test/e2e/viewports.ts:
| project | size | Schedule drawer |
|---|---|---|
1280x800 | 1280 × 800 | yields — the table shrinks (≥ lg) |
768x800 | 768 × 800 | covers — the drawer sits over the table (< lg) |
⚠️ These are not "desktop and mobile". They are the exact viewports the owner verified F1 and F2
at, which is what makes a red here mean the same thing as a red on their screen. 800 is deliberately
short — a taller window would hide F2 entirely. ⭐ Adding a width adds a ROW here, not a test file;
a spec never names a number, it asks viewportOf().
Run one project or one spec:
npx playwright test --project=1280x800
npx playwright test test/e2e/f3-raw-body-never-rendered.spec.ts
Fixtures — two directories, and the distinction is load-bearing
| directory | what it holds |
|---|---|
test/fixtures/api/ | REAL CAPTURES — bodies taken from the live database |
test/e2e/fixtures/ | HAND-BUILT payloads, each carrying a _note saying so at its own site |
⛔ Do not put an invented payload in test/fixtures/api/. Its name is a claim about provenance that
other things rely on: test/no-mjs-references.test.ts excludes that directory precisely because captures
legitimately quote retired paths, and an invented file would inherit an exemption it has not earned.
Hand-built lives there for one of two reasons, both recorded in the file:
- the endpoint cannot be captured —
/api/sessionsreturns500 column "track" does not existwhile migration 034 is unapplied (an owner gate), and/api/session-promptswas never captured; - the payload is a probe —
test/e2e/fixtures/portfolio.json.
⚠️ The three prompt fixtures deliberately carry raw_body NEXT TO text, and the two are deliberately
different. That is the premise of f3-raw-body-never-rendered.spec.ts: against a payload with no
raw_body, "the body is not on the page" passes identically whether the surface refused to render it or
there was nothing to render. ⭐ A leak with no raw_body identifier in the source — a {...row} spread, a
debug <pre> — is invisible to the static guard in test/ui-guardrails.test.ts and caught by this
spec; that gap is the reason it exists.
⛔ An unmapped /api/* route is a 501 naming the route, never an empty 200. A blank success would
render as "no data", and a surface that looks healthy and empty is the exact failure the harness is meant
to catch.
CI
.github/workflows/ci.yml runs two jobs:
test suite (relay-site + verify-open + d13)—npm testagainst a Postgres service rebuilt bydb/fixtures/d13.ts, then the coverage ratchet, thenverify-openas a gating step (see ADR 0014). The asserts CI cannot satisfy are declared inrelay.config.jsonunderverifyOpen.ciSkipAsserts, print as– skipped in CI: <reason>, and arena— neverpass.e2e (chromium, 1280x800 + 768x800)— the browser suite, in its own job, and it gates: no|| true.
Publish Check (the deploy gate)
Before Scriptorium is built, every archived dispatch and return is scanned for secrets, and a hit
refuses the deploy — it names the file and the rule and never the match. It detects and
refuses; it never edits a file. The gate lives inside query/build-corpus.ts, which is the first half
of vercel.json's buildCommand, so a non-zero exit stops the build before the UI is built.
Config is relay.config.json → publishCheck. Run it yourself with node query/build-corpus.ts --census.
⛔ If Publish Check refuses a deploy, the instructions are docs/PUBLISH-CHECK.md —
the refusal output ends with that path, because a control nobody remembers how to operate gets
bypassed (DN9-D104).
This repo is Relay-governed
It carries its own relay.config.json, ROADMAP.md, WAYS-OF-WORKING.md and docs/sessions/ paper
trail, and runs the kit's own gates on itself:
npx relay verify-open # the same command a consumer runs — the bin resolves locally either way
npx relay verify-close
npm test
Session ids here are RL-D<n>-P<seq>. Legacy RELAY-D* / CF-D* ids in CHANGELOG.md are a different,
historical series — see WAYS-OF-WORKING.md § 2.1. Start with
WAYS-OF-WORKING.md and docs/sessions/SESSION-HANDOFF.md.
⚠️ Never run
attic/sync-relay.shagainst this repo. It vendorskit/into a consumer; this repo is the source. Pointed here it would overwritekit/with a copy of itself and stamp a consumerrelayVersion. The script REFUSES a self-target in code (TD-RELAY-SYNC-SELF-GUARD, shipped RL-D008) — this paragraph previously said no guard existed, which stopped being true at RL-D008 and was corrected at RL-D014. The refusal is asserted from theattic/path intest/distribution.test.mjs. The standing obligation SO-KIT-IS-SOURCE was RETIRED at RL-D015 once both consumers of the day (HZ + CF, the only two that existed then) moved to npm — the prohibition above is unchanged and the in-code guard stays; what ended is the re-confirm-every-close ceremony for a mechanism nobody uses any more. The script itself is archived, not deleted.
Vendor into a project
./attic/sync-relay.sh --dry-run ~/path/to/project # preview
./attic/sync-relay.sh ~/path/to/project # apply (target must be clean)
Then ensure the target's relay.config.json has the required keys (see kit/CLAUDE.md.skeleton
and RELAY-CORE.md § Config): projectPrefix, ownerName (required), docsCheck, etc.
relay-site (static site generator)
Turns a project's ROADMAP.md + docs/relay/ session artifacts into a static site. Opt-in: add a
site block to the target's relay.config.json. No site block → the script exits 0 with
"not configured" and writes nothing, so it is inert in every project that has not asked for it.
npx relay site # or: make site
npx serve docs/site # generator does NOT serve — preview separately
Design brief, config schema, and the ROADMAP.md convention the parser expects:
docs/relay-site.md. Tests: ./test/run-tests.sh (synthetic fixture; no
real project is touched).
Dependencies
The kit is vendored by file copy, not installed by a package manager — so a target repo receives
the scripts without receiving what they import. kit-dependencies.json declares the gap:
| Dependency | Needed by | If missing |
|---|---|---|
Node >=18 | all kit scripts | ESM + top-level await; fetch/AbortController in verify-close.mjs |
micromatch ^4.0.8 (runtime) | kit/docs-check.mjs | docs:check cannot run at all — the import throws before any check executes |
sync-relay.sh reads this file and checks the target after vendoring, printing the exact install
command for anything unmet. It reports only — it never installs, and never edits the target's
package.json. Adding a dependency to a target repo is that repo owner's decision, not the sync
script's.
Everything else the kit imports is a node: builtin.
The two channels classify micromatch differently, and both are correct. A vendored consumer
receives readable kit/docs-check.mjs, which imports micromatch at module load — so for that channel
it is a genuine runtime requirement, which is what kit-dependencies.json declares. The published
npm package ships a bundled build with micromatch inlined, so it has no runtime dependency at
all; there micromatch is a build-time tool and sits in devDependencies. The published tarball's
third-party dependency count is zero.
Distribution and licence
Relay is distributed under a proprietary licence — all rights reserved. See LICENSE.
It is not open source, and the ISC declaration that appeared in package.json before v1.6.11 was
a placeholder that never matched any licence text.
⚠️ The current
LICENSEtext is a DRAFT pending trademark and licensing review by counsel (RL-D010). It must be cleared before the first publicnpm publish.
The published package is a bundled, minified build produced by npm run build from kit/ into
dist/. Source is not distributed and source maps are never emitted — build.mjs sets
sourcemap: false and both the build and the test suite fail if a .map file or a sourceMappingURL
string reaches the output. A source map would ship the entire readable source and silently undo this.
Some files ship readable by design, each for a stated reason (see VERBATIM in build.mjs):
RELAY-CORE.md (the protocol is meant to be read), the four commands/*.md, CLAUDE.md.skeleton,
Makefile.template and settings.presets.json — all of which relay init writes into your
repository, where being readable is the product rather than a leak.
The package runs no code on your machine at install time — ever. There is no postinstall, and no
preinstall, install, prepare or prepublish either. relay init writes into .claude/, the
directory an agent takes its instructions from, so it must stay a typed command: a package that
edits agent instructions as a side effect of npm install could change an agent's behaviour during an
unrelated dependency bump. The test suite asserts every install-time hook is absent.
prepublishOnly is the deliberate exception, and it is not the same class. It runs
node build.mjs && npm test on the publisher's machine during npm publish — never on a
consumer's, and it is not even present in the published tarball's execution path. It exists because
"remember to run the build before publishing" is a manual step guarding the difference between
shipping the current code and shipping a stale dist/, and manual steps of that shape have already
failed here (the version-file sync incidents). Mechanised, it cannot be forgotten. See
docs/npm-deprecation-policy.md § Lifecycle hooks for the full
install-time/publisher-side split.
If a bad version ships
Corrections go forward, never backward — see docs/npm-deprecation-policy.md.
npm restricts unpublishing after 72 hours, and unpublishing breaks every install that already resolved
to that version. A bad publish is fixed with a patch release plus npm deprecate on the bad
version, not by withdrawing it.