# relay > Your coding-agent subscriptions, working as one system. ## What relay is relay is an open-source harness for people who pay for more than one coding agent. It turns Claude Code, Codex and Grok Build into a team that shares memory, picks up work from GitHub, runs on machines you already own, and shows you everything it did and what it cost. There is no new agent to learn and no token ever leaves your machines. GitHub issues are the task bus, a label picks the lane, a self-hosted runner executes the vendor's own CLI under your own login, and a pull request is the result. A Postgres database holds the shared memory and an append-only feed of events; this site is the read model over it. relay is free and Apache-2.0 licensed. There is no paid tier, no sign-up wall and nothing to buy; you bring the subscriptions you already have. ## The five rules 1. **Tasks move, credentials never do.** Every model call runs on a machine where the owner logged in with the vendor's own tool. No token is copied, proxied, or stored in CI. 2. **GitHub is the task bus.** An issue is a task, a label picks the lane, a pull request is the result. Nothing of record lives anywhere else. 3. **Every step starts fresh with a small brief.** No transcript is ever sent to a model. A brief is the repo's rules file, the issue, relevant decisions, and the vendor guidance that matches the intent. 4. **Official vendor sources only.** Model guidance comes from the vendor's documentation and the skills the vendor publishes. Community prompt guides are excluded unless the user allowlists a domain. 5. **Everything is an event.** One append-only table records what every human, bot, and runner did, with tokens and cost. The ledger is a view over it. ## How a task flows 1. **Open an issue, add a lane label** (GitHub). From a phone, a chat bot, or a terminal. agent:claude, agent:codex or agent:grok picks the lane. 2. **A runner claims it** (your machine). A self-hosted runner with that label checks out a fresh worktree and compiles a small brief. 3. **The vendor CLI runs headless** (your machine). claude -p, codex exec or grok -p under the machine's own login. Planner and subagents off. 4. **A branch and a PR appear** (GitHub). The output is snapshotted and pushed; the usage line lands as a PR comment and a step.usage event. 5. **Preview builds, a second model reviews** (Vercel · your machine). Vercel or CI builds the preview. A review lane on a different model comments. 6. **You merge; memory updates** (Postgres). Decisions are extracted into shared memory and the rules file is regenerated for every linked repo. ## Quick install Paste this into whichever coding agent you use: ```text Set up relay from https://github.com/kyleroche/relay following its AGENTS.md "Installing relay for a user" section. Stop and tell me when a step needs my browser. ``` Or by hand: ```bash git clone https://github.com/kyleroche/relay && cd relay pnpm install pnpm relay doctor # what is installed, logged in, and reachable pnpm relay setup # hooks + memory server into each CLI you have cd ~/code/some-repo && pnpm --dir ~/relay relay link ``` ## Link your model accounts relay never asks for a token. Log in to each tool you own, on each machine that will run work. | Tool | Login | Plan | |---|---|---| | Claude Code | `claude then /login` | Pro or Max | | Codex | `codex login, or fx login codex` | ChatGPT | | Grok Build | `grok login, or fx login grok` | SuperGrok | | GitHub | `gh auth login` | any | ## Agents and APIs - Public read API: `https://relay.kyro.house/api/v1/info`, `/api/v1/feed`, `/api/v1/projects` (OpenAPI at `/openapi.json`) - MCP server (Streamable HTTP): `https://relay.kyro.house/mcp` - Agent skill for installing relay: `https://relay.kyro.house/.well-known/agent-skills/index.json` - Docs: `https://relay.kyro.house/docs`, `https://relay.kyro.house/llms.txt` ## License Apache-2.0. Read AGENTS.md first if you want to contribute; it is written for people and agents alike. Source: https://github.com/kyleroche/relay --- # Architecture relay exists because one person using several coding-agent subscriptions wastes most of them on re-read context and re-explained decisions. It makes the subscriptions work as one system without building a new agent. ## The rules 1. **Tasks move, credentials never do.** Every model call runs on a machine where the owner logged in with the vendor's own tool. No token is copied, proxied, or stored in CI. 2. **GitHub is the task bus.** An issue is a task, a label picks the lane, a pull request is the result. Nothing of record lives anywhere else. 3. **Every step starts fresh with a small brief.** No transcript is ever sent to a model. A brief is the repo's rules file, the issue, relevant decisions, and the vendor guidance that matches the intent. 4. **Official vendor sources only.** Model guidance comes from the vendor's documentation and the skills the vendor publishes. Community prompt guides are excluded unless the user allowlists a domain. 5. **Everything is an event.** One append-only table records what every human, bot, and runner did, with tokens and cost. The ledger is a view over it. ## Pieces | Piece | What | Where it runs | |---|---|---| | Front end | Any chat agent that can file GitHub issues and call a webhook. Grok Bot is the reference; a plain terminal works. | wherever the user talks | | Task bus | Issues, labels (`agent:claude`, `agent:codex`, `agent:grok`), one Projects board across repos | GitHub | | Runners | Self-hosted GitHub Actions runners that run `claude -p`, `fx`/`codex exec`, or `grok -p` in a fresh worktree and open a PR | the user's machines | | Memory | Postgres: projects, file digests keyed by content hash, decisions with validity, preferences, vendor guidance with vector and full-text search | Neon in production, PGlite locally | | Memory server | MCP over stdio for the CLIs and over HTTP for cloud agents | the web app and each machine | | Rules file | A generated `relay.md` per repo, referenced from `AGENTS.md`/`CLAUDE.md` through an owned, marker-delimited section | in every linked repo | | Hooks | Session-start injection and session-end capture for Claude Code, Codex, and Grok Build | each machine | | Feed | The events table plus a web page over it, with usage meters and worker health as rollups | the web app | | Onboarding gate | A model cannot be used by a lane until its official docs and skills are ingested | CLI and runners | ## A task's life 1. A human or bot opens an issue and labels it with a lane. 2. A runner with that label claims the job, checks out a fresh worktree, compiles the brief, and runs the vendor CLI headless with the planner and subagents disabled. 3. The CLI's output is snapshotted and pushed as a branch; a PR opens with the usage line as a comment. 4. Vercel or the repo's CI builds the preview. A review lane, on a different model, comments. 5. The human approves. On merge, a job extracts decisions into memory and regenerates the rules file. 6. Every step above is a row in the feed. ## What relay deliberately does not do - It does not run a coordinator model. Planning is the front end's job, or the human's. - It does not host a chat UI. It hosts a feed. - It does not hold API keys for vendors. Pay-per-token providers are optional overflow lanes the user configures. --- # API and MCP relay exposes the same read model three ways: this site, a small JSON API under `/api/v1/`, and an MCP server at `/mcp`. All three read through one data interface; none of them call a model. ## Base URL and versioning Base URL: `https://relay.kyro.house/api/v1`. The version is in the path. Changes follow the [versioning and deprecation policy](/docs/versioning): additive changes ship in place, breaking changes get a new path segment, and retired operations announce themselves with `Deprecation` and `Sunset` headers (RFC 9745) before they go. The OpenAPI 3.1 description is at [`/openapi.json`](/openapi.json) and is listed in [`/.well-known/api-catalog`](/.well-known/api-catalog) (RFC 9727). ## Authentication and scopes Read endpoints work without credentials when the deploy enables public read (`RELAY_PUBLIC_READ=1`, the default for demo data). Otherwise, and for every write, send a bearer token: ```http Authorization: Bearer ``` Scopes are named and granted per token: | Scope | Grants | |---|---| | `feed:read` | GET /api/v1/feed, /api/v1/projects, relay_get_feed, relay_get_project | | `memory:read` | relay_recall, relay_brief, relay_guidance, relay_feed, relay_project | | `memory:write` | relay_decide, relay_prefer | | `assets:read` | GET /api/v1/assets, GET /api/v1/assets/{id}, relay_asset_list, relay_asset_get | | `assets:write` | POST /api/v1/assets, finalize, publish, relay_asset_request_upload, relay_asset_finalize | | `assets:review` | PUT /api/v1/assets/{id}/review | Tokens are created with the CLI on a machine that holds the database connection; the web app never mints them, it only checks them: ```bash pnpm relay token create --name ci-runner --scopes feed:read,assets:write # prints the token once pnpm relay token ls pnpm relay token revoke ci-runner ``` Only the SHA-256 of a token is stored (`tokens` table). A deployment without a database can set `RELAY_API_TOKENS` instead (`token:scope,scope;token2:scope`), and `RELAY_MEMORY_TOKEN` is one shared secret that grants every scope. The bearer scheme and scopes are also published at [`/.well-known/oauth-protected-resource`](/.well-known/oauth-protected-resource) (RFC 9728). ## Rate limits Every API response carries structured rate-limit headers (IETF RateLimit header fields): ```http RateLimit-Policy: "default";q=60;w=60 RateLimit: "default";r=57;t=42 ``` The default policy is 60 requests per 60 seconds per client. Over the limit you get `429 Too Many Requests` with a `Retry-After` header and a problem document. ## Errors Errors are `application/problem+json` (RFC 9457): ```json { "type": "https://relay.kyro.house/docs/api#errors", "title": "Not Found", "status": 404, "detail": "No project with slug 'nope'.", "instance": "/api/v1/projects/nope" } ``` Unknown paths under `/api/v1/` return 404 in this shape, never an HTML shell. ## Operations ### GET /api/v1/info `operationId: getInfo`. Name, version, description, the five rules, data mode, and links to every machine-readable surface. ```bash curl -s https://relay.kyro.house/api/v1/info | jq . ``` ### GET /api/v1/feed `operationId: listFeed`. Events newest first, sanitized: `payload` and `dedupe_key` are omitted. Query: `project`, `kind`, `actor`, `lane`, `limit` (1-200, default 50), `before` and `after` (ISO timestamps) for paging. ```bash curl -s "https://relay.kyro.house/api/v1/feed?kind=step.usage&limit=5" | jq '.events[] | {ts, actor, model, cost_equiv}' ``` ### GET /api/v1/projects `operationId: listProjects`. Every linked project with last activity, open issues, decision count and digest coverage. ### GET /api/v1/projects/{slug} `operationId: getProject`. One project with its current decisions, models and recent events. ### Assets The asset library ([docs/ASSETS.md](/docs/assets)): media agents produce, stored privately, versioned, reviewed. Every view URL is signed and expires after an hour. Uploads need a database and a store (`RELAY_DATA=db`); the demo deployment answers `501` to uploads and serves mock assets. | Operation | Scope | Does | |---|---|---| | `GET /api/v1/assets` (`listAssets`) | `assets:read` | pending first, then newest; filter by `project`, `status`, `kind`, `issue`, `pr` | | `POST /api/v1/assets` (`createAsset`) | `assets:write` | multipart `file` + metadata ingests in one call; JSON `{"presign": true, "project", "filename"}` reserves an id and returns a one-time upload URL | | `POST /api/v1/assets/{id}/finalize` (`finalizeAsset`) | `assets:write` | after the presigned PUT: probe, thumbnail, create the row | | `GET /api/v1/assets/{id}` (`getAsset`) | `assets:read` | metadata, the version chain, signed `url`, `thumb_url`, `bundle_url` | | `PUT /api/v1/assets/{id}/review` (`reviewAsset`) | `assets:review` | `{"decision": "approved" \| "rejected", "note"}`; a rejection note becomes a project decision and, with a GitHub token, reopens the issue | | `POST /api/v1/assets/{id}/publish` (`publishAsset`) | `assets:write` | copy to the public Blob store and record a permanent URL (`501` without one) | One-call upload: ```bash curl -X POST https://relay.kyro.house/api/v1/assets -H "Authorization: Bearer $TOKEN" \ -F file=@render.png -F project=atlas-site -F issue=118 -F title="Pricing hero" -F tags=ux,pricing ``` Presigned upload (bytes go straight to storage, never through the app or a model): ```bash curl -s -X POST https://relay.kyro.house/api/v1/assets -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" \ -d '{"presign":true,"project":"atlas-site","filename":"walkthrough.mp4","issue":73}' > presign.json curl -X PUT "$(jq -r .upload_url presign.json)" -H "content-type: video/mp4" --data-binary @walkthrough.mp4 curl -X POST "$(jq -r .finalize_url presign.json)" -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" \ -d "$(jq .finalize_body presign.json)" ``` Review: ```bash curl -X PUT https://relay.kyro.house/api/v1/assets//review -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" \ -d '{"decision":"rejected","note":"Keep one accent hue; lower the chroma to match the light theme."}' ``` ## MCP server `https://relay.kyro.house/mcp` speaks the MCP Streamable HTTP transport (POST JSON-RPC; stateless). It is the same server the CLIs talk to over stdio (`relay-mcp`): one tool set, gated by the scopes your token carries. Registry metadata is at [`/server.json`](/server.json) and the server card at [`/.well-known/mcp/server-card.json`](/.well-known/mcp/server-card.json). | Tool | Scope | What it does | |---|---|---| | `relay_get_info` | public | the same document as GET /api/v1/info | | `relay_get_methodology` | public | the five rules and a task's life, as text | | `relay_get_feed` | `feed:read` | events, with the same filters as the API | | `relay_get_project` | `feed:read` | one project with decisions, models and recent events | | `relay_recall` | `memory:read` | decisions and preferences relevant to a query | | `relay_brief` | `memory:read` | the compiled brief for a project and intent | | `relay_guidance` | `memory:read` | official vendor guidance for a model | | `relay_feed`, `relay_project` | `memory:read` | compact text forms for a CLI session | | `relay_decide`, `relay_prefer` | `memory:write` | record a decision; set a preference | | `relay_asset_list`, `relay_asset_get` | `assets:read` | the library, and one asset with signed URLs | | `relay_asset_request_upload`, `relay_asset_finalize` | `assets:write` | reserve an id and upload URL; turn the upload into an asset | With public read enabled (the demo default) the `feed:read` and `memory:read` tools answer without a token; everything else returns an error naming the missing scope. Claude Code: ```bash claude mcp add --transport http relay https://relay.kyro.house/mcp ``` ## CLI The `relay` CLI lives in the repo (`pnpm relay --help`). First calls: ```bash pnpm relay doctor # what is installed, logged in, reachable pnpm relay feed --limit 20 # the same feed, in the terminal pnpm relay usage --window 7d ``` --- # Assets: media that is too large to share, with a review loop Agents produce things that are not code: a short film from a cloud agent, a UX mockup, a rendered page, a storyboard. The asset library stores them privately, versions them, and gives a human an approve-or-reject path that writes back to the task and to memory. It is the visual counterpart of a pull request. Code: `packages/core/src/db/assets.ts` (rows, review, retention), `packages/core/src/assets/` (`store.ts`, `ingest.ts`, `media.ts`, `index.ts`), `packages/cli/src/commands/assets.ts`, migration `packages/core/src/db/migrations/002_assets.sql`, tests `packages/core/test/assets.test.ts`. ## Storage One interface, `AssetStore` (`packages/core/src/assets/store.ts`): ```ts interface AssetStore { kind: "local" | "blob"; put(key, data: Buffer | Readable, { contentType }): Promise; getSignedUrl(key, ttlSeconds): Promise; delete(key): Promise; exists(key): Promise; publish(key, { contentType }): Promise; // permanent public URL; Blob only } ``` `openAssetStore()` returns `BlobStore` when `BLOB_READ_WRITE_TOKEN` is set, else `LocalStore`. Keys are relative paths `//` (thumbnail: `//thumb.webp|jpg`); the same key works in both adapters. Keys with `..`, empty segments, backslashes or a leading `/` are rejected. ### Production: Vercel Blob, private `BlobStore` uses `@vercel/blob` 2.8 with `access: 'private'` throughout. Exact calls: | operation | call | |---|---| | write | `put(key, body, { access: 'private', contentType, addRandomSuffix: false, allowOverwrite: true, token })` | | signed read | `issueSignedToken({ pathname: key, operations: ['get'], validUntil, token })` then `presignUrl(signedToken, { operation: 'get', pathname: key, access: 'private', validUntil })` → `presignedUrl` | | exists | `head(key, { token })`; `BlobNotFoundError` means false | | delete | `del(key, { token })` | | publish | `get(key, { access: 'private', token }).stream` piped into `put(key, stream, { access: 'public', contentType, addRandomSuffix: false, allowOverwrite: true, token: BLOB_PUBLIC_READ_WRITE_TOKEN })` → `url` | A private store never yields a permanent URL, so publishing needs a second, public store: set `BLOB_PUBLIC_READ_WRITE_TOKEN` to its read-write token. Without it `publish` throws `PublishUnsupportedError` and `relay asset publish` exits 2 with a note. Reference: https://vercel.com/docs/vercel-blob/using-blob-sdk (SDK), https://vercel.com/docs/vercel-blob (product). Create the private store with `vercel blob create-store --access private`. ### Local development and tests: `~/.harness/assets/` `LocalStore` writes under `${RELAY_HOME||~/.harness}/assets/`. A fresh clone needs no account. Its signed URL is: ``` file:////?exp=&sig=\n")> ``` - The secret is `RELAY_ASSET_SIGNING_KEY` when set, else 32 random bytes written once to `/.signing-key` (mode 0600) on first use. Set the env var when the web app and the CLI run as different users or on different machines against the same folder. - The web app never hands a `file://` URL to a browser. Its view route takes `key`, `exp` and `sig` (or the whole URL), calls `verifyLocalSignedUrl(url)` (or `new LocalStore().verify(key, exp, sig)`), and streams the file from the returned absolute path. Verification is constant-time, refuses expired tokens, and refuses any path that resolves outside the asset root. - `publish` is not available locally. ### Thumbnails and probes Ingest (`packages/core/src/assets/ingest.ts`) never fails for a missing helper: - mime: magic bytes for png, jpeg, gif, webp, avif, mp4/mov (ftyp brands), webm (EBML), pdf, zip; then the extension; then `application/octet-stream`. - image dimensions: parsed from PNG, JPEG (SOF), GIF and WebP (VP8/VP8L/VP8X) headers in pure JS. - video and audio: `ffprobe` on PATH gives width, height and duration; absent, those columns stay null. - image thumbnail: `sharp` (an **optional** dependency of `@relay/core`) resizes to fit 512 px and writes webp. If `sharp` did not install, no thumbnail. - video poster: `ffmpeg` on PATH grabs a frame at 1 s (first frame for shorter clips) as jpeg, 512 px wide. - HTML bundles: a zip is kind `html` only when its central directory holds `index.html` at the root or inside a single top-level folder (`site/index.html`); otherwise it is kind `file`. A bare `.html` file is also kind `html` (mime `text/html`). The app serves both in a sandboxed iframe. Zip64 archives are rejected. Bytes are read fully into memory for hashing and sniffing; this is fine for the clips and renders agents make, not for multi-gigabyte masters. ### Retention Rejected and superseded versions get `expires_at = now() + RELAY_ASSET_RETENTION_DAYS` (default 30). Approved and published assets have no expiry. `relay asset expire` lists what is due; `relay asset expire --yes` deletes the rows first, then the bytes, so a failed blob delete can only leave an orphan object, never a row pointing at nothing. A thumbnail key still referenced by another row is kept. ## Data ```sql CREATE TABLE IF NOT EXISTS assets ( id text PRIMARY KEY, -- ulid project text REFERENCES projects(slug) ON DELETE SET NULL, issue int, pr int, title text NOT NULL, description text, kind text NOT NULL, -- image | video | audio | pdf | html | file mime text NOT NULL, size bigint NOT NULL, sha256 text NOT NULL, storage_key text NOT NULL, -- blob pathname or local relative path thumb_key text, width int, height int, duration_ms int, version int NOT NULL DEFAULT 1, parent_id text REFERENCES assets(id) ON DELETE SET NULL, -- previous version status text NOT NULL DEFAULT 'pending', -- pending | approved | rejected | superseded | published created_by text NOT NULL, -- actor, e.g. bot:grok, runner:mini, human:kyle created_at timestamptz NOT NULL DEFAULT now(), reviewed_by text, reviewed_at timestamptz, review_note text, public_url text, -- set only after an explicit publish expires_at timestamptz, -- set when rejected or superseded tags text[] NOT NULL DEFAULT '{}' ); CREATE INDEX IF NOT EXISTS assets_project_status_idx ON assets(project, status, created_at DESC); CREATE INDEX IF NOT EXISTS assets_expires_idx ON assets(expires_at) WHERE expires_at IS NOT NULL; CREATE INDEX IF NOT EXISTS assets_parent_idx ON assets(parent_id); ``` There are no per-row grants; access is enforced by the app's API scopes. `parent_id` is `ON DELETE SET NULL` so expiring an old version never cascades into the current one. ## Module API `packages/core/src/db/assets.ts` (also reachable as `repo.*`): | function | does | |---|---| | `createAsset(db, input)` | insert version 1 (bytes must already be in the store); writes `asset.uploaded` | | `getAsset(db, id)` | one row or undefined | | `listAssets(db, { project?, status?, kind?, issue?, pr?, limit? })` | pending first, then newest first | | `listVersions(db, id)` | the whole version chain of the asset `id` belongs to, oldest first | | `newVersion(db, parentId, input)` | insert version n+1 inheriting project/issue/pr/title/description/tags unless given; parent becomes `superseded` with an expiry; writes `asset.uploaded` | | `reviewAsset(db, id, { decision: 'approved' \| 'rejected', by, note? })` | sets status, reviewer, note; rejected rows get an expiry; writes `asset.reviewed` (note in `summary`); a rejection note is also an `addDecision` with `source: 'review'`, `source_ref: ` | | `publishAsset(db, id, publicUrl, { by? })` | status `published`, clears expiry, writes `asset.published` with `url` | | `listExpiredAssets(db, now?)` | dry run of the retention sweep | | `expireAssets(db, now?)` | deletes due rows, returns `{ id, keys }[]` for the caller to remove from the store | | `retentionDays()` | `RELAY_ASSET_RETENTION_DAYS` or 30 | `packages/core/src/assets/`: | function | does | |---|---| | `openAssetStore({ root? })` | `BlobStore` when `BLOB_READ_WRITE_TOKEN` is set, else `LocalStore` | | `new LocalStore(root?)`, `new BlobStore(token?, publicToken?)` | the adapters | | `verifyLocalSignedUrl(url, store?, now?)` | `{ ok, key, path, exp }` or `{ ok: false, reason }` | | `ingestFile(db, store, { path \| buffer, filename?, project, issue?, pr?, title?, description?, replaces?, createdBy, tags?, thumbSize?, log? })` | sniff, hash, measure, thumbnail, store, row (new version when `replaces`, inheriting title/issue/pr/description/tags that are left undefined; otherwise `title` defaults to the filename); removes the bytes again if the row fails | | `probeBytes(bytes, filename)` | the sniffing step on its own: `{ mime, kind, size, sha256, width, height, durationMs, htmlIndex }` | | `previewExpiredAssets(db, now?)`, `purgeExpiredAssets(db, store, now?)` | the retention sweep as the CLI runs it | | `sniffMime`, `imageDimensions`, `zipEntryNames`, `htmlBundleIndex`, `kindForMime`, `safeFilename` | pure helpers in `media.ts` and `ingest.ts` | ## Events | kind | actor | when | payload | |---|---|---|---| | `asset.uploaded` | `created_by` | a new asset or version lands | `asset_id, kind, mime, size, version, parent_id, sha256` | | `asset.reviewed` | the reviewer | approve or reject; `summary` is `approved: ` or `rejected: <title> — <note>` | `asset_id, decision, note, version` | | `asset.published` | the publisher (default `created_by`) | a public URL was created; `url` is set | `asset_id, public_url, version` | All three carry `project`, `issue` and `pr` from the row. A rejection note is also written to memory as a decision for that project, source `review`, `source_ref` the asset id, text `Rejected "<title>" (v<n>): <note>`, so the next attempt sees it in its brief. ### Presigned uploads Bytes should never pass through a model or, when a store can take them directly, through the app. Two more store methods and one ingest helper make that possible: | function | does | |---|---| | `store.getUploadUrl(key, ttlSeconds, { contentType })` | Blob: `issueSignedToken({ operations: ['put'] })` + `presignUrl({ operation: 'put', allowedContentTypes, allowOverwrite })`. Local: a `file://` URL whose token signs `upload/<key>` (so a view token can never write), with `op=put`; the app rewrites it to `PUT /api/assets/upload` | | `store.read(key)` | the whole object (local file, or Blob `get` with `access: 'private'`) | | `verifyLocalUploadUrl(url, store?, now?)` | the upload-side twin of `verifyLocalSignedUrl` | | `uploadKeyFor(project, id, filename)` / `parseUploadKey(key)` | the key shape a presigned upload must use: `<project>/<id>/<safe filename>`, with a ulid in the middle | | `finalizeFromKey(db, store, { key, project, issue?, pr?, title?, description?, replaces?, createdBy, tags? })` | after the PUT: read the object back, `probeBytes`, thumbnail, insert the row under the key's id (a new version when `replaces`). A missing or empty object throws `IngestError`; a failed insert deletes the bytes | `readZipEntry(zip, name)` and `mimeForEntry(name)` (in `media.ts`) serve one entry of an HTML bundle at a time (stored or deflate), so a bundle is never unpacked to disk. ## Interfaces - CLI (`createdBy` and the reviewer default to `human:$USER`, override with `--by`): - `relay asset put <file> --project <slug> [--issue N] [--pr N] [--title ...] [--description ...] [--replaces <id>] [--tag a b]` - `relay asset ls [--project] [--status] [--kind] [-n 50]` - `relay asset get <id> [--ttl 3600]` prints the row, the version chain, and signed `url` / `thumb_url` - `relay asset review <id> --approve|--reject [--note "..."]` - `relay asset publish <id>` (local store, or Blob without `BLOB_PUBLIC_READ_WRITE_TOKEN`: prints why it cannot and exits 2) - `relay asset expire [--yes]` (dry run by default) - HTTP (`/api/v1/assets`, in `apps/web`; every operation is in `/openapi.json`, errors are RFC 9457): - `GET /api/v1/assets` (`assets:read`): `listAssets` with `project`, `status`, `kind`, `issue`, `pr`, `limit`. - `POST /api/v1/assets` (`assets:write`): multipart `file` + metadata → `ingestFile` → `201` with the asset; or JSON `{ "presign": true, "project", "filename", "content_type"?, ...metadata }` → `201` with `id`, `key`, `upload_url`, `method: PUT`, `headers`, `expires_at`, `finalize_url`, `finalize_body`. - `POST /api/v1/assets/{id}/finalize` (`assets:write`): `finalizeFromKey`; `409` when already finalized, `404` when nothing was uploaded to the key. - `GET /api/v1/assets/{id}` (`assets:read`): metadata, `versions`, `task_url`, and signed `url`, `thumb_url`, `bundle_url` (absolute, expire after an hour). - `PUT /api/v1/assets/{id}/review` (`assets:review`): `{ decision, note?, by? }`; a rejection needs a note. - `POST /api/v1/assets/{id}/publish` (`assets:write`): `store.publish` + `publishAsset`; `501` without a public store. - Local store only: `GET /api/assets/stream?key&exp&sig` streams a verified signed URL (range requests supported); `PUT /api/assets/upload?key&exp&sig` is the presigned upload target (200 MB cap). - `GET /api/assets/bundle/{id}/{exp}/{sig}/{entry}` serves one entry of an HTML bundle for the viewer's sandboxed iframe. The signature is app-level (HMAC over `bundle/<id>` with `RELAY_ASSET_SIGNING_KEY`, else the local signing key, else the Blob token) so one URL covers the whole bundle and relative links keep working. - `GET /api/cron/assets-expire` with `Authorization: Bearer $CRON_SECRET`: `purgeExpiredAssets`. `apps/web/vercel.json` schedules it daily at 04:00 UTC. - The app calls the module functions above; nothing in `packages/core` speaks HTTP. In mock mode (`RELAY_DATA=mock`) the list, get, review and publish operations work on ~7 demo assets whose bytes come from `/api/assets/mock/{id}`; uploads answer `501`. - MCP tools (the shared server in `packages/mcp`, over stdio and at `/mcp`): `relay_asset_list` and `relay_asset_get` (`assets:read`), `relay_asset_request_upload` and `relay_asset_finalize` (`assets:write`). Over stdio the URLs are the store's own (`file://` locally); over HTTP the app rewrites them to its routes. Agents upload bytes to the returned URL; bytes never pass through the model. - GitHub (`apps/web/lib/github.ts`): with `GITHUB_APP_TOKEN` or `GITHUB_TOKEN` set, an upload with an issue or PR number gets a comment with the title and the viewer link; a review gets the decision, and a rejection also reopens the issue (`PATCH /repos/{repo}/issues/{n}` with `state: open`). Without a token nothing is posted and nothing fails. ## Web - `/assets`: gallery, pending first in a "Needs review" group, then grouped by project; filters by project, status and kind live in the URL (`listAssets`). - `/assets/[id]`: viewer per kind (image, video with poster, PDF in an iframe, HTML in an iframe with `sandbox="allow-scripts"` and no `allow-same-origin`; bundles are unzipped entry by entry on demand), version strip (`listVersions`), approve / reject with note (`reviewAsset` through a server action), keyboard `a` / `r` (Cmd/Ctrl+Enter submits the note), who made it and from which task, a publish button once approved. View URLs come from `store.getSignedUrl` (Blob) or the app's own streaming route after `verifyLocalSignedUrl` (local). The web app's reviews are attributed to the signed-in GitHub user, `human:<login>` (docs/SECURITY.md); in mock mode to `RELAY_REVIEWER`, default `human:demo`. - The feed home shows the pending count in its header and a "Needs review" strip above the events whenever any exist. Pending reviews also reach the front end (Grok Bot, iMessage) with a signed link; replying through Woodhouse calls the review route. ## Environment | name | effect | |---|---| | `BLOB_READ_WRITE_TOKEN` | selects the private Blob store | | `BLOB_PUBLIC_READ_WRITE_TOKEN` | the public store `publish` copies into | | `RELAY_ASSET_SIGNING_KEY` | HMAC secret for local signed URLs and bundle URLs (else `<root>/.signing-key`; Blob deployments fall back to the Blob token for bundle URLs) | | `RELAY_ASSET_RETENTION_DAYS` | days before rejected/superseded versions expire (30) | | `RELAY_HOME` | base folder; assets live in `$RELAY_HOME/assets/` | | `CRON_SECRET` | what the retention cron must present to `/api/cron/assets-expire` | | `GITHUB_APP_TOKEN` / `GITHUB_TOKEN` | enables the GitHub comments and reopen on rejection | | `RELAY_REVIEWER` | mock mode only: actor the demo's reviews are attributed to (default `human:demo`); with a database the signed-in user is the reviewer | ## Not in scope Annotation tools, comments pinned to coordinates, collaborative editing. Approve, reject, note, versions. That is the loop. --- <!-- /docs/feed --> # The feed Everything relay knows about happens as a row in the `events` table (`packages/core/src/db/schema.sql`). Humans, bots, runners and lanes all write the same shape: a `kind`, an `actor`, where it happened (`repo`, `issue`, `pr`, `project`), optional usage numbers, a one-line `summary`, a `url`, and the raw `payload`. The web app's feed page is a time-ordered view over this table; the `ledger` view is the subset with `kind = 'step.usage'`. ## Event kinds | Kind | Source | Actor | Fields set | Summary line | |---|---|---|---|---| | `issue.opened` | GitHub webhook `issues` (opened) | `human:<login>` or `bot:<login>` | repo, issue, url | issue title | | `issue.labeled` | GitHub webhook `issues` (labeled, `agent:*` labels only) | `human:<login>` | repo, issue, lane | `agent:<lane>` | | `run.started` | GitHub webhook `workflow_job` (in_progress) on a `relay run` job | `runner:<runner name>` | repo, issue, lane, url (job url) | lane and runner | | `run.finished` | GitHub webhook `workflow_job` (completed) | `runner:<runner name>` | repo, issue, lane, duration_ms, payload.conclusion | success, failure, cancelled, or "no changes" | | `step.usage` | GitHub webhook `issue_comment` on a PR, parsed from the runner's usage comment (below) | `agent:<lane>` | repo, issue, pr, lane, model, subscription, tokens_in, tokens_out, cache_read, cache_write, cost_equiv, duration_ms, url | model and tokens | | `pr.opened` | GitHub webhook `pull_request` (opened) from a `relay/` branch | `agent:<lane>` (lane from branch name) | repo, issue (from branch), pr, lane, url | PR title | | `pr.reviewed` | GitHub webhook `issue_comment` carrying a review from a lane, or `pull_request_review` | `agent:<lane>` or `human:<login>` | repo, pr, lane | first line of the review | | `pr.merged` | GitHub webhook `pull_request` (closed, merged) | `human:<login>` | repo, issue, pr, url | PR title | | `preview.ready` | Vercel webhook `deployment.succeeded` for a `relay/` branch | `bot:vercel` | repo, pr, url (preview URL) | project and branch | | `preview.failed` | Vercel webhook `deployment.error` | `bot:vercel` | repo, pr, url | project and branch | | `decision.captured` | session-end hook (`relay capture`), `relay decide`, the `relay_decide` tool, or the post-merge job | `agent:<cli>` or `human:<login>` | project, payload.decision_id, payload.source, payload.superseded | the decision text | | `preference.set` | `relay prefer` or the `relay_prefer` tool | `human:<login>` or `agent:<cli>` | project (project scope only), payload.key/value/scope/scope_id | `key = value (scope: id)` | | `project.linked` | `relay link` | `human:<login>` | project, repo, payload.path | repo and path | | `memory.consolidated` | nightly job | `bot:relay` | project, payload.counts | merged and superseded counts | | `onboard.refreshed` | `relay onboard <model>` | `human:<login>` | model, payload.sources | model and chunk count | | `runner.offline` | GitHub webhook `workflow_job` (queued) with no matching runner for longer than a threshold, or a failed keep-alive | `bot:relay` | lane, payload.runner | which label has nobody serving it | `subscription` is derived from the lane: `claude -> max`, `codex -> codex`, `grok -> grok`, overflow lanes `-> gateway`. `cost_equiv` is computed at ingest from the Gateway list-price table in `packages/core/src/ledger`, so the runner never has to know prices. Every webhook write sets `dedupe_key` to the GitHub delivery id (or the Vercel webhook id), and `step.usage` rows add the comment id, so redeliveries never double-count. ## The usage comment `scripts/runner/run-lane.sh` posts exactly one comment per run on the PR. It ends with a marker and a fenced JSON block: ````markdown ## Summary from the claude lane ...the lane's own final message... <!-- relay:usage --> ```json {"relay":"step.usage","lane":"claude","mode":"code","tool":"claude","model":"claude-opus-5","issue":42,"tokens_in":18234,"tokens_out":2210,"cache_read":91200,"cache_write":4100,"duration_ms":412000} ``` ```` How the GitHub webhook handler parses it, on `issue_comment` (created or edited) where the issue is a pull request: 1. Require the comment to contain `<!-- relay:usage -->`; otherwise ignore the comment for usage purposes. 2. Take the first fenced block after the marker whose info string is `json`, trim it, and `JSON.parse` it. The block is a single line, but a multi-line object is accepted. 3. Require `relay === "step.usage"`; otherwise ignore. 4. Required fields: `lane` (`claude|codex|grok|gateway`), `tokens_in`, `tokens_out`, `cache_read`, `cache_write`, `duration_ms` (all non-negative integers), `model` (string, may be `"default"` when the CLI did not report one). Optional: `mode` (`code|review`, default `code`), `tool` (`claude|fx|codex|grok`), `issue` (integer). Anything else is kept in `payload` but not interpreted. 5. Write one `events` row: `kind = 'step.usage'`, `actor = 'agent:<lane>'`, `repo`, `pr` and `url` from the comment, `issue` from the field or from the PR's `relay/<lane>-<issue>` branch name, `lane`, `model`, `subscription` derived from the lane, usage columns copied, `cost_equiv` computed, `summary` = `"<model>: <tokens_in>+<cache_read> in, <tokens_out> out"`, `dedupe_key = 'gh-comment:<comment id>'`. 6. In review mode (`"mode":"review"`), also write a `pr.reviewed` row with the text above the marker as the summary. Only comments by logins in the repo's `RELAY_ALLOWED_SENDERS` variable (which includes the runner's gh account) are trusted for usage; the handler reads that variable through the GitHub API (with `GITHUB_TOKEN`) once per hour and caches it, or takes the comma-separated `RELAY_ALLOWED_SENDERS` environment variable when set. With neither configured every sender is trusted. A usage block posted by anyone else is recorded as `payload.untrusted = true` with its usage columns zeroed (the reported numbers stay in `payload.reported`), so the `ledger` view, which is a plain `kind = 'step.usage'` filter, never counts it. The parser lives in `apps/web/lib/webhooks/usage.ts`; the handler for every GitHub delivery is `githubEvents` in `apps/web/lib/webhooks/github.ts`, which returns zero, one or two rows per delivery. ## Reading the feed - Web: the feed page groups rows by issue, with the usage meter per subscription in the header. - CLI: `pnpm relay feed` (last 50 rows) and `pnpm relay usage --days 7` (per-subscription totals, the same query as the `ledger` view). - SQL: `select * from ledger where ts > now() - interval '7 days' order by ts desc`. --- <!-- /docs/versioning --> # Versioning and deprecation This policy covers everything relay serves to other programs: the JSON API under `/api/v1/`, the MCP server at `/mcp`, the OpenAPI description, the well-known files, and the `@relay/core` package. ## Versions - The API version is a path segment: `/api/v1/`. A new major version gets a new segment and the old one keeps working through its sunset date. - Additive changes ship in place without a version bump: new optional query parameters, new response fields, new event kinds, new MCP tools. - Breaking changes never ship in place. A field is never renamed, retyped or removed within a version; an operation is never given different semantics. - MCP tools follow the same rule. A tool's input schema only gains optional fields; a tool that must change is added under a new name and the old one is deprecated. - `@relay/core` follows semantic versioning. The `events` table is append-only and its columns are never dropped; new columns are nullable or defaulted. ## Deprecation When an operation, tool, field or file is scheduled for removal: 1. It is announced in the changelog and in `docs/AGENT-READINESS.md` at least 90 days before removal. 2. Every response from a deprecated operation carries RFC 9745 headers: ```http Deprecation: @1767225600 Sunset: Sat, 04 Apr 2026 00:00:00 GMT Link: </docs/versioning>; rel="deprecation"; type="text/html" ``` `Deprecation` is the instant the deprecation took effect; `Sunset` (RFC 8594) is when the operation stops answering. The `Link` points at this policy and at the replacement where one exists. 3. The OpenAPI description marks the operation `deprecated: true` and names the replacement in its description. 4. After the sunset date the operation returns `410 Gone` as `application/problem+json` for at least 30 days, then 404. ## What is stable now The following are covered by this policy as of the first public release: `GET /api/v1/info`, `GET /api/v1/feed`, `GET /api/v1/projects`, `GET /api/v1/projects/{slug}`, the MCP tools `relay_get_info`, `relay_get_feed`, `relay_get_project`, `relay_get_methodology`, and the files `/openapi.json`, `/llms.txt`, `/llms-full.txt`, `/.well-known/ai-catalog.json`, `/.well-known/api-catalog`, `/.well-known/mcp/server-card.json`, `/.well-known/agent-skills/index.json`, `/.well-known/oauth-protected-resource` and `/server.json`. Write endpoints and the token-gated MCP memory tools are pre-release: they may change without the notice period until they are listed here. --- <!-- /docs/agent-readiness --> # Agent readiness contract for the relay site Target: a 100/100 on [is-agentic.com](https://is-agentic.com) for the hosted site, scanned with `npx is-agentic <domain> --json`. The score is 80 points of Essential checks, 20 of Recommended, and up to 5 bonus; checks that do not apply are excluded and partial results earn proportional credit. The checks come from Ora and follow the [AgentReady standard](https://www.agentready.org/) (find, read, act). This file lists what the site must serve. CI runs the scan after each production deploy and fails on a drop. ## Find - `/robots.txt`: `User-agent: *` allowed, only low-value paths disallowed, `Sitemap:` line. Explicit groups for `GPTBot`, `ClaudeBot`, `OAI-SearchBot`, `Claude-SearchBot`, `ChatGPT-User`, `Claude-User` that allow. - `/sitemap.xml` listing every public page. - `/.well-known/ai-catalog.json`, linked from the homepage `<head>` with `<link rel="ai-catalog">`. - `<meta name="is-agentic-site-type" content="app">` on every page. - Brand clarity: `<title>` and H1 say "relay" and what it is in plain words. ## Read - Every public page server-rendered: at least 500 characters of meaningful text in the initial HTML, an H1, sequential headings, a content ratio above 5%. The feed page renders its first page of events on the server. - Real links in the homepage HTML to `/info`, the docs, the GitHub repo, and the API docs. - `/llms.txt` (H1, blockquote summary, H2 link sections) and `/llms-full.txt` (the docs concatenated). - Markdown mirrors: each docs page also served as `text/markdown` through `Accept` negotiation, with `Vary: Accept`, `406` when nothing acceptable, and `<link rel="alternate" type="text/markdown" href="...">` in the HTML head. - JSON-LD on the homepage and info page: `SoftwareApplication` plus `Organization`, with the facts repeated in visible text. - Correct status codes: 404 for unknown paths, never a 200 shell; 429 with `Retry-After` when throttled. - Fenced code blocks with language tags everywhere. ## Act - `/openapi.json`: every API operation with an `operationId`, typed responses, errors as RFC 9457 `application/problem+json`, paths under `/api/v1/`, documented rate limits, security scheme with **named scopes** (`feed:read`, `memory:read`, `memory:write`). - `/.well-known/api-catalog` (RFC 9727) pointing at the OpenAPI description. - Rate limits: `RateLimit-Policy` and `RateLimit` structured headers on every API response; `429` includes `Retry-After`. - Public read endpoints that work without credentials when the deploy enables public read: `/api/v1/info`, `/api/v1/feed` (sanitized fields), `/api/v1/projects`. Write endpoints require a bearer token; document self-serve token creation. - Versioning and deprecation policy at `/docs#versioning`, with RFC 9745 `Deprecation` and `Sunset` headers when used. - MCP: Streamable HTTP at `/mcp` with public read-only tools (`relay_get_info`, `relay_get_feed`, `relay_get_project`, `relay_get_methodology`) and token-gated memory tools; `/server.json` registry metadata; `/.well-known/mcp/server-card.json`; protected-resource metadata at `/.well-known/oauth-protected-resource` (RFC 9728) describing the bearer scheme and scopes. - Agent skills: `/.well-known/agent-skills/index.json` listing the `relay` install skill with its SHA-256 digest, served from `skills/relay/SKILL.md` in the repo. - SDK and CLI: the `relay` CLI on npm with install and first-call examples in the docs. - Agent onboarding friction: a free tier is the default; nothing is sales-gated; the info page says so in one sentence. ## Not applicable, declared Payments (x402, UCP, ACP) do not apply to a free tool. WebMCP and NLWeb are optional; skip until the rest scores. ## Verification ```bash npx is-agentic relay.kyro.house --json | jq '.score, .issues[] | {id, result, recommendation}' ``` --- <!-- /docs/agents --> # AGENTS.md — relay relay is an open-source harness that lets one person's coding-agent subscriptions (Claude, Codex, Grok) work as one system: GitHub issues are the task bus, self-hosted runners on the user's own machines execute steps with the vendor CLIs under the machine's own login, a Postgres database holds shared memory and an append-only event feed, and a small web app shows it all. Credentials never leave the machine they were created on. ## Layout - `packages/core` — TypeScript library: `db/` (Postgres schema + migrations + typed queries; PGlite locally, Neon in production), `brief/` (the brief compiler and `relay.md` renderer), `memory/` (transcript capture: parse, gate, extract decisions with the Sonnet lane), `onboard/` (official-vendor-docs ingestion gate), `lanes/` (headless adapters for `claude -p`, `fx`, `codex exec`, `grok -p`), `ledger/` (step.usage writer and price table), `assets/` (Blob or local store, versions, review), `rules/` (owned-section installer for `AGENTS.md`/`CLAUDE.md`), `hooks/` (per-CLI hook installers and the brief cache), `state/` (project registration and file digests). - `packages/cli` — the `relay` command. `src/commands/` holds one file per command group (`setup`, `link`, `brief`, `recall`, `decide`, `prefer`, `capture`, `memory`, `assets`, `token`), `hooks/*.mjs` are the dependency-free scripts the CLIs' hooks point at, `templates/` the caller workflows `relay link` installs. `relay onboard <model>` ingests vendor guidance; `relay feed`, `relay usage`, `relay doctor`; `relay token create` mints scoped bearer tokens for the web app (hash stored, shown once). - `packages/mcp` — the relay MCP server `@relay/mcp`: `bin/relay-mcp.mjs` (stdio for the CLIs), `createMcpHandler` (Streamable HTTP for the web app), one tool set for both: public read tools (`relay_get_*`), memory tools (`relay_recall`, `relay_brief`, `relay_guidance`, `relay_feed`, `relay_project`, `relay_decide`, `relay_prefer`) and asset tools (`relay_asset_*`), gated by the scopes a token carries (`TOOL_SCOPES` in `src/server.ts`). - `apps/web` — Next.js app: event feed, projects, usage meters, workers, asset library with review (`/assets`), info and docs pages, webhook ingest (GitHub, Vercel), the public read API and the asset API under `/api/v1`, the MCP server at `/mcp`, the retention cron, and the agent-readiness files (`/.well-known/*`, `/openapi.json`, `/llms.txt`). Data goes through `apps/web/lib/data.ts`: `RELAY_DATA=db` or any `DATABASE_URL` selects `lib/data-db.ts` over `@relay/core` (PGlite when `DATABASE_URL` is unset, one handle per instance in `lib/db.ts`); otherwise `lib/data-mock.ts` serves `lib/mock.ts`. Bearer tokens are checked in `lib/auth.ts` (`tokens` table, then `RELAY_API_TOKENS`, then `RELAY_MEMORY_TOKEN`). `DESIGN.md` there holds the UX brief and tokens. - `skills/relay/SKILL.md` — the agent skill for installing relay, served at `/.well-known/agent-skills/index.json`. - `.github/workflows` — reusable runner workflows that repos call with a thin caller. - `docs/` — `ARCHITECTURE.md` is the source of truth for design; `AGENT-READINESS.md` is the contract for the hosted site; `VERSIONING.md` is the API deprecation policy; `research/` holds dated surveys. ## Commands ```bash pnpm install # Node 24+ required (.node-version); bin/relay finds one itself pnpm -r typecheck pnpm -r test # vitest; DB tests run on PGlite, no server needed pnpm relay --help pnpm relay setup # plan hooks + MCP server for the CLIs on this machine; --yes writes pnpm relay link # in a repo (bin/relay link from elsewhere): digests, summary, relay.md, owned sections, workflows, labels; idempotent pnpm relay brief 42 # the memory envelope a lane would get for issue 42 pnpm --filter @relay/web dev # mock data RELAY_DATA=db pnpm --filter @relay/web dev # the CLI's PGlite file (stop the CLI first; PGlite is single-process) pnpm --filter @relay/web build && pnpm --filter @relay/web typecheck pnpm relay token create --name ci --scopes feed:read,assets:write # bearer token for /api/v1 and /mcp, shown once RELAY_PUBLIC_URL=https://relay.kyro.house pnpm --filter @relay/web check:agentic # is-agentic score gate, after deploy ``` Memory commands (`docs/MEMORY.md`): `relay brief`, `relay recall`, `relay decide`, `relay prefer`, `relay capture`, `relay memory history`. Hooks call the CLI through the repo's own `tsx`, so the checkout must stay where `relay setup` ran (re-run it after moving). ## Conventions - TypeScript strict, ESM, `.ts` imports with extensions. No default exports except Next.js pages/routes. - Plain SQL migrations in `packages/core/src/db/migrations/NNN_*.sql`; never edit a shipped migration. - Every model call goes through a lane adapter and writes a `step.usage` event. Nothing calls a model directly. - Official vendor sources only for model guidance (`onboard/registry.ts` allowlists domains). Do not add community prompt guides. - Secrets live in environment variables, never in files or examples. `.env.example` lists names only. - Keep `AGENTS.md`, `llms.txt`, and `README.md` accurate when you change commands or layout; CI checks that README commands exist. - Commit messages: imperative, one topic each. Do not commit generated output, databases, or anything under `.harness/`. ## Installing relay for a user (agent instructions) If a user asks you to set up relay, do these steps and stop at anything that needs their browser: 1. `git clone <repo> && cd relay && pnpm install` 2. `pnpm relay doctor` — reports which CLIs are installed and logged in (`claude`, `codex` or `fx`, `grok`, `gh`), Node version, and database reachability. Tell the user what is missing; logins are theirs to do. 3. `pnpm relay setup` — prints every file it would touch in `~/.claude`, `~/.codex`, `~/.grok` (hooks and the memory MCP server) and writes nothing; run `pnpm relay setup --yes` only if the user said so. Run it through `pnpm` so the hooks record the pinned Node 24. 4. In each repo they want managed: `cd <repo> && <relay checkout>/bin/relay link`. This is the only per-repo step and runs the whole pipeline: registers the project and hashes its files, digests the changed files and writes the project summary (Sonnet lane, needs the `claude` login; `--no-digest` skips it, `--max-files` caps it at 400 by default), writes `relay.md` from that summary, adds the owned section to `AGENTS.md`/`CLAUDE.md`, the GitHub labels (`gh`), and the caller workflows. It prints every stage and ends with one cost line. Re-running is safe and makes no model calls unless files changed; `--refresh` rewrites the summary. Do not run `relay project digest` or `relay project summarize` by hand. 5. Optional, for runners: follow `docs/RUNNERS.md` to register a self-hosted GitHub Actions runner on a machine that holds the logins. 6. Optional, for a model the user will prompt (image/video models): `pnpm relay onboard <provider/model>`. Never move or copy a login, token, or `auth.json` between machines. Never run with permission checks disabled on a machine that holds the user's logins. --- <!-- /docs/readme --> # relay **Your coding-agent subscriptions, working as one system.** relay turns Claude Code, Codex, and Grok Build into a team that shares memory, picks up work from GitHub, runs on machines you own, and shows you everything it did and what it cost. No new agent to learn, no tokens leaving your machines. > Status: alpha. The pieces work individually; the first end-to-end lane is being wired. Watch the feed at a demo soon. ## What you get - **One memory.** Decisions made in Codex show up in Claude and Grok. A generated rules file in each repo plus a small MCP server keep every tool on the same page. - **Work from anywhere.** File an issue from your phone, a chat bot, or a terminal. A runner on your own machine picks it up, opens a PR, and your deploy preview appears. - **Every dollar and token accounted for.** An event feed records what each agent did, on which subscription, at what cost. You see which plan is about to run out before it does. - **Vendor guidance built in.** Before relay lets a lane use a model, it ingests that vendor's official docs and skills, so prompts for image and video models follow the maker's own guide. ## Quick install Paste this into whichever coding agent you use: ```text Set up relay from https://github.com/kyleroche/relay following its AGENTS.md "Installing relay for a user" section. Stop and tell me when a step needs my browser. ``` Or by hand: ```bash git clone https://github.com/kyleroche/relay && cd relay # needs Node 24+ (.node-version) and pnpm pnpm install pnpm relay doctor # what is installed, logged in, and reachable pnpm relay setup # hooks + memory server into each CLI you have cd ~/code/some-repo && ~/relay/bin/relay link ``` `relay link` is the only per-repo step: it registers the repo, digests its files and writes the project summary (Sonnet, a few cents), renders `relay.md` from that summary, and installs the owned sections, caller workflows and labels. It prints each stage and one cost line, and a second run with nothing changed makes no model calls. `--refresh` rewrites the summary after memory changes; `--no-digest` skips the model steps. ## Link your model accounts relay never asks for a token. Log in to each tool you own, on each machine that will run work: | Tool | Login | Plan | |---|---|---| | Claude Code | `claude` then `/login` | Pro or Max | | Codex | `codex login`, or `fx login codex` | ChatGPT | | Grok Build | `grok login`, or `fx login grok` | SuperGrok | | GitHub | `gh auth login` | any | `relay doctor` tells you which ones it found. Machines without a login simply do not get that lane's label. ## Memory in daily use Once `relay setup --yes` has run, every Claude Code, Codex or Grok Build session that starts inside a linked repo begins with that project's brief (decisions, preferences, summary; ≤2.5K tokens, cached), each prompt in Claude Code and Grok Build is prefixed with the decisions that match it (≤1K tokens), and the session's durable decisions are extracted when it ends. The same memory is one `relay_recall` / `relay_decide` tool call away through the `relay` MCP server. By hand: ```bash pnpm relay decide "We use vitest because it runs PGlite in-process" pnpm relay prefer commit_style "imperative, one topic" --scope project pnpm relay recall "which test runner" pnpm relay memory history ``` See [docs/MEMORY.md](docs/MEMORY.md) for the data model, the brief compiler and the hooks. ## How it fits together Issues are tasks. Labels pick the lane. Self-hosted runners on your machines run the vendor CLI in a fresh worktree and open a PR. Postgres holds memory and the event feed. A small web app shows the feed, projects, and usage. Read [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the rules and the task lifecycle. ## Hosting The web app deploys to Vercel with a Neon Postgres database. Locally it runs on an embedded Postgres with no setup. See `docs/HOSTING.md`. ## Contributing Read [AGENTS.md](AGENTS.md) first; it is written for both people and agents. Apache-2.0.