Hosting your vak core
The architectural bet of this project: the core is headless and runs anywhere; TUI, desktop and chat bridges are just clients. This guide makes that concrete — from laptop LaunchAgent to a $5 VPS.
Topology
┌────────────── any machine ──────────────┐ ┌── your phone ──┐
│ vak serve --gateway --trust :8901 │◀────│ Telegram bot │
│ ▲ bearer token (pinned) │ │ (bridge process │
│ └── sessions/, memory/, tasks/ live here │ │ can run anywhere)│
└──────────────────────────────────────────┘ └────────────────┘
- One gateway per workspace (
--trustthe workspace once). - Bridges (Telegram today; more later) need network access to the Bot API and to your gateway — they do NOT need to be on the same machine.
- TUI/desktop connect locally as always; remotely via SSH tunnel or tailnet pointing at the same port.
- The web client is served by the same process at
/app— the same workspace client the desktop app ships, in a browser (docs/design/48-web-client.md). See "Reaching the web client" below.
Managed local install
scripts/release.sh
target/release/vak self install --force
cd /path/to/the/workspace-the-gateway-should-serve
/Applications/Vak.app/Contents/MacOS/vak self services-sync # macOS
# ~/.local/share/vak/bin/vak self services-sync # Linux
self installplaces one verified manifest of the CLI, desktop, and workers in the managed platform prefix; services never run fromtarget/.- macOS → LaunchAgents
com.vak.gateway/com.vak.telegram(KeepAlive + RunAtLoad; survives reboot & crashes). - Linux → systemd user units
vak-gateway.service/vak-telegram.service(systemctl --user ..., enable lingering for boot start:sudo loginctl enable-linger $USER). - The desktop app gets a unit too —
com.vak.desktop/vak-desktop.service— so the menu-bar icon comes back at login and survives a reboot. It starts asvak-desktop --tray: menu-bar icon only, no window until you open one (Dock, Finder, or the tray'sOpen Vak). Unlike the headless pair it is not kept alive, so the tray'sQuit Vakactually quits; the next login orself services-syncbrings it back. - On a headless server there is no
vak-desktopbinary in the release, and the unit is skipped. If you installed a desktop build on a machine with no display, drop just that one:launchctl bootout gui/$UID/com.vak.desktop/systemctl --user disable --now vak-desktop.service— re-runningself services-syncwill recreate it. VAK_GATEWAY_TOKENis pinned automatically on first activation (ensure_gateway_token) — nothing to set by hand. Set channel credentials (bot tokens, provider keys) through the admin/Settings API (PUT /config/key, the gateway bot endpoints) before starting services so bridges keep working across restarts; there is no file to hand-edit — secrets are never stored as plaintext (docs/design/44-shared-config.md, "Secrets Chain").- Re-run
self installafter upgrading binaries andself services-syncafter changing the served workspace or generated-unit contract. self services-syncrecords the workspace directory in each generated unit; run it from the workspace that the gateway should serve. This keeps the gateway's provider/model config and the project secret scope aligned with that workspace instead of inheriting launchd/systemd's default directory. Units also preserve non-secretHOME, while the shared resolver falls back to the OS account home for GUI launches that omit it.
Secrets
All user-level secrets — provider keys, bot tokens, and
VAK_GATEWAY_TOKEN — live in the canonical Shared secret scope, resolved
through vak_config::credentials to an OS-native secret service (macOS
Keychain / Windows Credential Manager / Linux Secret Service) or, when
none is reachable (the common case for a headless server with no D-Bus
session), an AES-256-GCM encrypted-file fallback under the shared data
home — never a plaintext file (docs/design/44-shared-config.md, "Secrets
Chain"). This sits beside the Shared config layer at
~/vak-home/.vak/config.toml. Sessions, ledgers, and gateway state live
separately under the platform data home (~/Library/Application Support/vak
on macOS, ~/.local/share/vak on Linux).
Nothing secret is written to config.toml, generated units, the repo, or logs. Generated bearer tokens are printed only to an interactive terminal; Telegram HTTP errors omit Bot API URLs.
Reaching the web client
vak serve serves the full workspace client at /app. Three ways in,
in increasing order of how much you are taking on:
1. Loopback (default). Nothing to configure.
vak serve --port 8901
open http://127.0.0.1:8901/app
2. SSH tunnel — the recommended way to use a headless box.
ssh -N -L 8901:127.0.0.1:8901 you@box # then open http://127.0.0.1:8901/app
Zero new attack surface: the server stays on loopback, Host is
localhost, SSH does the crypto, and the terminal keeps working. Prefer
this unless you specifically need a browser that cannot tunnel.
3. A real hostname. Requires opting in, because a non-loopback bind exposes the agent — including its tools — to whoever can reach the port.
[server]
bind = "127.0.0.1" # behind a TLS proxy; or a tailnet IP
trusted_hosts = ["vak.example.com"] # exact names; wildcards are refused
public_url = "https://vak.example.com" # enables Secure cookies
session_ttl_hours = 24 # shorter than the 168h default
[server.web]
terminal = false # a shell over HTTP is remote code execution
vak serve refuses to start on a non-loopback bind with no
trusted_hosts, rather than starting and then rejecting every request
with 421. Put a TLS-terminating proxy in front (Caddy, nginx) and make
sure it passes Host and sets X-Forwarded-Proto.
Sign in with the server's token — the one it prints on startup, or your
VAK_GATEWAY_TOKEN. It is exchanged once for an HttpOnly cookie and is
never stored in the page.
Docker (the headless Linux image)
export VAK_GATEWAY_TOKEN="$(openssl rand -hex 32)"
export VAK_WORKSPACE=/path/to/your/project
docker compose -f docker/compose.yaml up --build
# then open http://127.0.0.1:8901/app
The image is vak serve and nothing else: the web client at /app, the
operations console at /admin, one port. It runs as a non-root user, the
binaries stay root-owned (a compromised agent must not be able to rewrite
its own executable), and it contains no Node.js — the admin and client
bundles are committed precisely so a headless host builds the server
without a JavaScript toolchain.
Two volumes matter:
/workspace— the project the agent acts on. Bind-mount real code here./home/vak/.local/share— sessions, memory, tasks, commitments, audit logs. A named volume, because this is append-only history rather than a cache: losing it loses the receipts.
The compose file publishes to 127.0.0.1 deliberately. "8901:8901" would
bind every interface and hand an agent that can run commands to anyone who
can reach the host. Serving a real hostname is the separate, deliberate step
below.
Inside a container vak serve binds 0.0.0.0, because that is the only
address the published port can reach — and unlike on a host, it does not
demand trusted_hosts first, since docker run -p is already the explicit
act that decides reachability. The Host check itself does not relax: DNS
rebinding is defended identically inside a container.
Security posture
- Bearer token on every route except
/healthand the client shell. - Bind to loopback by default. For remote access use a tunnel
(
ssh -L, Tailscale/WireGuard) or, deliberately,--host/[server] bindwithtrusted_hostsset and a firewall in front. Hostheaders are pinned: loopback names, plus exactly whattrusted_hostslists. This is the DNS-rebinding defence — a page you visit can resolve its own domain to 127.0.0.1 andSameSitewill not save you, but a pinnedHostwill.- Cross-origin mutations are refused even when they carry a valid session cookie.
- The web terminal is off by default, and loopback-only even when on. Everything else the client can reach is permission-gated; a shell is not.
[gateway],[sandbox]and[server]are privileged config sections — untrusted repositories cannot enable remote execution, pick sandbox backends or images, or widen network exposure.- Unattended turns auto-deny approval gates unless you configure
approvals = "forward"with an approver surface (docs/design/ 22-gateway.md G2). - Backups = copy
<home>(the data home:~/Library/Application Support/vakon macOS,~/.local/share/vakon Linux): sessions, memory, tasks, bindings are all plain files.
Updating
git pull && cargo build --release -p vak
target/release/vak self install --force
cd /path/to/served/workspace
/Applications/Vak.app/Contents/MacOS/vak self services-sync
Sessions and memory are append-only JSONL/markdown — upgrades require no migration.
Troubleshooting
| Symptom | Check |
|---|---|
| bridge replies "(gateway unreachable)" | gateway down or token mismatch — vak open admin --print prints the pinned VAK_GATEWAY_TOKEN as a loopback-only link; compare it against what the bridge is configured with |
| replies "(aborted)" | pre-0.3.0 bug; upgrade. Also check ~/Library/Logs/vak/gateway.log (macOS) or ~/.local/state/vak/logs/gateway.log (Linux) |
| tool calls denied on phone | expected in default deny mode; configure an approver surface or use TUI/desktop for escalations |
| model errors | /health shows effective provider/model plus provenance/revision; keys live in the Shared secret scope (docs/design/44-shared-config.md, "Secrets Chain"), not a file — use the Settings UI or PUT /config/key to check/change them |
| MCP server "spawn failed" / dies at handshake | under service managers PATH is minimal: use the absolute interpreter path (which npx) in [mcp.servers.*].command; network-client tools also need network = true |
| Tavily/web search denied on phone | add allow = ["+mcp(tavily/*)"] to trusted config — scoped to that server |
Telegram bridges are single-consumer per bot token: local duplicates fail fast via $VAK_HOME/locks, cross-machine rivals put the local bridge into hot-standby with automatic takeover (docs/design/22-gateway.md § Telegram bot ownership).