Self-host & operate
The relay is one static binary plus one SQLite file. It listens on localhost by default; TLS is the reverse proxy’s job. localhost, LAN, and a public VPS are the same code — the difference is only where you point the proxy.
CGO_ENABLED=0 go build -o askrelay ./cmd/askrelay
ASKRELAY_BASE_URL=https://relay.example.com \ASKRELAY_DB=/var/lib/askrelay/askrelay.db \./askrelay servebase-url is required and must be the public https:// URL clients reach — it
is the OAuth issuer/audience and the base for enrollment links, so it must match
what the proxy serves.
Built, not yet wired · WP-14
You build from source. There are no prebuilt binaries and no published
container image — pointing docker pull at anything will not find one. What does
work today is the Dockerfile in the repository’s docs/deploy/, which builds
the binary itself; that container path is now the documented default deploy, and
a PaaS that terminates TLS for you (Coolify, Dokku, Fly) makes the reverse-proxy
section below optional. Give it one persistent volume covering both the SQLite
file and signing.key — losing either loses every enrollment. Publishing
images and binaries is WP-14.
TLS via reverse proxy
Section titled “TLS via reverse proxy”Terminate TLS at Caddy, nginx, or Traefik and proxy to the relay on localhost.
Caddy auto-provisions certificates; a minimal Caddyfile ships in the
repository’s docs/deploy/.
Your proxy’s read/response timeout must exceed the long-poll cap. The relay
sets ReadHeaderTimeout 10s, ReadTimeout 30s, and IdleTimeout 120s, but
deliberately sets no global WriteTimeout: wait_for_activity long-polls
for up to 25 minutes on the claude-code-remote profile (240s for
claude.ai and claude-desktop, 45s otherwise), and a global write deadline
would sever those legitimate long-polls. A proxy with a 60s response timeout in
front of it will cut them for you instead.
Ingress is pre-bounded at the relay — /mcp bodies at 128 KiB, enrollment
bodies at 4 KiB, /ws inbound frames at 128 KiB. Mirroring those caps at the
proxy costs nothing and moves the rejection one hop further out.
Configuration (flags + env only)
Section titled “Configuration (flags + env only)”Precedence is flag > env > default. Retention/freshness durations have a 1-hour
floor; serve refuses to start below it. -fresh-max-skew must be in
(0, 1h], and base-url must be an absolute http(s) URL.
| Flag | Env | Default | Meaning |
|---|---|---|---|
-listen |
ASKRELAY_LISTEN |
127.0.0.1:8080 |
listen address |
-db |
ASKRELAY_DB |
askrelay.db |
SQLite path |
-base-url |
ASKRELAY_BASE_URL |
(required) | public https URL |
-signing-key |
ASKRELAY_SIGNING_KEY |
signing.key beside the DB |
Ed25519 token key |
-invite-ttl |
ASKRELAY_INVITE_TTL |
24h |
invite lifetime |
-ack-grace |
ASKRELAY_ACK_GRACE |
72h |
delete acked messages this long after last ack |
-hard-ttl |
ASKRELAY_HARD_TTL |
720h |
delete any message this long after receipt |
-fresh-max-age |
ASKRELAY_FRESH_MAX_AGE |
24h |
reject/forget messages older than this |
-fresh-max-skew |
ASKRELAY_FRESH_MAX_SKEW |
5m |
reject messages this far in the future |
Rate-limit the OAuth endpoints (required for browser connectors)
Section titled “Rate-limit the OAuth endpoints (required for browser connectors)”Dynamic Client Registration (POST /oauth/register) is unauthenticated by the
OAuth spec — a client must register before it holds a token. The relay caps the
resulting growth (abandoned client rows are pruned after ~30 days), but you
must also rate-limit that endpoint at the proxy before exposing the
browser-connector path publicly. Example (nginx):
limit_req_zone $binary_remote_addr zone=oauth_reg:10m rate=6r/m;server { location = /oauth/register { limit_req zone=oauth_reg burst=3 nodelay; proxy_pass http://127.0.0.1:8080; } location / { proxy_pass http://127.0.0.1:8080; }}Caddy needs the caddy-ratelimit module; Traefik has a rateLimit middleware.
Until the endpoint is rate-limited, keep the relay on localhost/LAN.
Retention sweeper
Section titled “Retention sweeper”The sweeper runs hourly, and the interval is not configurable — that is
deliberate: the grace and TTL horizons you tune are hours-to-days, so an hourly
pass is ample. Tightening -ack-grace to 2h does not make deletion prompt to the
minute; it makes it prompt to the hour.
Each pass deletes:
- Messages — fully-acked ones past
-ack-grace, and any message past-hard-ttlregardless of acks. Per-device delivery rows cascade with them; threads, grants, and tombstones survive. - Drafts — decided (
sent/discarded) drafts past the hard TTL, keyed on when they were decided. Apending_reviewdraft is live work and is never swept, however old it is. - Replay tombstones — pruned exactly at the freshness horizon, using the same
-fresh-max-agethe ingest path uses. There is no separate TTL that could be set below it and silently reopen the replay window. - Expired OAuth codes and refresh tokens, plus abandoned DCR client rows.
A failed sweep is logged and retried on the next tick — it is never fatal.
Backup
Section titled “Backup”Back up two things together:
- The SQLite database — a consistent snapshot, not a raw copy of a live
WAL file:
sqlite3 askrelay.db ".backup '/backup/askrelay.db'". It mostly holds transient messages by design (ephemeral retention). signing.key— the Ed25519 token-signing key beside the DB. Losing it forces everyone to re-enroll; leaking it enables token forgery. Back it up encrypted and keep its0600permissions.
Separately, each person’s own machine holds secrets after askrelay enroll:
~/.config/askrelay/ contains their device key and a long-lived device
credential in config.json, because askrelay daemon and askrelay mcp
authenticate to the relay with it. Both are written 0600. Treat that directory
like a key, not like a config — and if a laptop is lost, askrelay device revoke
is the fix.
Operating notes
Section titled “Operating notes”- Revocation is immediate for the WebSocket credential — checked at connect,
on every inbound frame, and by a 15s reconcile ticker — and for new token
issuance; an already-issued access token lasts until it expires (≤1h). It now
also extends to envelope signatures: verification looks the signing key up
among active devices, so a revoked device’s messages read
relay-attestedfrom that moment on. See Security for what is and is not verified. - Shutdown is graceful. On SIGINT/SIGTERM the relay drops WebSocket connections first so their read loops exit, then drains in-flight HTTP requests within a 15s grace.
- Health:
GET /healthzreturns status + version for your load balancer. - Logs are structured (
slog) and never contain message content, secrets, or tokens — ids and shapes only. - systemd unit and Dockerfile stubs ship in the repository’s
docs/deploy/.
Revoke a device with the CLI, on the relay host (it opens the database directly):
askrelay device list -db askrelay.dbaskrelay device revoke <device-id> -db askrelay.dbNo restart is needed: the relay re-reads devices.revoked_at on every credential
check, and the 15-second reconcile ticker severs any socket the revoked device
still holds.
Planned · WP-12
The remaining operate verbs — inbox, approve, and status — are not
built. Approvals happen through your AI client’s MCP tools.
Verified against askrelay 2e019ea on . Anything newer than that commit is not reflected here.

