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:7879The 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-onlyThe 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_controlreturnscannot open control connection to 127.0.0.1:7879: <error>— usually a refused connection, because nothing is listening on your local port;Server::ensure_observesays the same withobserve. Nothing was sent. - Mid-command. The write tool returns an
isErrorwhose text says the control connection dropped before the node answered and the outcome is unknown — it may have executed. That isCommandOutcome::Disconnectedincrates/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_deadinremote_control.rs), so a command that reaches a dead link is classifiedCommandOutcome::NeverSent— not one byte went on the wire — andServer::executereconnects and sends that command on the same call. The agent sees an ordinary outcome, not "unknown". Reads are protected the same way:tool_node_snapshotchecksRemoteCoreHandle::is_connectedafter waiting for a frame, tries one fresh connection, and otherwise returns an error naming the last frame'sseqas 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 -Lwith no keepalive). The observe stream carries a heartbeat the node sends everyOBSERVE_HEARTBEATof silence, and the client reads it underOBSERVE_READ_TIMEOUT(three beats) — both incrates/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 withssh -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.
Windsurf
The mcpServers entry in ~/.codeium/windsurf/mcp_config.json, reached from Cascade's MCP panel — and why a desktop-launched editor is the case where the credential store earns its keep.
Raw JSON-RPC
There is no URL to curl — the trader server is a pipe. The stdio wire protocol, the result envelope, every error shape, and the two that mean "it may have executed".