How-to guides
Short, task-oriented guides for the things an operator does by hand. Each section stands alone; they share their text with Get started, so the two can never disagree.
Reach the backend over your own SSH tunnel
The backend binds 127.0.0.1:9119 inside the VM, so you forward a host loopback port to it over SSH. Torio deliberately adds no tunnel feature — you control the forward, which means network exposure is never an accident of running a command.
Derive the forward from the supported live Lima SSH config and open it:
ssh -F ~/.lima/torio/ssh.config -L 19119:127.0.0.1:9119 -N -f \
-o ExitOnForwardFailure=yes lima-torioVerify it from the host — you should get 200:
curl -s -m 5 -o /dev/null -w '%{http_code}\n' http://127.0.0.1:19119/api/statusTear the tunnel down when you are done by killing the ssh process holding the forward.
overall:degraded in /api/status is expected when the messaging gateway is stopped — the serve backend/dashboard component is still ok.
Pin a session token
hermes serve gates every non-public /api/* route behind an X-Hermes-Session-Token header. That token is normally injected into the dashboard SPA's index.html, but serve is headless and never renders that page — so a remote Desktop client has nothing to read it from, and unauthorized calls fail:
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:19119/api/sessions # -> 401/api/status is public and answers 200 either way, so a green readiness check does not prove the token is usable.
Pin a token you can also paste into Desktop by setting HERMES_DASHBOARD_SESSION_TOKEN in a systemd drop-in, kept separate from the base unit so torio serve install re-rendering the unit does not wipe it.
Generate the value
Any long random string works. Torio does not generate one for you — it handles no secrets. For example:
python3 -c 'import secrets; print(secrets.token_urlsafe(32))'This is the value you will paste into Desktop, so keep it somewhere you can copy from once, such as your password manager.
Create the drop-in on the guest
Do this in an interactive shell on the guest, not through torio vm ssh. Two reasons, one of which fails silently:
torio vm sshforwards no stdin, so piping a file in produces an empty file while still exiting0—echo … | torio vm ssh -- sudo -u hermes -- tee …looks like it worked and did nothing.- Passing the token as a command argument would put the secret in the control plane's logs and in
/procon the guest. Typing it into a shell you opened yourself keeps it out of both, which is what the credential-neutral boundary expects.
limactl shell torio # interactive shell in the VM (Lima user)
sudo -iu hermes # become the hermes service identity
install -d -m 700 ~/.config/systemd/user/hermes-serve.service.d
umask 077
nano ~/.config/systemd/user/hermes-serve.service.d/override.confType these two lines, then paste your token immediately after the =:
[Service]
Environment=HERMES_DASHBOARD_SESSION_TOKEN=Save with Ctrl+O, Enter, leave with Ctrl+X, then exit twice.
The token is typed, never pasted as part of a ready-made block, and the line above deliberately stops at =. Copying a block that already contains a stand-in value pins that value: the backend starts, Desktop connects, every check passes, and the deployment is guarded by a token an attacker can read in the documentation. Leaving the value empty fails visibly instead, which is the failure you want.
The file stores the token in plain text, so it must not be group- or world-readable: 700 on the directory, 600 on the file. nano writes through a new file, so check the result rather than assuming:
torio vm ssh -- sudo -u hermes -- \
stat -c '%a %U:%G %n' /home/hermes/.config/systemd/user/hermes-serve.service.d/override.confConfirm you pinned something, without printing it — this catches an empty value and a value you meant to replace:
torio vm ssh -- sudo -u hermes -- \
awk -F= '/SESSION_TOKEN/ {print "token_chars=" length($NF)}' \
/home/hermes/.config/systemd/user/hermes-serve.service.d/override.confApply it
Reload and restart. Plain sudo -u hermes does not set XDG_RUNTIME_DIR, so systemctl --user fails with Failed to connect to bus: No medium found unless it is passed explicitly (the hermes uid is 1000):
torio vm ssh -- sudo -u hermes -- \
env XDG_RUNTIME_DIR=/run/user/1000 systemctl --user daemon-reload
torio serve restart --timeout 2mVerify the gate now opens — 401 without the header, 200 with it:
curl -s -o /dev/null -w '%{http_code}\n' \
-H 'X-Hermes-Session-Token: [REDACTED]' http://127.0.0.1:19119/api/sessionsBecause the value is pinned in the drop-in it is stable across restarts.
Rotate it
Rotate whenever the value has been anywhere it should not have been — a screenshot, a paste buffer you shared, a terminal someone else watched. Generate a new value, edit the drop-in with nano exactly as above, then repeat the reload and restart. The old token stops working the moment the process comes back, so update Desktop in the same sitting: a stale token there produces a 401 that looks like a broken tunnel rather than a rejected credential.
The token is a secret: generate your own, keep it out of the repository, its evidence, and any pull request or comment, and rotate it if it leaks.
Connect Hermes Desktop
Desktop talks to the same loopback endpoint you forwarded, so bring the tunnel up first and confirm both ends are ready: torio serve status exits 0, and the curl to http://127.0.0.1:19119/api/status returns 200.
In Hermes Desktop → Settings → Gateway Connection → Remote gateway, set:
| Field | Value |
|---|---|
| Remote URL | http://127.0.0.1:19119 — the host end of the SSH forward to the guest backend on 127.0.0.1:9119 |
| Session token | the value you pinned in the drop-in |
After Save and reconnect, the status bar shows the remote endpoint plus matching client and backend versions.
Point Desktop at a project
Registering the project does not by itself change what Desktop shows. Settings → Workspace → Working Directory defaults to ., which resolves against the serve unit's WorkingDirectory=/home/hermes/hermes-agent — the Hermes source checkout — so the file tree shows the wrong repository. Set it to the project's derived path:
/home/hermes/projects/my-serviceOptionally set Repository Discovery Roots to /home/hermes/projects so discovery stays inside the workspace root and sees every project you attached.
Two dead ends worth skipping: the folder chip in the status bar opens a context menu, not a project switcher; and Desktop's Terminal tab is a shell on your host, not on the guest.
Configure a model provider
Until a provider is configured, starting a session fails at agent init — for example agent init failed: No Codex credentials stored. Check what the guest currently has:
torio vm ssh -- sudo -u hermes -- hermes statustorio vm ssh forwards no stdin or TTY by design, so the interactive picker cannot be driven through the control plane. Use an interactive shell on the guest instead. limactl shell logs you in as the Lima user, so become the hermes service identity explicitly:
limactl shell torio # interactive shell in the VM (Lima user)
sudo -iu hermes # become the hermes service identity
hermes model # interactive provider/model picker
# or, for one provider's credential:
hermes auth add <provider>Which provider and credential to use is the operator's judgement. The secret never enters the repository, its evidence, or any pull request or comment.
Attach a repository
The model can see the repositories you registered, and nothing else. Nothing is discovered, scanned, or picked up because it happened to be on disk.
torio project add my-service https://github.com/you/my-service --use
torio project listadd clones the exact remote into the derived workspace path, gives you and hermes shared access to it, and registers the project with Hermes before it records anything in config. --use makes it the active project. If a valid checkout of that remote is already at the path, add verifies and adopts it rather than touching it.
You do not choose the path. It is always /home/hermes/projects/<id>, derived from the project id — never taken from you, never stored in config. Without --id, the id is the name you gave, which must be a lowercase slug; pass --id to pick a different one.
Read access is your job. Torio stores no Git credentials, prompts for none, and passes none to the model. A remote the guest cannot already read without prompting fails closed:
torio: project add: auth: the guest cannot read the remote noninteractively; provision access for the hermes user out of bandThat is exit 7.
The fix is to grant the guest read access yourself, on the guest, outside Torio — not to re-run the command. Do not work around it by copying a checkout from your host: a recursive copy drags host Git config, hooks, and keys across the VM boundary, which is exactly the thing this path exists to prevent.
Nothing on the guest is reset, cleaned, or deleted, so if add fails partway a rerun finishes the work instead of starting over.
Inspect and forget
torio project show my-service # registry entry, checkout state, Hermes registration
torio project use my-service # switch the active project
torio project remove my-service # forget itshow reports drift as stable markers rather than repairing it, and returns no file names, diffs, or raw Git output. list reads config only and runs nothing on the guest, so it works with the VM stopped.
remove archives the Hermes project and drops the config entry. The checkout is never deleted — the output tells you where it still is. There is no --delete.
Push, when you decide to
The persistent Hermes backend has read access to your checkouts and nothing more. It cannot push, and no credential of yours is stored anywhere it could reach.
When you want to write to a remote, open a session that carries your own capability:
torio project shell my-serviceThis forwards your host's SSH agent into an interactive session in the checkout. The capability lives exactly as long as the session does and leaves with you when you exit. Inside it you are the hermes identity, in the project directory, with your agent available to Git:
git status
git diff
git commit -am 'the change you decided to make'
git push
exitTorio preflights the session — the project registered, the VM bootstrap-verified, the checkout present with the registered origin and shared permissions, your local agent actually holding an identity to forward. It never test-pushes to prove any of that, because a test push is a write you did not ask for.
Once you exit, Torio makes no claim about what happened. It does not know whether you pushed, and it will not tell you that you did. Check the remote yourself.
The session is not bounded by --timeout; you end it. It is interactive, so it does not support --json — there is no document to emit, and asking for one is a usage error rather than a silently ignored flag.
Your agent must be loaded before you start: ssh-add -l should list an identity. An empty agent fails the preflight rather than opening a session that cannot push.
Run a check without leaving the control plane
To run something inside a checkout without opening a session, torio vm ssh executes a fixed command as hermes and returns its output. It forwards no stdin and no TTY, so it suits non-interactive checks and nothing else.
Pick a check from the repository's own contributor documentation — one that reads and reports rather than writing, installing, deploying, or pushing — and run it against the derived workspace path:
torio vm ssh -- sudo -u hermes -- \
python3 /home/hermes/projects/my-service/scripts/some-check.py --check
torio vm ssh -- sudo -u hermes -- \
git -C /home/hermes/projects/my-service status --porcelainThe second command must print nothing: a check that leaves the tree dirty was not the read-only check you thought you were running.
Two limits:
torio vm sshforwards no stdin. Piping into it produces an empty result while still exiting0—echo … | torio vm ssh -- … tee filelooks like it worked and wrote nothing. Create files in a real shell instead.- The guest is deliberately minimal. Python is there; most other toolchains are not. Anything else you want to run inside the VM you install in the VM yourself, and that install must never add a Git remote, configure a credential helper, or grant push access.
Edit a project with your own editor
Torio adds no editor integration and mounts no host directory into the VM. A project is an ordinary Git checkout at /home/hermes/projects/<id>, owned by the hermes guest identity, on a minimal Ubuntu image. Whatever tool you use is your own, reaching that checkout; it does not move the boundary — read access stays your prerequisite, and commit and push stay decisions you make after reading git diff and git status.
The normal entrypoint opens the checkout without forwarding your SSH agent:
torio project enter my-serviceOnly open the push-capable operator session when you intentionally need it:
torio project shell my-serviceThe guest is deliberately minimal — Python is present, most other tools are not — so anything you want to run inside the VM you install in the VM yourself. That install is your setup, not part of the control plane, and it stays inside the VM: it must never add a Git remote, configure a credential helper, or grant push access.
| Tool | How it reaches the checkout, and what to watch |
|---|---|
| Neovim, or any terminal editor | Run it inside project enter for ordinary work. If it is not in the guest, install it there once (for example sudo apt-get install neovim) — your setup, inside the VM. Your plugins, LSP servers, and config are the guest's, not your host's; keep a dotfiles checkout on the guest if you want them. Torio also ships a host-side management panel under integrations/neovim; :Torio lists projects, opens routine or push-capable terminals, reports health, and shows Hermes sessions. |
| VS Code / Cursor, over Remote-SSH | Add Include ~/.lima/torio/ssh.config to your ~/.ssh/config so the lima-torio host resolves, then Connect to Host → lima-torio. Include the file rather than copying a port: Lima reassigns the SSH port across VM restarts. See the caveat below the table. |
| Claude Code, or another terminal AI agent | Run the agent inside the VM as hermes, pointed at the checkout, so its edits land as hermes. Keep it to editing, inspection, and read-only checks; leave commit, push, remote changes, and any credential setup to you. Installing the agent and its runtime in the VM is your setup, outside the control plane and its credential neutrality. |
Caveat for Remote-SSH: it connects as the Lima user, not hermes, and installs a server component into that user's guest home — so saving files in the hermes-owned tree needs the remote window's integrated terminal running sudo -iu hermes for Git and for checks. For a single-identity session, project enter is cleaner.
The everyday loop
Once the VM and backend are up, the day-to-day loop is the same whichever editor or interface you use:
- Work in a checkout — from a Desktop session, your own editor, or
torio project enter <id>. - Edit, or let your AI tool edit, files there.
- Run a check that reads rather than writes.
- Review what changed:
git diffandgit status. - Decide whether any of it should leave the VM.
- If it should:
torio project shell <id>, commit, push, exit.
Steps 5 and 6 are the whole point of the split. Torio automates no part of either. The persistent backend cannot push at all — write capability exists only inside a session you opened, and ends when you exit it.
Work out why something isn't running
Most first-run failures are one of the cases below. On failure torio exits non-zero with a specific code and prints a diagnostic to stderr; match the symptom, then apply the fix.
| What you see | What it means, and what to do | |
|---|---|---|
zsh: command not found: torio | The binary is not on your PATH. Build it and install it once — go build -o torio ./cmd/torio then sudo install -m 755 torio /usr/local/bin/torio — or run it in place as ./torio from the repository root. | |
torio: no command given; run 'torio --help' (exit 2) | You ran torio with no subcommand. Add one, for example torio vm status, or run torio --help to list the command surface. | |
A vm command fails mentioning limactl (exit 8) | Lima is not installed, or limactl is not on your PATH. Install Lima and confirm limactl runs; Torio drives the VM through it. | |
torio: stopped, or a precondition error (exit 3) | The VM is not running. Start it with torio vm start, then re-run your command. | |
torio: not_found from torio vm status (exit 0) | No VM exists yet. This is the answer on a host that has never run Torio; create one with torio vm init. | |
torio: timeout … exceeds policy maximum 10m0s (exit 2) | --timeout is capped at ten minutes for any single operation, and the check runs before any work. Ask for 10m or less. | |
A Desktop session does not offer torio-brain although torio brain status says the skill is installed | Hermes caches the assembled skills prompt in the backend process. Restart the backend — torio serve restart --timeout 2m — not just the Desktop connection. | |
torio brain status reports retrieval_skill_drift on a guest that was working | Expected after upgrading Torio: the skill moved into its own category, and a guest still holding the older copy has two files claiming one skill name, which Hermes refuses to load rather than choosing between. Run torio brain init to retire the old copy, then torio serve install and torio serve restart --timeout 2m so the backend picks up the regenerated unit. | |
torio serve status exits 3 | The backend service is not installed or not active. Run torio serve install, then torio serve start. | |
torio serve status exits 6 | The service is active but the loopback endpoint did not answer. Check torio serve logs, then torio serve restart. | |
curl to 127.0.0.1:19119/api/status hangs or fails | The SSH tunnel is not up, or you forwarded a different local port. Re-open the forward and curl the same port you forwarded. | |
overall:degraded in /api/status | Hermes reports one of its own optional components as down — its messaging gateway, which Torio neither installs nor manages. The backend and dashboard components are still ok; no action needed. | |
401 on /api/* while /api/status returns 200 | The X-Hermes-Session-Token gate. Headless serve surfaces no token, so pin one in the systemd drop-in and give Desktop the same value — see Pin a session token. | |
torio project add fails saying the guest cannot read the remote (exit 7) | Read access does not exist on the guest yet. Torio stores no credentials and will not prompt, so re-running changes nothing: grant the guest read access yourself, outside Torio, then attach again. | |
torio project shell refuses before opening a session | A preflight did not hold — most often an empty SSH agent. Check ssh-add -l lists an identity; the other causes (project not registered, VM not bootstrap-verified, checkout missing) are named in the error. | |
| Desktop's file tree shows the Hermes source checkout, not your project | Settings → Workspace → Working Directory is still ., which resolves against the serve unit's WorkingDirectory. Set the project's derived path — see Point Desktop at a project. | |
agent init failed: No … credentials stored | No provider is configured on the guest. Run the interactive picker as hermes in a real shell — see Configure a model provider. torio vm ssh cannot do this; it forwards no TTY. | |
Failed to connect to bus: No medium found | systemctl --user under plain sudo -u hermes has no XDG_RUNTIME_DIR. Pass it explicitly: env XDG_RUNTIME_DIR=/run/user/1000. | |
A file you piped through torio vm ssh is empty, but the command exited 0 | torio vm ssh forwards no stdin, so `… \ | torio vm ssh -- … tee file writes nothing and still reports success. Create the file in an interactive shell (limactl shell torio, then sudo -iu hermes`) instead. |
Add --json to any command for a single machine-readable envelope on stdout; human diagnostics always go to stderr.
The full exit-code table is in Reference.