Gateway, pairing & remote access

Every vis channel talks to one long-lived gateway daemon: an HTTP + SSE runtime that owns the sessions, turns, and the live event bus. You rarely start it by hand; a channel spawns it for you. This page explains its lifecycle, why vis gateway start stays in the foreground, the token model (and the HTTP 401 you hit on --host 0.0.0.0), and how to pair a phone over LAN or Tailscale.

The gateway starts itself (in the background)

When you run a client such as:

vis tui

the client looks up the gateway registered for the current database (~/.vis/vis.mdb by default). If none is alive it spawns one, fully detached — on unix under nohup … &, reparented to init, with its stdout/stderr captured to a per-database boot log under ~/.vis/gateway/. That daemon is client-managed: it self-reaps once the last client disconnects. So the normal flow is just "start the TUI" — the background gateway is automatic, and a herd of clients all attach to the same one.

You do not need to run vis gateway start yourself for local use.

Why vis gateway start does not go to the background

vis gateway start is deliberately a foreground daemon: it prints its connection line and parks until you stop it with Ctrl-C / SIGTERM. It is meant for running the gateway as a supervised, user-owned process (a systemd/launchd unit, a container entrypoint, a tmux pane) — not for a throwaway shell. A foreground vis gateway start is not refcounted, so it will not self-reap when clients come and go.

To run it detached yourself, background it explicitly:

# quick-and-dirty
nohup vis gateway start --host 0.0.0.0 --require-token > ~/.vis/gateway.out 2>&1 &

# or let a client auto-spawn the managed background daemon for you
vis tui

Inspect and control the daemon:

vis gateway status     # pid, url, db, client count, auth mode
vis gateway stop       # ask the running daemon to exit

The token model — and the HTTP 401

Auth is gated on the bind host:

Bind Bearer token Why
127.0.0.1 (default) off a single-user localhost daemon; the token dance is pure friction
any non-loopback (0.0.0.0, LAN, Tailscale) required the port is reachable by other hosts
127.0.0.1 --require-token required force the token on loopback too

The token is a secret minted on first run into ~/.vis/gateway.token (mode 600); override with --token-file PATH. A client on the same machine reads that secret from the gateway's on-disk registry automatically, so a local TUI authenticates transparently even against a --host 0.0.0.0 daemon. Remote clients (a phone, another machine) must be handed the token — that is what pairing does.

If you ever see:

vis: fatal error - gateway HTTP 401
{:error {:type "unauthorized", :message "missing or invalid bearer token"}}

it means the client reached a token-gated gateway without a valid token. The usual causes: connecting from a different machine without pairing, or a stale/rotated gateway.token. Fixes: run the client on the same host as the gateway, re-pair the remote client, or restart the gateway on loopback (vis gateway start).

Pairing a phone (mobile companion)

Start the gateway on a reachable host and print a pairing QR:

vis gateway start --host 0.0.0.0 --require-token --pair

--pair prints a terminal QR encoding a tiny URL payload:

vis://gateway?url=http%3A%2F%2F<host>%3A7890&token=<bearer-token>

In the companion app, open Gateways → Add a gateway → Pairing link and tap Scan QR (or paste the link into the field and tap Pair). The QR also lists the reachable hosts it picked, in preference order: Tailscale addresses first (they keep working off-LAN), then LAN (10.x / 192.168.x / 172.16–31.x), then the concrete bind host.

Connecting from the companion app

Open the companion (web, iOS, or Android). Its first screen is Gateways. Under Add a gateway there are two ways in:

  • Pairing link — the fastest path, and the only one that also carries the token. Either tap Scan QR and point the camera at the QR from vis gateway pair (or vis gateway start … --pair), or paste the vis://gateway?url=…&token=… link into the field and tap Pair. Both fill in the URL and bearer token together, so there is nothing else to type.
  • URL + token — for a gateway whose address you already know. Enter the gateway URL (LAN, Tailscale, or a Cloudflare tunnel address) and, when it is token-gated, the bearer token, then tap Connect. The token is optional only for a loopback (127.0.0.1) gateway.

Each saved gateway then carries a live status dot, re-probed every six seconds: green online (with the round-trip in milliseconds), red offline, amber unauthorized — reachable, but the token is missing or wrong. Tap a row to open its Settings; tap the active row to reconnect.

Pairing a gateway that is already running

The TUI (and other channels) auto-spawn a loopback gateway on first launch, so there is usually one running already — but bound to 127.0.0.1, which a phone can never reach. To pair a running daemon without a start flag, use:

vis gateway pair

It reads the gateway registered for the current DB and prints the same QR that --pair prints at boot — no restart needed. Two guardrails:

  • No gateway running → it tells you to start one: vis gateway start --host 0.0.0.0 --require-token --pair.
  • Running but loopback-bound (the auto-spawned TUI daemon) → it refuses, because 127.0.0.1 is phone-local, and prints the exact restart to run: vis gateway stop then vis gateway start --host 0.0.0.0 --require-token --pair.

So: if you only ever ran vis tui, the daemon behind it is loopback and cannot be paired as-is — stop it and restart the gateway reachable (above). Once it is bound to 0.0.0.0 (or a Tailscale host), vis gateway pair prints the QR on demand any time.

Tailscale (access from anywhere)

0.0.0.0 only exposes the gateway on the local network. To reach it from your phone off-LAN, put both devices on a Tailscale tailnet and bind/advertise the machine's Tailscale IP (the 100.64.0.0/10 range):

# with tailscale up on this machine
vis gateway start --host 0.0.0.0 --require-token --pair

The pairing QR automatically prefers the 100.x Tailscale address when a tailnet interface is present, so the scanned URL keeps working when you leave the LAN. (Binding 0.0.0.0 still listens on all interfaces including Tailscale; the QR just advertises the durable 100.x host.) For a locked-down setup you can instead bind the Tailscale IP directly with --host 100.x.y.z.

