Configuration recipes and tips
Worked setups for the things people configure first. Every key is described in full in the Configuration reference. Global settings go in ~/.config/hort/config.json; project settings in the project's .hort.json.
Dotfiles, read-only
Mount the configuration your shell and editor need. Paths under your home land at the same place under the sandbox home, /home/hort:
// ~/.config/hort/config.json
{
"rootfs": "~/.local/share/hort/devbox",
"mounts": {
"readOnly": ["~/.config/nvim", "~/.gitconfig", "~/.tmux.conf", "~/.bashrc"],
},
}
The programs themselves must be in the rootfs: mounting ~/.config/nvim does nothing for a rootfs without Neovim. If you want your usual shell as the session shell, install it in the rootfs; hort uses your host $SHELL automatically when the rootfs has the same path, or set shell.
Fish and a read-only config directory
Do not mount all of ~/.config/fish read-only. fish writes its universal variables to ~/.config/fish/fish_variables, and with the directory read-only every prompt prints:
error: Unable to open universal variable file '/home/hort/.config/fish/fish_variables': EROFS: Read-only file system
Mount the parts you edit instead, and leave the directory itself writable (it is in the sandbox's in-memory home):
{
"mounts": {
"readOnly": [
"~/.config/fish/config.fish",
"~/.config/fish/conf.d",
"~/.config/fish/functions",
"~/.config/fish/completions",
],
},
}
The same applies to any program that writes state next to its configuration: mount the files, not the directory. Note that hort config offers ~/.config/fish as a whole directory; pick the individual paths by hand instead.
Editing a mounted file while a sandbox runs
A read-only mount of a directory shows your edits inside running sandboxes immediately. A mount of a single file follows that file only while it is the same file on disk: many editors save by writing a new file and renaming it over the old one, and a running sandbox then keeps seeing the old content. New sandboxes see the new file. If you need live edits, mount the directory, or restart the sandbox.
Agent credentials
Claude Code
Claude Code keeps its login under ~/.claude. Mount it read-only and declare the completion hook:
// ~/.config/hort/config.json
{
"agents": [
{
"command": "claude --dangerously-skip-permissions",
"auth": { "readOnly": ["~/.claude"] },
"notify": { "stopHook": true },
},
],
}
Also required: IS_SANDBOX=1 in the rootfs, or Claude Code refuses its unrestricted mode because the session is uid 0 (see Preparing a rootfs).
Because the mount is read-only, nothing Claude Code writes under ~/.claude from inside the sandbox is saved, and whatever it writes elsewhere in the sandbox home is gone at hort down. It may therefore ask again for things it normally remembers, such as whether you trust the project folder.
Agents that take an API key
Forward the variable by name. Its value is read from your environment when the session opens and never written to disk by hort:
{
"agents": [
{ "command": "aider", "auth": { "env": ["OPENAI_API_KEY"] } },
{ "command": "gemini", "auth": { "env": ["GEMINI_API_KEY"] } },
],
}
export OPENAI_API_KEY=sk-... # in the terminal you run hort attach from
hort attach fix-login
If a variable is not set, the session opens without it and hort warns. Use development keys only: the agent can read anything you forward.
GitHub CLI without logging in each time
The gh command reads a token from GH_TOKEN (or GITHUB_TOKEN). Forward it the same way, so gh inside the sandbox is authenticated on every session with no gh auth login:
{
"agents": [
{ "command": "claude --dangerously-skip-permissions", "auth": { "env": ["GH_TOKEN"] } },
],
}
export GH_TOKEN=github_pat_... # in the terminal you run hort from
hort up review-pr
Use a fine-grained token, scoped to the one repository you are working on with only the permissions you need (typically Contents and Pull requests). The sandbox runs an agent you are not watching line by line, so the token is what bounds what it can do on the remote; branch protection on the remote is the backstop.
Under an egress allowlist the sandbox reaches nothing by default, so add github.com and api.github.com to the allowlist for gh to work. Under open egress they are reachable already.
In the default worktree mode git does not work inside the sandbox, so gh covers only what is API work: opening a pull request for a branch that is already on the remote, reading issues, gh api. To let the agent commit and push from inside, build the sandbox in clone mode; the same token then serves both.
Dependency caches
Keep installs across sandboxes of the same project:
// .hort.json
{
"cache": {
"dirs": [
"node_modules",
{ "name": "npm", "target": "~/.npm" },
{ "name": "pip", "target": "~/.cache/pip" },
{ "name": "cargo-registry", "target": "~/.cargo/registry" },
],
},
}
node_modules lands at /workdir/node_modules; the others under /home/hort. Every sandbox of this project shares the same directories, so two sandboxes running installs at the same moment write into the same cache. hort prune removes a project's caches once the project directory is gone, or with --force.
A resource ceiling
{
"resources": { "memory": "6g", "cpus": 4 },
}
Useful when agents run unattended: a runaway process hits the ceiling instead of your machine. cpus needs the cpu cgroup controller delegated to your user; hort doctor tells you, and Installation shows how.
A development database
A PostgreSQL installed on the host, and a Redis published by Docker Compose on another address:
// .hort.json
{
"network": [
{ "mode": "host", "host": "127.0.0.1", "port": 5432 },
{ "mode": "network", "host": "172.17.0.1", "port": 6379 },
],
}
Inside the sandbox, use 127.0.0.1:5432 and 127.0.0.1:6379. Each database needs its own port. Development data only. See Databases.
An egress allowlist
Start from what your agent and your package manager need, then add hosts as the proxy log shows them refused:
// .hort.json
{
"egress": {
"allow": [
"api.anthropic.com",
"registry.npmjs.org",
"github.com",
"*.githubusercontent.com",
],
},
}
Which hosts an agent contacts is up to that agent; check its documentation, then watch the log:
tail -f "${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/hort/sandboxes/fix-login/output.log" | grep --line-buffered -E '^(allowed|refused)'
Rebuild the sandbox (hort down, hort up) after changing the list. Tools must honour HTTPS_PROXY; see Networking and egress.
Notifications
hort cannot tell when an agent finishes by watching processes: the agent returns to its prompt and nothing exits. The agent has to announce it. Claude Code can, through a Stop hook that hort installs for you.
You need:
"notify": { "stopHook": true }on the Claude Code entry inagents(as in the credentials recipe),notify-sendon the host (libnotify) and a desktop session that shows notifications,- optionally, a
notificationsblock to change the text:
{
"notifications": {
"sink": "desktop",
"message": "hort: '<name>' is done, come and review",
},
}
How it works: when at least one agent has stopHook enabled, hort up mounts a small channel directory at /run/hort/notify/ and writes /etc/claude-code/managed-settings.d/hort-notify.json into the sandbox, a Claude Code managed-settings file whose Stop hook appends one line of JSON to /run/hort/notify/events.jsonl. A small process on the host, started by hort up and stopped by hort down, raises your message each time that file grows. hort ls also counts the last completion as activity when computing idle time.
To test it without an agent, append a line from a session:
echo '{"ts":"","event":"stop","agent":"manual"}' >> /run/hort/notify/events.jsonl
Things to know:
-
The settings file only exists in sandboxes built after you enabled
stopHook. -
Claude Code documents that it merges every file in that managed-settings directory; its documentation does not say whether hooks from there run alongside the hooks in your own
~/.claudesettings. If your own Stop hooks stop firing inside a sandbox, this is the reason. -
If
notify-sendis missing, or the sink is anything butdesktop,hort upwarns and the sandbox starts without notifications. -
A notification that fails to show (for example no desktop session over SSH) is recorded in the sandbox's
output.log:hort: a completion of this sandbox was not raised: sandbox notifications failed: /usr/sbin/notify-send raised nothing: exit status: 1 -
Any other agent that can run a shell command when it finishes can append the same line. The channel is only mounted when some agent in the configuration has
stopHookenabled.
Showing the sandbox in your prompt
hort sets HORT_SANDBOX in every session and leaves drawing to your shell. With your prompt configuration mounted read-only, add a segment when the variable is set; examples for fish and bash are in Running agents in parallel.
Colors inside the sandbox
Sessions do not receive TERM or COLORTERM from your host terminal, so programs that decide on color from them (Claude Code among them) start without color. Until hort forwards them (planned), set defaults inside the sandbox from a shell configuration you mount into it. For fish:
if set -q HORT_SANDBOX
set -q TERM; or set -gx TERM xterm-256color
set -q COLORTERM; or set -gx COLORTERM truecolor
end
For bash or another POSIX shell:
if [ -n "$HORT_SANDBOX" ]; then
case "$TERM" in ""|dumb) export TERM=xterm-256color ;; esac
export COLORTERM="${COLORTERM:-truecolor}"
fi
The rootfs needs the terminfo entry for the TERM you choose (Debian's ncurses-base package carries xterm-256color).
Habits worth having
- Commit before
hort up. The worktree starts fromHEAD; uncommitted changes in your checkout are not in it. - Commit before
hort down. Uncommitted work in the worktree is deleted with it. - Name sandboxes after the task. The name becomes the branch.
- Run
hort lsnow and then. A forgotten sandbox costs memory until you remove it, anddirtymeans there is work nobody committed yet. - Keep production out. No production credentials, databases or tokens in any mount, variable or network entry.