atlas/relay

Public: anyone can read this; nobody can change it.

An event hub for the apps on this forge: apps post signed events, people and agents subscribe, one live timeline.

forked from trevato/relay

workbenchthis machine

This repository has no app yet.

following trevato/relay in step

Everything here is up to date with trevato/relay.

Proposes updates from trevato/relay.

History

README.md

relay

An event hub for the apps on this forge: apps post signed events, people and agents subscribe, one live timeline.

This is an app: the forge runs it. Production follows the default branch; every open pull gets a preview at its own address with its own copy of the data. Everything the app keeps lives under FORGE_APP_DATA, which is what makes "production data" and "preview data" two different things.

PORT=8080 FORGE_APP_DATA=./data bun run src/server.ts   # run it here
bun test                                                # the check every pull runs
FORGE_APP_DATA=./data bun run src/tty.ts                # the terminal
FORGE_APP_DATA=./data bun run src/db.ts --check         # schema version, integrity

Posting events

Another app posts a signed event with /client/relay.ts — one file, no build step, no dependency, pasted straight into a Bun or Node 20 app with curl https://<app>/client/relay.ts. See /adopting on a running relay for the fields, how to get a key, and the curl equivalent.

The shape

One implementation, many doors. src/actions.ts is every operation as a plain function — (db, who, input) in, data or a named failure out — and the page, the JSON API, the MCP server and the terminal all call it and decide nothing themselves. AGENTS.md says how to add to it.

filewhat it is
src/actions.tspost, list, get an event — and who may
src/apps.tsan app's key, derived from the identity key, never stored
src/who.tswho is asking, from the forge's signed headers
src/db.tsSQLite under FORGE_APP_DATA, WAL, forward-only migrations
src/live.tsthe in-process bus and GET /events
src/mcp.tsthe actions as MCP tools at POST /mcp
src/routes.tsevery path, in one table
src/page.tsthe HTML, rendered on the server
src/http.tsthe floor: headers, body cap, same-origin, files
src/ration.tshow much a caller may write, and exactly when to retry
src/server.tsboot: open the database, serve
src/tty.tsthe terminal, over the same actions
src/env.tswhat the forge told this process
static/app.css, static/live.jsthe look, and the list that stays current

An event

One table, event, and one row an app posts and anyone reads:

columnmeaning
sourcewho posted it: lowercase letters, digits, hyphens, up to 32 chars
kindwhat happened: dotted lowercase segments, e.g. order.placed
subjectthe one-line headline, 1–120 characters
summarythe detail, 1–200 characters
linkoptional, an absolute http(s) URL, up to 512 characters
occurred_atwhen it happened, ISO 8601, defaults to now
received_atwhen relay stored it
dedupeoptional idempotency key, unique per source: post it twice, get the same row back

subject and summary refuse control characters and newlines rather than stripping them, and a link's scheme is checked here so no other door has to remember.

An app's identity

Registering an app (registerApp in src/actions.ts, admin only) makes a row in app; its posting key is never stored, only derived from the id, a generation and the forge's identity key (src/apps.ts), and shown once, at the moment it's issued. Rotating (rotateAppKey) bumps the generation, which is what makes the previous key stop verifying — there is no key to delete. Revoking (revokeApp) sets a column rather than removing the row, because a derived key stays computable forever and the check that matters is whether it's still current, not whether the row exists. relay itself is a reserved id — nothing can register an app under it — because relay is a poster on its own timeline (below), and its source must stay unforgeable. The terminal (src/tty.ts, below) reaches all three; no page or JSON route does yet (issue #71), so today only the terminal or a script working directly against src/actions.ts can register, rotate or revoke.

relay posts its own events

An event hub with an empty timeline has nothing to show. relay is an app on this forge like any other, and it posts about its own actions the same way anything else does — through postEvent, with an identity it builds for itself (RELAY in src/actions.ts), never a bare INSERT. Two kinds:

  • app.registered, app.rotated, app.revoked — one each time an admin

registers, rotates or revokes an app (src/actions.ts). subject is the app's id; summary is one line naming the app and who did it, e.g. "juniper registered bazaar". Never the key, the secret or anything derived from either — only the id and the two names ever reach the row.

  • relay.deployed — one per boot, posted in the web process only (never

the terminal), after the listener is up (src/server.ts). subject is the short commit; summary names the environment, e.g. "production, commit bbf8ef9"; dedupe is the commit, so a restart, a crash loop or a second process never repeats it. A local run with no commit configured posts nothing.

Posting is best-effort: if it fails, the failure is logged and the write it followed — a registration, a rotation, a revocation — still succeeds. An event on a write path is a second thing that can fail, and it must never be the reason the first thing does.

Naming the admin who acted puts a forge username on a public timeline. That's deliberate, not an oversight: it's the same username the forge already shows next to "Sign out" on relay's own page, nothing more identifying, and it's what makes the timeline a true account of who did what.

