Instructions for coding agents working on outlay. Read this before editing.
The locked design lives in design/design.md — it is the
source of truth for architecture and decisions; do not relitigate it without an
explicit change request.
outlay is a Nostr relay exposed as a ContextVM (CVM) server: a transparent
proxy that binds CVM tool calls to NIP-01 relay traffic. A CVM client calls
subscribe/publish_event/relay_info; outlay translates each into the corresponding
NIP-01 exchange with an upstream relay, streaming relay events back over CEP-41
open-stream. Point it at any relay (clearnet or localhost) and anything that
speaks CVM can read/write that relay through it.
Stack: Rust 2021, MSRV 1.88. contextvm-sdk 0.2.2 (CVM transport +
RelayPool), rmcp 1.8 (MCP server handler + #[tool] macros; also re-exports
schemars for tool-param derives), nostr-sdk 0.44 (upstream relay client +
NIP-01 types), tokio, serde/serde_json, thiserror, anyhow, tracing,
dotenvy (.env loader).
Core invariant (do not break): one CEP-41 open-stream == one NIP-01
subscription. The stream's lifetime is the subscription's lifetime; cancelling
the call is CLOSE.
Two independent relay connections inside the server (keep them distinct):
- The upstream
Proxy— outlay's ownnostr-sdkClientconnected to the single configured upstream relay (throwaway ephemeral key; events forwarded verbatim, never re-signed). - The CVM transport (
NostrServerTransport) that CVM clients connect through, on the ContextVM relays.
The repo is a Cargo workspace with two crates (no shared core — they don't talk
in-process, only over CVM). crates/outlay is the server above; crates/outlay-shim
is a localhost WS/HTTP bridge that lets vanilla NIP-01 clients reach CVM-exposed
outlay servers. It is a CVM client, not a relay, and does not depend on the
outlay crate. Design: design/shim.md.
cargo run -p outlay-shim # serves http://127.0.0.1:8088/<server-pubkey>
cargo test -p outlay-shim # config + path + translate unit tests
cargo test -p outlay-shim --features test-utils --test smoke -- --ignored --nocapture
# end-to-end smoke (hits the network: primal)
cargo clippy -p outlay-shim --features test-utils --all-targets -- -D warnings
cargo fmt --all # formats both cratesA vanilla client connects at ws://127.0.0.1:<port>/<server-pubkey> (hex, npub,
or nprofile); / and /<pubkey> also serve NIP-11 over HTTP (content-negotiated:
JSON for Accept: application/nostr+json, HTML for browsers).
cargo build # build the bin + lib
cargo run # run the server (needs OUTLAY_PROXY_RELAY_URL)
# Self-contained (bundled in-process relay as the upstream — no external relay):
OUTLAY_BUNDLED_RELAY=1 cargo run --features bundled-relayRequired env (see crates/outlay/src/config.rs for the full set and .env loader):
OUTLAY_PROXY_RELAY_URL— the upstream relay to proxy. Required unless the bundled relay is enabled (OUTLAY_BUNDLED_RELAY=1), which supplies the upstream itself.OUTLAY_CVM_RELAYS— CVM relays to listen on (defaultwss://nostr.wtf).OUTLAY_SERVER_PRIVATE_KEY— hex/nsec; unset → ephemeral.OUTLAY_BUNDLED_RELAY—1/trueruns the in-process relay (bundled-relayfeature) as the loopback upstream. With it:OUTLAY_BUNDLED_BACKEND(sqlitedefault |memory),OUTLAY_BUNDLED_DB_PATH(defaultoutlay-relay.db),OUTLAY_BUNDLED_PORT(default0= scan a free loopback port).
.env and .env.local are loaded first-write-wins, then the process env.
Run the server against a real relay:
OUTLAY_PROXY_RELAY_URL=wss://relay.primal.net cargo run
OUTLAY_PROXY_RELAY_URL=ws://localhost:8080 cargo run # local relayFast feedback loop while editing:
cargo check # fast type-check
cargo fmt --all # format
cargo clippy --all-targets -- -D warnings # lint (warnings are CI failures)The reference/ directory is read-only and gitignored — vendored copies of
rs-sdk, cordn-rs, nostr, nostr-rs-relay, and the NIPs. Treat it as a
library of examples to learn from, never as something to edit or as part of the
build (it is not a Cargo workspace member and not required to compile outlay).
Two surfaces — know which one you are touching.
Unit tests — no network, instant. Config parsing and the pure demux
(proxy::map_notification):
cargo testSmoke tests — end-to-end, hit the network. A real CVM client (over a mock
relay — network-free client↔server CVM hop) drives the outlay server, whose
upstream Proxy connects to wss://relay.primal.net for real:
cargo test --features test-utils --test smoke -- --ignored --nocapture- Gated behind the
test-utilsfeature (pullscontextvm-sdk'sMockRelayPool). - Each test is
#[ignore](touches the network). Always pass--ignored. --nocaptureshows the streamed events; useful when debugging the proxy.- Run one:
cargo test --features test-utils --test smoke subscribe -- --ignored --nocapture. - The upstream URL is the
UPSTREAMconst at the top ofcrates/outlay/tests/smoke.rs(not an env var) — change it there to point the smoke tests at a different relay.
Bundled-relay smoke tests — network-free E2E for the bundled-relay feature:
the upstream is the in-process LocalRelay (loopback) and the CVM hop is mocked,
so no internet is touched. Not #[ignore], but gated on both features:
cargo test --features "test-utils bundled-relay" --test smoke_bundled -- --nocaptureDefault cargo test (no --features test-utils) skips the smoke binary
entirely. Keep it that way: do not remove the #[ignore] or the feature gate.
When you change behavior, add or update a test. Non-trivial pure logic gets a
unit test (see how map_notification is tested without any relay); plumbing
gets a smoke test.
cargo fmt --allandcargo clippy --all-targets -- -D warningsmust be clean before commit. Theclippy::redundant_closureandcloned_ref_to_slice_refslints have already bitten us — prefer the clippy-suggested form.- Cargo workspace.
crates/outlayis the server (bin+lib);crates/outlay-shim(planned — seedesign/shim.md) is the vanilla-NIP-01 bridge. They share no code: the shim is a CVM client, not a dependency of outlay.src/lib.rsre-exports the server's modules sotests/can import them;src/main.rsis a thin bin overoutlay::. - New rmcp tools: derive params with
serde::Deserialize+schemars::JsonSchema.nostrtypes (Filter,Event) do not implJsonSchema— accept them as rawserde_json::Valuein tool params and parse inside the handler (seecrates/outlay/src/handler.rs). The rmcp-side chunk framing is hand-built withserde_json::json!. - Mirror
cordn-rspatterns where applicable: the concreteStreamWritersink and theselect! { recv / sleep(is_active poll) }loop incrates/outlay/src/proxy.rsare ported fromcordn-server/src/adapter.rs.
These are settled in design/design.md. Highlights that are easy to get wrong:
- Use
RelayPoolNotification::Message, not::Event. TheEventvariant dedupes at pool level and excludes events sent by our own client — both break transparent proxying.Messagefires per-subscription, carries thesubscription_id, and includes every event (NIP-01 semantics). - Per-call random upstream
SubscriptionId, mapped back to the client's bare sub_id in chunks. The pool multiplexes one upstream socket; two CVM clients that both pick"sub1"would otherwise cross-receive. (The design doc's original<uuid>::<sub>was abandoned — it can exceed NIP-01's 64-char limit.SubscriptionId::generate()is length-safe.) - Forward events verbatim, never re-sign.
publish_eventusesclient.send_event(&event)(pre-built), notsend_event_builder. Re-signing turns this into a publisher, not a proxy. - Multi-filter REQs go through
client.pool().subscribe_with_id(id, Vec<Filter>, ..)— the SDK'sClientwrapper only takes a singleFilterand would replace the sub on each call. Reach the pool viaclient.pool(). - Register the notifications receiver BEFORE
subscribe_with_idso the first EVENT/EOSE cannot be missed in the race between subscribe and recv. - Always unsubscribe on exit (client abort, upstream CLOSED, or pool death)
— the
subscribeloop's cleanup is unconditional.
Local release build:
cargo build --release # release bin at target/release/outlayRelease profile: LTO, single codegen unit, stripped (root Cargo.toml — Cargo
ignores [profile] in workspace members). Linux only (amd64 + arm64).
Cutting a release is fully automated via the Makefile + GitHub Actions:
make version # print the current shared version
make patch # or minor / major → bumps, commits,
# tags v<ver>, and pushes. The tag push
# triggers .github/workflows/release.yml.The release workflow builds outlay + outlay-shim natively per arch (free
arm64 runners — no cross-compile), uploads the tarballs + a SHA256
checksums.txt to the GitHub Release, and builds multi-arch Docker images for
both outlay and outlay-shim (each packages the prebuilt binary; no Rust
compile inside Docker), pushed to ghcr.io as :<version> and :latest.
.github/workflows/ci.yml gates every PR with fmt + clippy (both feature
configs) + unit tests + the network-free bundled-relay E2E.
- The upstream
Proxycannot be mocked. It uses the concretenostr-sdkClient(forpool().subscribe_with_idandnotifications()), whichRelayPoolTrait/MockRelayPooldo not expose. That is why the upstream hop is exercised by the network smoke tests against a real relay, not a unit test. The demux is unit-tested by extracting it as a pure function. publish_eventloses the upstream's exact OK message prefix (duplicate:,blocked:, …).send_eventaggregates per-relay OKs into success/failed sets and surfaces the relay error text, not the raw frame. Good enough for v1; noted incrates/outlay/src/proxy.rswith aponytail:comment and an upgrade path (send_msg+ awaitOkby event_id).relay_infoproxies the NIP-11 doc as a rawserde_json::Value, not the typedRelayInformationDocument. The typed struct drops unknown fields (e.g. primal'snegentropy), which would violate verbatim proxying. Don't "improve" it to the typed struct. The overlay stamps outlay'ssoftware/version/proxyand stashes the upstream's underupstream.- Authz is deferred (open proxy in v1).
allowed_public_keysis not wired — by design, not oversight: authz depends on the trust model the companion WS shim will expose, so it lands after the shim. Do not expose the server on untrusted networks meanwhile. - Single upstream only (v1). Multi-relay fan-in makes EOSE semantics ambiguous; deferred.
- outlay-shim's loopback shortcut misfires in local dev. When
OUTLAY_SHIM_PUBLIC_URLSis unset, the bridge infers the shim's own URL fromOUTLAY_SHIM_CVM_RELAYS(correct for the collapsed public deploy, where the shim is its CVM relay) and rewrites any matching dial to the colocated loopback relay. Locally that inference is wrong — the shim islocalhost, the CVM relay is the realwss://nostr.wtf— so the bridge loops back to its own (empty) relay and the/<pubkey>page won't resolve. Run local dev withOUTLAY_SHIM_RELAY=false(no colocated relay → no shortcut → dials the real relay), or setOUTLAY_SHIM_PUBLIC_URLS=http://localhost:<port>to keep the relay on. The inference can't simply be dropped: the collapsed deploy would then have to declare its public URL explicitly (design/shim.md§12.1).
- Title format:
area: brief description(e.g.proxy: forward CLOSED as a stop chunk). - Required before commit:
cargo fmt --all,cargo clippy --all-targets -- -D warnings,cargo test. - If you change proxy/forwarding behavior, run the smoke tests too:
cargo test --features test-utils --test smoke -- --ignored --nocapture. - Update
design/design.mdwhen a locked decision actually changes (not for implementation detail) — and call it out in the PR description.