Skip to content

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.

Terminal window
CGO_ENABLED=0 go build -o askrelay ./cmd/askrelay
ASKRELAY_BASE_URL=https://relay.example.com \
ASKRELAY_DB=/var/lib/askrelay/askrelay.db \
./askrelay serve

base-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.

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.

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.

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-ttl regardless 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. A pending_review draft is live work and is never swept, however old it is.
  • Replay tombstones — pruned exactly at the freshness horizon, using the same -fresh-max-age the 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.

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 its 0600 permissions.

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.

  • 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-attested from 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 /healthz returns 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):

Terminal window
askrelay device list -db askrelay.db
askrelay device revoke <device-id> -db askrelay.db

No 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.