Tutorials
Guided, start-to-finish walkthroughs. Right now there is one; more will be added as Torio grows.
Get started
By the end of this page you will have built the CLI, created the Linux VM, brought up the loopback-only Hermes backend, reached it from your host through a tunnel you control, created your Second Brain, attached your first repository, and held a real Hermes Desktop session against it — with no automated commit, push, or credential handling anywhere in the loop.
Work straight down. Nothing here sends you to another page to finish a step; the links are for going deeper afterwards.
Before you start
You need all of the following in place first:
- a supported host with
limactlon yourPATH: macOS on Apple Silicon, where Torio uses LimavmType: vzonaarch64, or Linux on x86_64, where it usesqemuonx86_64over KVM. Intel Macs are out of scope —vzneeds Apple Silicon — and so is arm64 Linux, which nothing here has booted; - a checkout of the
toriorepository and a Go toolchain to build the CLI; - for each repository you plan to attach: read access that already works from the guest, without a prompt. Provisioning it is yours to do, outside Torio.
Torio creates the VM itself, so there is nothing to provision by hand first.
Step 1 — Install the CLI
Every command in these docs is written as torio, so put the binary on your PATH once and the rest of the page just works.
Where a release exists, install its verified asset. From a checkout of the torio repository:
scripts/install.sh # latest stable release
scripts/install.sh --version X.Y.Z # or a specific one, without the leading vThe installer resolves the release, verifies SHA256SUMS before the binary is copied anywhere — which is the whole reason to use it rather than untarring by hand — and installs into ~/.local/bin by default. It needs a published release to exist; before one does, build from source below.
install.sh authenticates to nothing and never will. Torio stores and transports no credentials, and an installer carrying a forwarded token would be the one exception that makes the claim untrue. So a repository it cannot read anonymously answers 404 from api.github.com, and gh — which does hold your credentials — closes that gap without Torio touching them:
gh release download vX.Y.Z -D /tmp/torio-rel
scripts/install.sh --version X.Y.Z --base-url file:///tmp/torio-relEither route verifies the same checksums. Set TORIO_REPO=owner/name if the assets live somewhere other than the default.
Building from source instead
With a Go toolchain, build the binary and put it on your PATH:
go build -o torio ./cmd/torio
sudo install -m 755 torio /usr/local/bin/torioPrefer not to install system-wide? Any directory already on your PATH works — for example install -m 755 torio ~/.local/bin/torio. If you skip this step entirely, torio will not resolve at all: a freshly built binary is not on your PATH and your shell does not search the current directory, so you would have to prefix every later command with ./ and run it from the repository root.
Confirm it resolves and runs:
which torio
torio versionversion is the only place the operator reads which build they have:
torio dev (commit …, built …)
go1.26.5 darwin/arm64A binary built straight from a checkout calls itself dev; the commit is the one you built.
torio vm status also works from here and answers torio: not_found until the next step creates the VM. That is the expected answer on a host that has never run Torio, not a failure — it exits 0.
Step 2 — Create and verify the VM
Create the VM from the trusted template, start it, then reconcile and verify it. Run all three in order: each is idempotent, so this is also the sequence you re-run later.
torio vm init
torio vm start
torio vm bootstrap --timeout 10minit prints next: torio vm start whether it created the instance or found a compatible one, so there is no state in which you skip the second command.
init creates the pinned Lima instance (or succeeds idempotently when a compatible one already exists) and verifies the post-create list output before reporting success. start is idempotent and confirms a Running post-state. bootstrap operates only on the existing target after a verified Running precondition, through the typed Lima boundary. Hermes Agent install can be slow, so give it room — but 10m is the policy maximum for any single operation, and asking for more is refused before any work starts:
torio: timeout 15m0s exceeds policy maximum 10m0sOn a fully-reconciled target it mutates nothing; when the pinned launcher is missing it installs Hermes Agent at the pinned commit, then reconciles the PATH shim. It:
- installs the pinned Hermes Agent when
/home/hermes/hermes-agent/venv/bin/hermesis missing (never curl|bash pipe — download to a hermes-writable path, run with fixed flags, verify git HEAD); - ensures
/usr/local/bin/hermesis a symlink to the pinned launcher (only after confirming the launcher exists); - verifies (not merely trusts an exit code): the
hermesuser exists; grouptorio-projectsexists;hermesand the Lima login operator are members;hermesis not in thedockergroup (rootful Docker for hermes is forbidden);uname -mmatches the host profile's guest architecture;hermes --versionthrough the documented stable command path;git --version; the persistent profile, Second Brain, and workspace paths are directories with the expected owner, group, and mode on native Linux (ext4), not a host share; and no broad host mount is present.
Any drift or unverifiable state fails closed (exit 6) with remediation. A rerun is success only when every postcondition is proven. Use --json for the machine-readable envelope (one document on stdout).
Persistent Hermes locations
| What | Path (on the VM's native Linux filesystem) |
|---|---|
| Guest user | hermes |
| Hermes home | /home/hermes |
| Profile / application state | /home/hermes/.hermes |
| Second Brain vault | /home/hermes/brain |
| Workspace root | /home/hermes/projects |
These are also emitted in the torio vm bootstrap output (human and --json).
init creates the VM; bootstrap then verifies the guest layout above on an already-running instance. Neither recreates or re-images it.
Step 3 — Bring up the loopback backend
Install and start the persistent Hermes backend as a user systemd service inside the VM, using the existing /home/hermes/.hermes profile (application state, not the Second Brain vault at /home/hermes/brain). It binds guest loopback only (127.0.0.1:9119) and never a public address:
torio serve install --timeout 2m
torio serve start --timeout 2m
torio serve statusinstallensures user linger, renders the unit (loopback bind,HERMES_HOME,Restart=always), validates it withsystemd-analyzebefore activation, then reloads and enables it for boot. Idempotent; accepts no secrets; does not start the backend.startstarts it and fails closed unless the systemd state is active andGET /api/statusanswers 200 through loopback.statusproves the same and exits non-zero when not ready.stopandrestartmirror the lifecycle.logs [--lines N]shows bounded, redacted, unit-scoped journal entries only.
Step 4 — Reach the backend from your host
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.
Step 5 — 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.
Step 6 — Create the Second Brain
The Brain is a private Markdown vault on the guest at /home/hermes/brain, versioned by a local Git repository and registered with Hermes as its own project, so any session can search it without you opening it first.
torio brain init
torio brain statusinit builds the scaffold atomically through private guest staging, makes the first local commit, registers the Hermes project, and installs the global torio-brain retrieval skill. It is idempotent on state it manages, and it refuses to touch non-empty data it did not create — so a second run is safe and an existing vault is never silently absorbed.
It configures no remote and pushes nothing. The Brain stays on the VM.
If the backend was already running, restart it — torio serve restart --timeout 2m. Hermes caches the assembled skills prompt in the backend process, keyed on the skills directory rather than on the files in it, and Torio installs the skill by writing it. Reconnecting Desktop is not enough: the client reconnects, the process does not, and the cache it holds is the one that decides whether torio-brain is offered. torio brain status reports that the file is correct and says in as many words that it cannot tell whether a running session has loaded it.
Bring an existing vault in
If you already keep Markdown notes on your host — an Obsidian vault, say — import them once:
torio brain import ~/path/to/vault --dry-run # preflight, transfers nothing
torio brain import ~/path/to/vaultStart with --dry-run: it runs the full preflight and reports what would move, without transferring a byte or touching Brain data.
The import is deliberately narrow. It carries Markdown, Canvas, and local attachments, and refuses or skips credential-shaped files, repository metadata, links, hardlinks, special files, and executables. Existing data is never overwritten — the one exception is a scaffold Torio itself created and you have not touched. To land the vault as one contained subtree instead of merging it into the root, pass --into notes.
Output is counts, bytes, and a manifest digest. No note name and no note content ever appears in it.
Getting it back out
Torio brings data in and does not take it out — there is no export command. Copying the Brain to your host is something you do explicitly:
limactl copy torio:/home/hermes/brain/ ~/torio-brain-copy/That is your command. Nothing verifies the result, and Torio does not call it a backup.
Step 7 — Attach your first 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.
Step 8 — 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.
Step 9 — Point Desktop at the 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.
Step 10 — 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.
Step 11 — Learn 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.
Step 12 — Leave a clean end state
Leave the VM and the backend running. Nothing you did here committed, pushed, merged, or deleted anything — that is the intended shape of the loop.
If a step failed, every known first-run failure and its fix is in why something isn't running.
Where to go next
- Ready to push something? Push, when you decide to.
- Prefer your own editor over Desktop? Edit a project with your own editor.
- Want the exact command surface and exit codes? Reference.
- Want to know why the boundaries are drawn this way? Explanation.