Because a non-loopback bind always requires the token, keep --require-token on for any remote/Tailscale exposure — the bearer token is the only thing standing between the tailnet and your sessions.

Protocol version and compatibility

The gateway, the TUI, and the companion app update on different clocks: a phone keeps a cached build for weeks while brew upgrade moves the daemon, or a long-lived gateway serves a client shipped months later. So both halves publish two numbers next to their release version — the wire protocol they speak, and the oldest counterpart they still serve.

The gateway advertises its contract on every open endpoint (GET /healthz, GET /v1/capabilities, GET /v1/admin/status):

{"protocol": {"protocol": 1, "min_client": 1, "min_gateway": 1, "version": "…"}}

Every client stamps the mirror image on each request:

Header Meaning
X-Vis-Protocol wire protocol the client speaks
X-Vis-Min-Gateway-Protocol oldest gateway it can drive
X-Vis-Client / X-Vis-Client-Version who is calling, and its release

A client below min_client gets HTTP 426 Upgrade Required with a plain explanation instead of a payload it would misread; /healthz, /readyz, /v1/capabilities, and /docs stay open so the refusal can explain itself. A gateway older than the client's floor is caught client-side from the same advertised block. Both directions render the SAME verdict — the TUI prints it as a panel, the companion replaces its UI with a version-mismatch screen naming which half is stale and how to update it. A peer that advertises nothing is grandfathered in, never refused.

Bump protocol-version in gateway/protocol.clj (and APP_PROTOCOL in the companion's lib/compat.ts) only for a breaking wire change, and raise min-client-protocol only when the old shape genuinely cannot be served.

Push notifications (iOS / Android)

The gateway pushes exactly one alert per finished turnturn.completed or turn.failed — to every device registered with it, so you can leave the app and still learn when the model is done. Nothing else is pushed, and the alert carries only the session title plus session_id, turn_id, status; the transcript never leaves over APNs.

Gateway side (APNs credentials)

Push is off until the gateway holds an APNs key. Give it one of:

export VIS_APNS_KEY_PATH=~/.vis/apns/AuthKey_ABCD123456.p8
export VIS_APNS_KEY_ID=ABCD123456
export VIS_APNS_TEAM_ID=YOURTEAMID                  # your Apple team id
export VIS_APNS_TOPIC=com.example.yourapp           # the app's bundle id
export VIS_APNS_ENV=production                      # or sandbox for Xcode builds

or drop the .p8 into ~/.vis/apns/ (the key id is read from its filename) and put the rest in ~/.vis/apns/apns.edn:

{:team-id "YOURTEAMID" :topic "com.example.yourapp" :environment "production"}

Create the key once at Apple Developer -> Certificates, Identifiers & Profiles -> Keys, with the Apple Push Notifications service (APNs) capability enabled; the same key signs for every app of the team and never expires.

GET /v1/capabilities reports readiness as features.push, naming what is missing when it is not ready.

Device registry

Route What it does
GET /v1/devices registered devices (tokens masked) + this gateway's push readiness
POST /v1/devices idempotently register {token, platform, environment, client, client_version, label}
DELETE /v1/devices/:token stop pushing to one device
POST /v1/devices/actions/test one test alert to every device, with APNs' per-device verdict

Tokens live in ~/.vis/devices.edn, are never echoed back in full, and a device Apple reports as Unregistered/BadDeviceToken is evicted automatically. A token registered under the wrong APNs environment is retried once against the other one and then re-labelled — the single most common misconfiguration fixes itself.

App side

Companion -> gateway Settings -> Notifications: Notify this device asks iOS for permission, registers the APNs token with that gateway, and Send a test proves the whole chain. Tapping an alert reopens the session it came from.

The iOS capability itself (aps-environment entitlement + AppDelegate token forwarding) is stamped into the regenerable ios/ project by npm run release:ios -- --prepare, so it survives a cap add ios.

Shared slash commands

GET /v1/slashes returns the channel-safe command palette used by web clients. The daemon derives it from the same extension slash registry and prompt templates as the TUI, then adds client-native navigation commands such as /new-session and /sessions. Sending an engine command as a normal turn still uses the canonical slash dispatcher and does not call an LLM.

Draft workspaces

The gateway also owns each session's current workspace and the repo-scoped draft list. That shared ownership is why a draft survives client reconnects and why TUI and web surfaces see the same state. Draft creation, safety, persistence, slash commands, and the workspace HTTP routes are documented in Drafts.

Resource limits and metrics

The gateway bounds expensive GraalPy work process-wide and evicts idle session environments under heap or resident-memory pressure. Defaults favor stable memory use; override them before starting the gateway when a larger host needs more concurrency:

Variable Default Purpose
VIS_GATEWAY_MAX_CONCURRENT_TURNS 50 Simultaneously executing turns across all sessions
VIS_GATEWAY_EVENT_RING_MAX 2000 In-memory SSE replay events retained per session
VIS_ENV_CACHE_MAX 8 Resident idle session environments
VIS_ENV_MAX_TURNS_PER_CTX 25 Turns before a long-lived GraalPy context is recycled
VIS_ENV_HEAP_BUDGET_MB 2048 JVM heap pressure threshold
VIS_ENV_RSS_BUDGET_MB 3072 Whole-process RSS threshold, including native/GraalPy memory

Values <= 0 disable an eviction threshold; non-positive concurrency and event ring values fall back to their defaults. GET /metrics exposes active/waiting turns, queue depth, replay retention, environment-cache size, JVM heap/GC/thread gauges, process RSS, and memory-pressure state in Prometheus format (or JSON when requested with Accept: application/json).