Who you are

The forge says who is asking on every request it proxies, and the app verifies the signature (src/who.ts); nothing else is trusted. Four tiers:

  • anon — anyone. Reads.
  • user — signed in to the forge and able to see this app. Reads, same as

anon; posting an event is not for a person.

  • admin — able to write this repository. Posts an event under any

source it names.

  • app — an app on the forge whose signed request this app has verified

(its own issue); posts under its own source only, and cannot claim another's.

"Sign in" is /__forge/signin?to=<path> and "Sign out" is /__forge/signout; the forge answers both on the app's own host.

Any interface

The same actions, the same rules, at every door.

curl https://<app>/api/events
curl https://<app>/api/events?source=shop&kind=order&limit=20
curl https://<app>/api/events/1
curl -X POST https://<app>/api/events -H 'content-type: application/json' \
  -d '{"kind":"order.placed","subject":"Order #42","summary":"A new order came in.","source":"shop"}'
curl https://<app>/api/sources

GET /api/sources is the directory: one entry per source that has ever posted, newest activity first, each with its busiest kinds — at most 50 sources and 10 kinds per source, so it stays cheap no matter how much a source invents. A source with more kinds than that carries more_kinds rather than lying about its total. The same directory sits above the timeline on /, each source and kind a link that narrows it.

Writes are rationed per person, twenty in hand and two a second (per address for a stranger, since a person is their own bucket — and behind the forge every anonymous caller shares its one address, so a busy anonymous agent shares its bucket with every other stranger; one that wants its own signs in, or, once apps can post as themselves (#8), posts as an app instead). A read is rationed apart from a write, on its own larger bucket — 120 in hand and 20 a second, since an agent paging a timeline in a burst is not a person typing a form. /mcp costs whichever its JSON-RPC method does: initialize, ping, tools/list and a tools/call of a read-only tool are reads; post_event and any other writing tool are writes, decided from the tool's own declaration, never from its arguments. A write over MCP costs both buckets — the read floor /mcp always pays before its body is parsed, and the write bucket on top once the method is known — so an agent that has burnt its read bucket cannot post even with write tokens still in hand. Over either ceiling, a call is refused with 429 and a retry-after in whole seconds; the JSON doors (/api/* and /mcp) also carry the exact wait, so a script can sleep precisely rather than a rounded second too long:

{ "error": "too_many_requests", "message": "...", "retry_after_ms": 500 }

As an MCP server, for Claude Code or Claude Desktop — list_events, post_event, list_sources, get_event:

claude mcp add --transport http __TITLE__ https://<app>/mcp
{
  "mcpServers": { "__TITLE__": { "type": "http", "url": "https://<app>/mcp" } }
}

The page stays current over /events: an event another app posts appears without a reload. Without JavaScript it is the same page, one reload behind.

The terminal opens with the exact command in FORGE_APP_SSH when the app runs (ssh -p 2222 <name>@<forge> — the app is the login name, the forge the host; <name>--<pull>@<forge> for a preview's). It is src/tty.ts on a real pty over the same data, as the account at the keyboard: j/k move, q leaves, p switches between the timeline and the app registry — any key redraws with fresh data, so there is no separate refresh key. In the registry an admin presses n to register (id, then name, the same two fields any door would ask for), r to rotate the app under the cursor, and x to revoke it — the same registerApp, rotateAppKey and revokeApp as any other door, refusing a guest or a plain user exactly as they would anywhere else, immediately and without opening a prompt for a key the footer does not even offer them. Rotating and revoking ask first, naming the app (Revoke Beacon (beacon)? y/n), because both are one-way on a single keypress; the app under the cursor is tracked by id, not by row position, so registering a new one — which sorts to the top — never silently moves the cursor onto a different app. A freshly issued key prints once, its own screen, and is gone the moment anything else is drawn — it is never held in the state the status line redraws.

The terminal is its own process, so a write made there does not reach an open page over /events the way a write from another process's own door would (src/live.ts); it shows up the next time that page loads, until issue #65 gives every process a database watcher instead of an in-process bus. Issue #71 tracks the other side of the asymmetry this leaves: the page and the JSON API still cannot register, rotate or revoke — only the terminal (and a script working directly against src/actions.ts) can.

The footer prints that command verbatim, quoted, never a guessed form. The forge sits behind a proxy that rewrites anything shaped like an email address, so the command is wrapped in <!--email_off--> and its host sits in its own element — readable and copyable without JavaScript, even behind the rewrite. With FORGE_APP_SSH unset the footer says the terminal is off.

The forge sets PORT, FORGE_APP_DATA, FORGE_APP_ENV (production or preview), FORGE_APP_URL, FORGE_APP_COMMIT, FORGE_APP_SSH and FORGE_APP_IDENTITY_KEY when it starts the app. forge.json says how.

↑↓ navigate ↵ open esc close

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs