Menu

Contact the founders

Bugs, feedback, or something the docs did not answer — arnav@relayevals.com. We read every message.

Documentation · v0.6.70

Prove the tests ran before you merge.

Relay does not read your code and judge it. It answers one question: is there signed evidence that the tests ran, and did they cover the lines that changed?

This is the reference. If you have not run Relay before, start here instead — two lines of shell, about a minute, and everything after happens in the session.

Security advisory · 2026-08-08 · fixed in 0.6.0

A receipt could be bypassed by an in-place edit

Every version before 0.6.0 was affected, 0.2.0 through 0.5.0. A receipt is documented to go stale once the worktree moves. It did not: an edit that changed a file’s contents without changing which files changed left the binding intact, so code introduced after the evidence was signed still returned PASS. No policy setting closed it.

Fixed in 0.6.0. Receipts now record a content hash for every changed surface at signing time, and the verdict compares them. Upgrade with the install command below and check relayevals --version.

Upgrading invalidates existing receipts, and that is the fix rather than a side effect of it — every earlier receipt certifies a command ran against a set of changed paths, not against the file contents in front of you. You will see RECEIPT_PREDATES_SNAPSHOT_BINDING, deliberately not RECEIPT_SNAPSHOT_STALE: that code means “you edited after running”, and you did not. One relayevals next per repository clears it.

§ 01

Install

macOS and Linux. No sudo, nothing outside your home directory. On Windows, use WSL2 and follow the Linux path — the installer is a bash script and there is no native Windows build.

curl -fsSL https://relayevals.com/install.sh | bash

If Node is missing the installer downloads a pinned, sha256-verified Node 22 and uses it — you do not need to install anything first. git ≥ 2.30 is the only prerequisite.

Check the bytes first

Piping a URL to bash executes whatever that URL returns. If that bothers you — it reasonably should — verify before running:

curl -fsSL https://relayevals.com/install.sh -o install.sh shasum -a 256 install.sh wc -c < install.sh

For 0.6.70 that must be exactly:

v0.6.706b1af0c43f0370a6fed6cfe7ff5fdcc197b58e4e96a145f160657f0794ebe45c 33419

Check the byte count as well as the hash. A truncated download hashes to a stable, plausible, wrong value — the count tells you why immediately instead of leaving you suspecting a bad mirror.

Where it goes

~/.relay/relayevals-0.6.70/ built source ~/.relay/node/ bootstrapped runtime, if one was needed ~/.local/bin/relayevals wrapper on your PATH

Set RELAY_HOME to relocate the root. Reinstalling is idempotent and upgrading is the same command. Nothing is written into your repositories.

Confirm it worked

relayevals --version # 0.6.70 relayevals doctor env

Alpine and other musl systems are refused up front with exit 78. TypeScript 7 ships a native compiler and publishes no musl build, so a source install cannot work there — saying so in two seconds beats failing after a 40 MB download.

§ 02

See the problem first

Before configuring anything. try detects your test runner and coverage tool, runs your suite once, and reports which of your changed lines no test executed. It needs no setup and writes nothing.

relayevals try
4 of your 12 changed files are executed by no test src/api/upload.ts 58 changed 0 executed src/lib/rate-limit.ts 31 changed 0 executed Your suite passed. These lines never ran.

Exit 1 means it found changed lines nothing executes. If your project has no coverage tool, try says exactly which one to install for your stack rather than guessing a number.

§ 03

Set up a repository

Once per repo.

cd your-repo relayevals setup --profile local git add relay.policy.json && git commit -m "chore: add Relay policy"

relay.policy.json is committed — it is shared team configuration and CI cannot read it otherwise. .relay/ is not; setup adds it to your .gitignore, because receipt run writes there and an untracked .relay/ would make every receipt stale the instant it was signed.

Then confirm you are ready:

relayevals check --format json

check reports every remaining gap as next_actions[] with a runnable command — the same shape verdict uses.

§ 03

The loop

Hand this to your agent. It is the entire integration — one command, and the agent never has to choose between them.

This repo uses Relay for merge verification. Loop: 1. Run: relayevals next --format json 2. Do exactly the returned action. Nothing else. 3. Repeat until state is PASS, READY, or NEEDS_HUMAN. Never edit relay.policy.json. Never report a task complete unless Relay's state is PASS.

next resolves the repository into exactly one state and returns exactly one allowed action — including on a repository it has never seen, where the action is relayevals setup. The agent needs no other knowledge of Relay.

relayevals next --format json
{ "schema": "relay.next/1", "state": "UNRESOLVED", "code": "EVIDENCE_MISSING", "exit_code": 20, "reason": "3 changed executable line(s) have no evidence yet. Nothing is known to be wrong — there is not enough evidence to say PASS.", "action": { "code": "RUN_RECEIPT", "description": "Run: relayevals receipt run --name tests --kind test …", "then": "relayevals next --format json" }, "forbidden": ["EDIT_POLICY", "HANDCRAFT_RECEIPTS", "DECLARE_DONE"], "attempt": { "soft": 1, "hard": 1, "soft_limit": 3, "hard_limit": 10 }, "changed_lines": { "total": 3, "verified": 0 } }

Exit codes are the contract an agent branches on: 0 PASS / READY / SETUP_NEEDED, 10 BLOCK, 20 UNRESOLVED, 30–39 environment, 40 NEEDS_HUMAN.

If the same finding survives three identical attempts — or ten in total — Relay stops the loop and hands control to you rather than letting an agent burn tokens on it. relayevals explain says why, in plain language, with no agent involved.

Hand it back: relayevals fix

On a BLOCK, Relay prints the evidence behind the verdict and the exact condition that clears it — ready to paste into a fresh agent session. The same text is on the fix_prompt field of the JSON.

RELAY · fix prompt (copy everything below to your agent) ──────────────────────────────────────────────────────── Relay blocked this change. Here is the evidence: • src/pay.js · 3 of 4 changed line(s) never executed (lines 5-7) • receipt "test" (npx c8 --reporter=lcov npm test) exited 0 and covered 4 line(s) • policy requires measured coverage of all changed executable lines (relay.policy.json, committed) Your task: 1. Add or update the smallest test that actually executes the changed code named above. Do not modify unrelated code. 2. Do not edit relay.policy.json. Do not write, edit, or fabricate receipts or coverage reports. 3. Produce evidence: relayevals receipt run --name tests --kind test … 4. Continue the loop until the state is PASS: relayevals next --format json Resolved when: the changed lines above are executed by the evidence and SURFACE_UNTESTED disappears. ────────────────────────────────────────────────────────

No model is involved — this is templated from the measurement Relay already made. Your agent has the code; Relay contributes the two things only it has: what the evidence actually showed, and what would clear the finding. Reading it never advances the attempt counters.

By hand

The whole product works without an agent. guard runs your suite, signs the receipt, and decides, in one step:

# finish editing, THEN ask for a verdict — one command relayevals guard "add rate limiting to /api/upload" -- npm test

The two-step form still works and is exactly what guard does internally:

relayevals receipt run --name tests --producer local-default --kind test \ --surface 'src/**' --coverage lcov:coverage/lcov.info -- npm test relayevals verdict "add rate limiting to /api/upload" --format json

verdict reads the newest receipt of each kind from .relay/receipts on its own. Do not pass a tests-*.json glob — it takes one positional task argument, so a glob matching two receipts fails with too many arguments.

The exit code is the answer: 0 PASS, 1 BLOCK, 2 UNRESOLVED.

edit → receipt run → verdict ✓ receipt run → edit → verdict ✗ RECEIPT_STALE edit WHILE receipt runs ✗ RECEIPT_SURFACES_MUTATED

The receipt binds to the worktree as it was during the run. Edit a file afterwards and it stops counting. Evidence last, always.

Every state next can return

Branch on state and code, never on prose. Each state also has a page of its own at relayevals.com/docs/states/<code>.

stateexitwhat it meanswhat to do
PASS0evidence exists and covers the changereport it verbatim; you are done
READY0configured, nothing changed yetstop — terminal for the loop
SETUP_NEEDED0never configuredrun relayevals setup
BLOCK10a finding needs workdo the returned action, or relayevals fix
NO_TEST_RUNNER10no runner Relay can driveadd a test script, then retry
UNRESOLVED20not enough evidence to deciderun the returned receipt run
NOT_A_REPO30not inside a git repositorystop; tell your human
SETUP_PARTIAL31configured, but not finishedrun relayevals setup again
SESSION_LOCKED32another Relay run holds the lockwait and retry
GRAMMAR_MISSING33no grammar for this languagerelayevals grammar install
UNSUPPORTED_PLATFORM34Relay cannot run herestop; tell your human
NEEDS_HUMAN40only a person can clear thisstop and say why

PASS, READY and NEEDS_HUMAN are the three states that end a loop. A missing signing key arrives as NEEDS_HUMAN carrying the code IDENTITY_MISSING — it is a code, never a state, so do not branch on it as one.

Two commands, two schemes. The numbers above belong to next, which reports the state of the loop. verdict and guard report the decision instead: 0 PASS, 1 BLOCK, 2 UNRESOLVED. Their failure codes are not the same as each other — verdict distinguishes usage (64), evidence and IO (74) and a malformed policy (78), while guard reports 70 for every runtime failure without exception. 70 is the catch-all in both and the one you will see most. An agent in the loop branches on next; CI gates on whichever command it runs.

The same thing, for a person

next answers an agent. explain answers you — the same state, walked through in plain language, with no JSON and nothing to parse.

relayevals explain

§ 04

Produce evidence

receipt run executes your command verbatim, hashes both output streams, binds the result to the current git snapshot, and signs the envelope. Everything after -- is yours.

relayevals receipt run \ --name tests \ --producer local-default \ --kind test \ --surface 'src/api/**' \ --coverage lcov:coverage/lcov.info \ -- npm test
FlagMeaning
--nameLabel, used in the receipt filename.
--producerWhich signing identity to use.
--kindcommand, test, typecheck, lint, coverage, build, security, inventory, custom. Policy’s mandatory_kinds matches on this.
--surfaceGlob this run vouches for. Repeatable.
--coverageAttach a real coverage report. See § 05.
--timeoutWall clock in ms. Default 300000.
--set-env, --inherit-envChild environment. Everything else is sanitized.

Without --surface the receipt still proves the command ran — it just vouches for no particular file, so every changed file comes back SURFACE_UNCOVERED.

§ 05

Measured coverage

This is the feature that makes the gate real. Without it, --surface src/api/upload.ts is simply believed — you could run a suite that never touches the file, declare it, and get a PASS.

--coverage lcov:coverage/lcov.info # jest, vitest, c8, nyc, coverage.py, # cargo-llvm-cov, JaCoCo --coverage go-coverprofile:cover.out # go test -coverprofile

The report is read after your command exits and folded into the receipt before signing, so you cannot edit it afterwards. Two things follow:

  • Your test command must write the report during this run. A report left from an earlier run is recorded with generated_after_command: false and does not count. Stale evidence that looks fresh is worse than none.
  • Only the changed lines must be hit. Not a percentage, not the whole file.

What setup writes. A new repository gets coverage_evidence: "measured" — a declared surface is checked against a real coverage report. setup never rewrites an existing relay.policy.json, so a repository that predates this setting keeps what it had, which for a policy written before 0.5 means declared. That is the upgrade-safety rule, and it applies to existing repositories only. Check yours with relayevals check, which names the mode every time it runs.

Under declared a --surface claim is believed rather than checked, so a change no test exercises can still reach PASS. A repository with no policy file at all is treated as declared for the same reason — Relay will not invent a stricter gate than you asked for. If check tells you the repository is in declared, that is the one field worth changing — see § 10.

And coverage_enforcement. The default is "warn", which makes a coverage gap UNRESOLVED rather than BLOCK: Relay is saying it could not prove the change ran, not that the change is wrong. Set it to "block" when you want the gate to stop merges.

This matters in CI. Under the default, a coverage gap exits 2, so a job written to fail only on exit 1 lets it through. Fail on anything other than 0.

§ 06

Read the verdict

relayevals verdict "<task>" --receipt <paths...> --format json

In a loop, ask only for what you will act on — you pay tokens per iteration:

relayevals verdict "<task>" --receipt <paths...> \ --format json --only next_actions --max-items 5

--only accepts next_actions, why, requirements, evidence, changed_surfaces, verdict, decision_hash.

Branch on code, never on reason

{ "code": "SYMBOL_UNTESTED", "reason": "src/api/upload.ts rateLimit() lines 42-58 changed, 0 of 14 executed", "commands": ["relayevals receipt run … --coverage lcov:coverage/lcov.info …"] }

code is contract. reason is prose for a human reading your transcript and may be reworded in any release. commands are runnable, with <…> marking what you substitute.

An error is not a verdict

If the output’s schema is relay:error:v1, no decision was reached. Your work may be perfect and your invocation wrong. Fix the invocation — do not rewrite code in response to an error envelope.

Exit codes

0PASS — evidence exists and covers the change
1BLOCK — something about the change needs work
2UNRESOLVED — not enough information to decide
64Bad invocation
65Malformed input data
70Internal error
74I/O error
77Signing key is protected — see § 11
78Configuration or platform is unsupported

Verdicts are deterministic. Identical inputs give an identical decision_hash, so re-running without editing cannot produce a different answer.

§ 07

Verdict codes

Every code carries its own remediation record. You never have to hardcode this — ask at runtime:

relayevals verdict --explain SYMBOL_UNTESTED --format json

It returns meaning, clears_when (the condition, not a ritual), commands[], and blocks_the_change. Branch on that last field: false means the finding is about your invocation or is advisory, and rewriting working code in response is a wasted loop.

Your change needs work

SYMBOL_UNTESTEDWrite a test exercising the named symbol. The reason says how many of its changed lines already run — partial progress is real progress.
SURFACE_UNTESTEDSame, at file granularity. Your tests ran but never reached the changed lines.
SURFACE_UNCOVEREDNo receipt declares this file. Add --surface — only if your tests genuinely cover it.
RECEIPT_COMMAND_FAILEDYour tests failed. Reproduce with commands[0], fix, re-run. Re-signing does not help; the receipt is accurate.
RECEIPT_SNAPSHOT_STALEYou edited after running. Re-run receipt run.
RECEIPT_KIND_MISSINGProduce a receipt of the kind policy requires.
RECEIPT_SIGNATURE_INVALIDAltered, or signed by an unknown key. Do not touch the code.
RECEIPT_NOT_SUPERVISEDRe-run under relayevals supervise. See § 11.

Your invocation needs work

COVERAGE_MISSINGAdd --coverage. Do not touch your code — nothing is known to be wrong with it.
SEMANTIC_UNAVAILABLErelayevals grammar install <language>
CHANGE_TEST_ONLYYou changed tests and no source. Often fine — but check you did not weaken an assertion to clear a failure.

Stop

ATTEMPT_THRASHINGThree changes have failed to move the same finding. Report what you tried and what still fails, and ask for human review. This is not a rate limit — it means the approach is not working.

§ 08

Semantic analysis

Without it, every changed file demands evidence — including one where you only rewrote a comment or reran the formatter. That is noise, and noise in the loop is a work order for something that cannot break.

relayevals grammar install typescript python go rust

Once per machine. Optional: skipping it costs precision, not safety.

ChangeDemands evidence?
Comment or docstring onlyno
Formatting onlyno
Import reordering, no new moduleno
Statement, expression, control flowyes
Signature or exported symbolyes, flagged as a contract change
Anything unrecognisedyes — fail safe

Do not read an exemption as approval. It proves the change cannot alter behaviour, not that it is correct.

With a grammar installed, a BLOCK names the symbol rather than the file:

SYMBOL_UNTESTED src/api/upload.ts rateLimit() lines 42-58 changed, 0 of 14 executed

§ 09

Attempt chains

A single verdict cannot tell you two things you need: did my last change help, and did it break something else? Every verdict carries an attempt block that does.

"attempt": { "attempt": 3, "resolved_since_previous": ["SURFACE_UNCOVERED:src/api/upload.ts"], "introduced_since_previous": ["SURFACE_UNTESTED:src/api/other.ts"] }
  • resolved_since_previous — your last change worked. Continue.
  • introduced_since_previous — you broke something else. Weigh that before piling on.
  • Both empty and attempt climbing — you are not moving. Escalate before Relay tells you to.

Chains are keyed by task plus repository, so keep the task string identical across attempts. Changing it starts a new chain and discards the history. Re-running verdict without editing is polling, not thrashing — it does not count against you.

§ 10

Policy

relay.policy.json is the gate, committed to the repo. This is exactly what setup writes for a new repository — an existing policy is never rewritten, so upgrading keeps whatever you had. What the two coverage fields mean, and what they do to a first run, is in § 05.

relay.policy.json — as written by setup{ "schema": "relay:repo-policy:v2", "assurance_profile": "local", "coverage_evidence": "measured", "coverage_enforcement": "warn", "mandatory_kinds": ["test"], "require_snapshot_match": true, "require_semantic": [], "test_globs": [], "flag_test_only_changes": true, "thrash_threshold": 3, "require_supervised": false, "min_producers": 1, "trusted_producer_ids": [], "policy_id": "sha256:…" }
coverage_evidencedeclared believes --surface. measured checks it against the report and produces SURFACE_UNTESTED naming the exact lines. This is the setting that makes Relay worth running.
mandatory_kindsReceipt kinds that must be present.
coverage_enforcementWhat a coverage gap costs. block makes it BLOCK (exit 1), warn makes it UNRESOLVED (exit 2), off disables the check. Distinct from coverage_evidence, which decides whether coverage is checked at all.
require_semanticAn array of languages whose grammar must be installed, else UNRESOLVED — not a boolean. Empty means fall back to file granularity rather than refusing to decide.
test_globsWhat counts as a test file. Empty falls back to built-in defaults (**/*.test.*, tests/**, and similar), so an empty array is not the same as “no test files”.
flag_test_only_changesRaise CHANGE_TEST_ONLY when no source moved.
thrash_thresholdAttempts against one unmoved finding before ATTEMPT_THRASHING.
require_supervisedOnly accept receipts signed by a supervisor.

Do not loosen the policy to make a verdict pass. Weakening the gate you are being measured by is not completing the task.

§ 11

Accounts

The verification path needs no account and no network: relayevals try, setup, receipt and verdict all run offline, free and unlimited, permanently. A verdict makes no network call on any plan. The relayevals session is the exception — it signs in on first run.

relayevals login

Relay prints a code, opens your browser, and confirms back in the terminal. Nothing is typed into the terminal: the code appears here, you enter it in the browser, and the browser shows you which machine is asking before you approve anything.

relayevals login Opening browser… https://relayevals.com/device Enter code RLAY-7QK2-M4XB Waiting for authentication… ✓ Signed in as @you ✓ Producer claimed ✓ 142 previous receipts attributed (3 repositories) ✓ Team acme — team plan · owner Console: https://relayevals.com/teams/acme

What signing in buys. The receipts and verdicts already on this machine are attributed to your account retroactively — the count on that last line is the whole argument. History follows you across machines, and a team can verify each other’s receipts without exchanging keys. On a first machine with no history that line reads Receipts will attribute as you produce them instead.

What it does not change. Every verdict is still produced locally and offline. If login fails, or you never run it, the gate works exactly the same.

relayevals login --no-browser # prints the URL and code only, for SSH and containers relayevals logout # revokes the token server-side, then deletes it locally

If your signing key is protected

Under producer protect (§ 12) the key is encrypted at rest. Login does not prompt for the passphrase — unlock once and it is cached for this machine.

relayevals key unlock

This is the only command that reads a passphrase.

If login cannot reach the server

Couldn't reach relayevals.com. Relay works offline — logging in is optional. Retry: relayevals login

A network problem and a key problem are reported separately, so the message always names the one thing that actually went wrong rather than listing everything it could have been.

§ 11b

Teams

A team is what turns the gate from something you run into something your repositories enforce. Everything below lives in the console at relayevals.com/teams — the nav links it as Console.

What the plan actually gates

One thing, and it is worth being exact about: the CI signing credential. A repository registered to a team on the Team plan can exchange a GitHub OIDC token for a short-lived signing key, which is what puts a signed check on every pull request and fills the analytics and audit history. Everything else — the whole local loop, the drive standard in relay.policy.json, shared memory through your own git, verifying a teammate’s receipt — works on every plan, deliberately.

Inviting people

An owner mints an invitation on the members tab and sends it themselves. There is no email system in Relay and the console says so rather than implying a message is on its way: you get a link, a copy button, and a hand-off to your own mail client. Links expire in seven days.

Whoever accepts signs in with GitHub and lands in the team. They then run relayevals login on each machine they work from — that is what registers the machine and lets the rest of the team verify its receipts.

Seats and billing

Seats are developers, not dashboards. The count that bills is the one on the subscription, and the members tab shows it beside the number of people who have accepted so the two never drift silently. Billing is centralised with the owner: an owner opens the Polar portal from the members tab for cards, invoices, seat changes and cancellation; members see the plan and its renewal date and nothing they could accidentally change.

The org baseline

An owner publishes one policy on the policy tab, versioned and append-only. It is enforced at the gate. ci auth writes it beside the credential, and ci report compares your repository policy against it on every run: a repository that TIGHTENS the baseline is fine, one that weakens it fails with POLICY_DRIFT naming the field and both values. The check is a file comparison, so it keeps working during our outage.

Machines

Every signed-in machine appears on the members tab, and an owner can revoke one. Revocation is honest about its limits: it takes effect at the next credential refresh, and a certificate already cached on a machine stays acceptable to an offline verifier until it expires.

From the terminal

whoami names your teams, your role and your plan; /team answers the same question inside a session. If someone buys a plan while you are signed in, your machine keeps its cached claim until you ask for a new one:

relayevals login --refresh # no browser; re-reads your plan and teammates

§ 12

Privacy

Your code never leaves your machine. Relay reads your repository locally, runs your test command locally, and decides locally. There is no code upload, no diff upload and no remote analysis — a verdict makes no network call at all, on any plan.

Relay does send anonymous usage counts, so we can tell whether the product works: a per-machine identifier, the event name, the version and the platform. That is the whole payload. File names, paths, task strings, branch names and repository contents are not fields in the schema, so they cannot be sent by accident. Repository, when it appears, is a salted hash computed on your machine — it distinguishes two repositories without naming either.

Turn it off and nothing is sent:

export RELAY_TELEMETRY=0

0, false, off and no all work, and CI is treated as opted out on its own. Relay prints a notice the first time before anything is sent, rather than after.

Signed in changes who those counts belong to, not what they contain: the same events are attributed to your account so your history follows you between machines.

On a team plan, the gate’s decision for repositories you register is recorded so the dashboard and audit log can show it: the repository, the verdict, how many changed lines there were and how many were covered, and the finding CODES.

Codes and nothing else — SURFACE_UNTESTED, not the file it was about. The endpoint validates every finding against ^[A-Z][A-Z0-9_]{2,47}$, which cannot express a path: no lowercase, no slashes, no dots. The CLI drops the human-readable explanation before sending for the same reason, because that sentence may quote a filename and a database is the wrong place for it. So a team plan records what happened, never where.

§ 13

Protecting the key

Under a plain install the signing key is a file the agent can read, so a receipt proves only that something with the key made a claim. Encrypting it moves signing out of the agent’s reach.

relayevals producer protect --producer local-default

The key is encrypted at rest with scrypt and AES-256-GCM. The passphrase is read from stdin only — never an argument, never an environment variable, because both are readable from the process table by anything running as you.

After that, receipt run exits 77 outside a session. That is not a failure. Start the agent inside a supervisor:

printf '%s' "$PASSPHRASE" | relayevals supervise --passphrase-stdin \ --producer local-default -- <your agent command>

Inside it, receipt run is invoked exactly as normal — it routes to the supervisor over a unix socket automatically. Nothing about the commands changes. An agent that sees exit 77 should report that it needs to be started under supervise, not go looking for the key.

§ 14

Signed verdicts

A verdict artifact records a decision. Signing it with the authority key records who accepted that decision — an unsigned artifact proves nothing about approval.

relayevals artifact verify .relay/verdicts/<id>.json

Verification runs three independent checks:

  • The Ed25519 signature over the canonical bytes.
  • The decision_hash, over the decision core — so timestamps and rendering options cannot change it.
  • Re-derivation of the verdict from the artifact’s own embedded evidence. This one needs no key, and it is what makes an artifact checkable by someone who does not trust you.

§ 15

CI

Do not use the interactive installer in a pipeline. There are two ways to give CI a signing identity.

Self-managed key — any CI, no account

The key and its authority both come from your secret store:

relayevals setup --profile ci --non-interactive \ --producer-key-env RELAY_PRODUCER_KEY \ --authority-public-key "$RELAY_AUTHORITY_PUBLIC_KEY"

This means a long-lived private key sits in your CI secrets, shared across the team and rotated by hand. It works, and for a solo repository it is fine. The section below removes that key entirely.

No stored key — GitHub Actions

On GitHub Actions nothing needs to be stored. GitHub proves the workflow’s identity with an OIDC token, Relay verifies it against GitHub’s published keys, and issues a signing credential that expires in fifteen minutes and is bound to that one run.

.github/workflows/verify.ymlpermissions: contents: read id-token: write # lets the job request an OIDC token steps: - uses: actions/checkout@v4 with: { fetch-depth: 0 } - id: auth run: relayevals ci auth - run: | relayevals setup --profile ci --non-interactive \ --producer-key @${{ steps.auth.outputs.producer-key }} \ --authority-public-key @${{ steps.auth.outputs.authority-key }}

ci auth writes the credential to $RUNNER_TEMP/relay-ci, which the runner deletes between jobs, and exposes the path as a step output. Nothing goes in repository secrets — not long-lived ones, not short-lived ones. Credentials are revocable from your team dashboard, and relayevals ci report --verdict-file <path> sends the decision back as counts and codes.

Three things must be true first, or ci auth answers 403 and the message will not tell you which — the exchange keeps its failures undifferentiated on purpose, so a stranger cannot map your estate by probing it.

  • The repository is registered to a team — an owner does that in the console, at relayevals.com/teams → repositories.
  • That team is on the Team plan, and the plan has not lapsed.
  • The workflow requests the token for this server. The audience must match the origin you are exchanging against — https://relayevals.com unless you are running your own.

Do the changed tests depend on the change?

A verdict says every changed line executed under signed evidence. It does not say anything checked the result. A test that calls the changed function and asserts nothing satisfies the gate completely — and it is the first shape an agent optimising for green will find.

red-first is the measurement that separates the two. It rebuilds your repository as it was before the branch, keeps your new tests, and runs them. Tests that genuinely cover a change fail there. Tests that pass there proved nothing, and the coverage they produced was execution without verification.

relayevals red-first --base origin/main --format json

It exits 0 for genuine, 21 for vacuous, and 0 for inconclusive. Passing the report to ci report --red-first-file <path> attaches a VACUOUS_TESTS finding to the check.

It needs history. “Before the branch” is the merge base with your target branch, so fetch-depth: 0 on actions/checkout is required — the default shallow clone has no common ancestor to compare against. Without it the check reports inconclusive and says why. It does not fall back to HEAD: on a branch whose change is already committed, HEAD contains the change, so that counterfactual would be identical to the change and every honest test would be called vacuous.

Advisory, deliberately. red-first runs a fresh counterfactual, and an execution is not a signed receipt — so it never moves a verdict. It reports, and your workflow decides. Relay still never says a change is safe to merge.

Pinning the version

Every release publishes its own installer, and that installer carries its version, its tarball URL and its tarball hash as literals. Fetching it pins the whole chain:

curl -fsSL https://relayevals.com/releases/0.6.70/install.sh | bash

/install.sh is whatever is current, which is what you want on a laptop and not what you want in CI — a build that pins nothing installs something new the morning after a release, and the first you hear of it is a job that behaved differently. A version that was never published answers 404 rather than quietly giving you a different one.

Or use the action

The steps above, packaged. It installs the pinned CLI, exchanges the OIDC token, verifies, checks that your changed tests depend on your change, and exits with the verdict.

.github/workflows/verify.ymlpermissions: contents: read id-token: write steps: - uses: actions/checkout@v4 with: { fetch-depth: 0 } - uses: relay-evals/verify-action@v1

A composite action, not a JavaScript one. A JS action ships a bundled dist/index.js — tens of thousands of lines of vendored dependencies that every consumer runs with their OIDC token in scope and almost nobody reads. This runs the published CLI and nothing else, so what executes in your job is the artifact you can download and checksum yourself.

Installing it cannot turn a passing build red on day one: set fail-on-verdict: false to observe the gate first, and fail-on-vacuous-tests is already false. The GitHub org is relay-evals (hyphenated); the CLI and this site remain relayevals.

The gate, in either mode

The exit code is the gate. Fail the job on anything other than 0, and treat UNRESOLVED as a configuration problem rather than a code problem — it means Relay could not decide, not that your change is bad. Which numbers those are depends on which command you run; see § 17.

§ 16

Agents

Relay ships as a skill so a coding agent loads the protocol itself instead of you pasting instructions into a prompt. It travels with the install, not with your project — setup writes nothing into your repository — so point your coding agent at the installed copy:

~/.relay/relayevals-0.6.70/skills/relay/SKILL.md

Three rules make an agent’s loop terminate:

  • Branch on code, never on reason.
  • Stop on ATTEMPT_THRASHING. Do not try a fourth time.
  • An error envelope is not a verdict. Check schema first.

The important consequence of the design: there is no prompt to persuade. A BLOCK clears when the evidence exists and not before.

Run Relay when your agent stops

Installs a hook so Relay runs the moment your coding agent reports it is done, instead of you remembering to ask. The verdict lands in the same place the agent says “finished”, which is the point.

relayevals install-hook claude

claude is the only coding agent wired up today. Others follow the same pattern when they land.

What Relay has caught for you

report reads the receipts and verdicts already on this machine and totals them over the last 30 days. It runs offline and covers this machine only.

relayevals report

The number most people quote back to us is the ratio — how often a change arrived with no test touching the lines that moved.

§ 17

Command reference

relayevals --version relayevals doctor env --format json # what env vars got sanitized? relayevals config path --format json # where is my data home? relayevals try # what is untested, right now relayevals setup --profile local # once per repository relayevals check --format json # is this repo ready? relayevals # where am I (status pulse) relayevals report # what Relay has caught for you relayevals install-hook claude # run Relay when your agent stops relayevals next --format json # the agent loop — one state, one action relayevals fix # the evidence, and what clears it relayevals explain # the same thing, for a person relayevals login [--no-browser] # claim this machine for your account relayevals logout # revoke the token, then delete it relayevals key unlock # for a protected key (§ 13) relayevals trust approve # approve this machine's signing key relayevals ci auth # exchange a GitHub OIDC token (§ 15) relayevals ci report --verdict-file <path> # report counts and codes to the server relayevals red-first --base <ref> # would these tests fail without the change? relayevals guard "<task>" -- <command> # run, sign, and decide — one step relayevals receipt run … -- <command> # produce evidence # npm test · pnpm test · yarn test · bun test relayevals receipt replay <path> # is this receipt still valid? relayevals receipt inspect <path> --format json relayevals verdict "<task>" --receipt <paths...> --format json relayevals verdict --explain <CODE> --format json relayevals grammar install <languages...> relayevals grammar list --format json relayevals producer protect --producer <name> relayevals supervise --passphrase-stdin --producer <name> -- <command> relayevals artifact approve <path> relayevals artifact verify <path>

§ 18

Troubleshooting

relayevals: command not found~/.local/bin is not on your PATH, or your shell cached the old lookup. Open a new shell, or run hash -r.
--version prints 0.0.0-devThe install did not complete its version step. Reinstall rather than continuing — that build is not one you can identify later.
An old install shadows the new oneVersions before 0.4.4 wrote to /usr/local, which takes priority on PATH. Remove /usr/local/bin/relayevals and /usr/local/libexec/relay.
this installer was not finalisedYou have an installer whose pin was never filled. It is not publishable and refuses to run. Re-download from relayevals.com.
Exit 77 on receipt runNot a failure. The key is encrypted — see § 11.
Exit 2 with COVERAGE_MISSINGPolicy asks for measured coverage and no receipt carried a report. Add --coverage. Nothing is wrong with your code, which is why this is UNRESOLVED and not BLOCK.
npm ci fails during installThe installer prints the head of npm’s output, which names the cause on the first line. Read that line before anything else.
A verdict you did not expectrelayevals verdict --explain <CODE> --format json returns what it means, what clears it, and whether it is about your change at all.

Uninstalling

rm -rf ~/.relay ~/.local/bin/relayevals hash -r

Per-repository state is .relay/ and relay.policy.json. Your data home — signing keys and grammars — is separate; find it with relayevals config path --format json before deleting anything. Deleting it destroys your producer key. Receipts already signed stay verifiable by anyone holding the public key, but you cannot sign as that producer again.

§ 19

What it does not prove

The limits are part of the product. Keep them straight when you summarise your work.

  • A PASS means the evidence exists and covers the change. It does not mean the change is correct.
  • Coverage proves a line ran, not that anything was asserted about it.
  • An unsigned verdict artifact is not approval by anyone.
  • A semantic exemption proves a change cannot alter behaviour, not that it is a good change.

A tool that overstates what it knows is worse than no tool, because you stop checking.

Reference for relayevals 0.6.70. Every code and schema is also available offline: relayevals verdict --explain <CODE> --format json.