vike
Client setup

Remote node

Reach a vike-tradehub node on another machine through an ssh tunnel — the daemon keeps binding loopback, -L brings the port to you, and the same tunnel serves vike-cli mcp, the trade REPL and the desktop app.

A node on another machine is reached exactly the way a local one is — --node 127.0.0.1:7879 — with one thing added in front: an ssh tunnel that makes the daemon's loopback port appear on yours. Nothing on the daemon changes. This page is that recipe, and the reason it is the recipe rather than one option among several.

Why a tunnel, not an open port

The node protocol authenticates the connection and encrypts nothing. Its own primitive says so, in a section headed What this does NOT buy — crates/vike-datahub-client/src/node_auth.rs, where sign and verify live: the handshake is plaintext and authenticates the connection rather than each frame, and confidentiality and integrity "still come from the tunnel (or a VPN) in front of it" — scoped authorization behind that barrier, never a replacement for it. The daemon's own account of the consequence is blunter (start_observe_server in crates/vike-tradehub/src/tradehub_cli.rs): on a reachable network the handshake hands out an offline cracking target for the node key, and once a session is up an on-path attacker can inject a Command.

So the tunnel supplies the two properties the wire deliberately lacks; the keys supply only the third — which of the two scopes this peer may use.

What the daemon binds, and what not to change

crates/vike-tradehub/src/server.rs's DEFAULT_ADDR is 127.0.0.1:7879, and its doc comment names the tunnel as the intended way in. The address the daemon actually uses is tradehub_addr in config.toml (Config::tradehub_addr, crates/vike-config/src/config.rs), which VIKE_TRADEHUB_ADDR (TRADEHUB_ADDR_ENV in the same file) still overrides — and it is unset by default, meaning no node server at all: start_observe_server returns without binding when the address is absent or blank. On the daemon's machine, then:

# <project>/settings/config.toml
tradehub_addr = "127.0.0.1:7879"

Keep it loopback. The tempting edit — 0.0.0.0:7879, "so I can reach it" — is the thing this page exists to steer you away from, and the daemon now refuses it: bind_decision in server.rs classifies the wildcard binds and any non-loopback interface as public (bind_exposure, which also calls a hostname resolving to both loopback and a LAN address public — the strictest answer is the true one), and without the separate opt-in flags.tradehub_allow_public_bind (or VIKE_TRADEHUB_ALLOW_PUBLIC_BIND=1) the observe server is not started and the daemon keeps trading headless. The refusal is logged as an error whose text tells you to keep tradehub_addr on 127.0.0.1 and run the tunnel below. The flag exists for a trusted LAN or a VPN interface; it is a distinct flag rather than an inference from the address because typing the address is the mistake.

The daemon reads both node keys from its own credential store at bind time (vike_tradehub_client::auth::from_vars over the daemon's <project>/settings/secrets.env), and without VIKE_TRADEHUB_OBSERVE_KEY there it likewise starts no server. Control is gated once more by flags.tradehub_control.

The tunnel

ssh -L 7879:localhost:7879 <host>

Read -L left to right: 7879 is the port opened on your machine; localhost:7879 is where the remote sshd connects on your behalf, evaluated on the remote host — so localhost there is the daemon's loopback, which is exactly why a loopback bind is enough. Take the port from DEFAULT_ADDR or from whatever tradehub_addr you set, not from memory.

That form ties the tunnel to an interactive shell. For a session that outlives a terminal:

ssh -N -f \
    -o ServerAliveInterval=30 -o ServerAliveCountMax=3 \
    -o ExitOnForwardFailure=yes \
    -L 7879:localhost:7879 <host>

-N runs no remote command, -f backgrounds after authenticating, the two ServerAlive* options make a silently dead link fail within about 90 seconds instead of hanging, and ExitOnForwardFailure turns "the local port was already taken" into a non-zero exit rather than a tunnel that forwards nothing. To have something re-establish it, hand the same line to autossh:

autossh -M 0 -N \
    -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -o ExitOnForwardFailure=yes \
    -L 7879:localhost:7879 <host>

or give it to a systemd user unit, which restarts it for you:

# ~/.config/systemd/user/vike-node-tunnel.service
[Unit]
Description=ssh tunnel to the vike-tradehub node

[Service]
ExecStart=/usr/bin/ssh -N -o ServerAliveInterval=30 -o ServerAliveCountMax=3 \
    -o ExitOnForwardFailure=yes -L 7879:localhost:7879 <host>
Restart=always
RestartSec=5

[Install]
WantedBy=default.target

(systemctl --user enable --now vike-node-tunnel.) If the backtest tools' datahub lives on the same host, forward its port in the same command — -L 7878:localhost:7878, the DEFAULT_DATAHUB_ADDR in crates/vike-config/src/config.rs — and --addr then needs no change either; that file's Config::datahub_advertise_addr doc describes precisely this two-port tunnel posture.

Pointing the client at the local end

vike-cli mcp --node 127.0.0.1:7879

The address is your own loopback, so every client entry on the setup pages works unchanged. The same local end serves the human surfaces: vike-cli trade --node 127.0.0.1:7879 (USAGE in crates/vike-cli/src/cmd/trade.rs) and vike-desktop --observe 127.0.0.1:7879 — the argument after --observe is the address (the scan in crates/vike-desktop/src/main.rs's main); Observe mode is that page.

Two questions are being answered here, and they have different answers. The tunnel authenticates you to the host — your ssh key, sshd's business. The node keys authenticate the connection to the node — VIKE_TRADEHUB_OBSERVE_KEY for reads and VIKE_TRADEHUB_CONTROL_KEY for writes (OBSERVE_KEY_ENV / CONTROL_KEY_ENV in crates/vike-cli/src/cmd/nodekeys.rs), resolved by resolve in that file per key and independently: the process environment first, the credential store second, a value blank after trimming counting as absent in both, so an empty export cannot shadow a stored key. They must be the same values the daemon loaded on its machine, and on a remote setup the store your client resolves is your <project>/settings/secrets.env, not the daemon's — so either put the two values in it or export them. A key missing from both places returns a tool error naming both (Server::missing_key, crates/vike-cli/src/cmd/mcp.rs); the trade REPL prints which source answered on its connected: line (nodekeys.rs's KeyOrigin).

Withholding the control key is one way to give an agent a read-only view of a remote node, and it has a limit worth knowing: the write tools are still advertised, and a fully confirmed call reaches the missing-key error rather than being refused up front. --profile read-only is the other way, and it acts one layer earlier — the seven order-write tools are absent from tools/list and refused by tools/call, from one derivation, so nothing on that side of the tunnel offers the agent an order to place:

vike-cli mcp --node 127.0.0.1:7879 --profile read-only

The two compose, and neither can be widened from the agent's side of the pipe. --deny-tool NAME takes out one more tool by name.

When the tunnel drops

The observe and control connections are opened lazily and kept for the life of the vike-cli mcp process (Server in crates/vike-cli/src/cmd/mcp.rs) — but a dead one is not kept: the next call that finds it dead reopens it. Four shapes, depending on when the tunnel went and whether the socket noticed:

  • Before the first use. Server::ensure_control returns cannot open control connection to 127.0.0.1:7879: <error> — usually a refused connection, because nothing is listening on your local port; Server::ensure_observe says the same with observe. Nothing was sent.
  • Mid-command. The write tool returns an isError whose text says the control connection dropped before the node answered and the outcome is unknown — it may have executed. That is CommandOutcome::Disconnected in crates/vike-tradehub-client/src/remote_control.rs, and the Outcomes table on the trading page is the contract for it. The handle is dropped, so the next explicit call reconnects — the unknown command itself is never resent by anything.
  • While idle, and the socket reported it (the tunnel process exited, the daemon restarted). The control worker probes the link before writing (link_is_dead in remote_control.rs), so a command that reaches a dead link is classified CommandOutcome::NeverSent — not one byte went on the wire — and Server::execute reconnects and sends that command on the same call. The agent sees an ordinary outcome, not "unknown". Reads are protected the same way: tool_node_snapshot checks RemoteCoreHandle::is_connected after waiting for a frame, tries one fresh connection, and otherwise returns an error naming the last frame's seq as stale — it never returns a stale frame as a result.
  • While idle, and the socket did not report it (laptop sleep, a Wi-Fi change, a VPN re-key under an ssh -L with no keepalive). The observe stream carries a heartbeat the node sends every OBSERVE_HEARTBEAT of silence, and the client reads it under OBSERVE_READ_TIMEOUT (three beats) — both in crates/vike-tradehub-client/src/liveness.rs — so a silently dead link is noticed within that window rather than never; a write's reply read carries its own deadline. From there it is the reported case above. To shorten the window at the source, run the tunnel with ssh -o ServerAliveInterval=15 -o ServerAliveCountMax=3 -L …, which turns a silent death into a reported one.

The node no longer closes an idle authenticated connection: the 300-second bound in crates/vike-tradehub/src/server.rs's handle_connection applies to the handshake of an unauthenticated peer and is replaced at AuthOk by AUTHED_IDLE_TIMEOUT (none) — an agent that thinks for ten minutes between calls is not disconnected for it.

So recovery is: re-establish the tunnel, then carry on — the next call reconnects. No restart of vike-cli mcp is needed. The one discipline that remains is the same as on loopback: after any outcome that read unknown, call node_snapshot and read a fresh seq before retrying.

Docker

The tunnel's local end is on the host, and 127.0.0.1 inside a container is the container's own loopback — the callout under Where the binary comes from on the setup page covers what a containerised vike-cli needs instead.

On this page