Configuration Reference
Complete guide to configuring HORUS projects via horus.toml and understanding the auto-managed .horus/ directory.
Quick Reference
Minimal horus.toml:
[package]
name = "my-robot"
version = "0.1.0"
[dependencies]
horus = "*"
Full horus.toml:
[package]
name = "my-robot-controller"
version = "1.2.3"
description = "Advanced mobile robot controller with navigation"
authors = ["Robotics Team"]
license = "Apache-2.0"
[dependencies]
horus = "*"
serde = { version = "1", features = ["derive"], source = "crates.io" }
tokio = { version = "1", features = ["full"], source = "crates.io" }
[ignore]
files = ["debug_*.rs", "**/temp/**"]
directories = ["experiments/", "old/"]
packages = ["ipython", "jupyter"]
Project Metadata
Project metadata lives under the [package] table. The keys below are never written at the top level of the file.
name (Required)
Project name used for identification and package management.
Type: String Required: Yes Constraints:
- 2-64 characters
- Only lowercase letters, digits,
-,_,@, and/— scoped names such as@org/pkgare allowed - These names are reserved and rejected outright:
horus,core,std,lib,test,main,admin,api,root,system,internal,config,setup,install
Examples:
name = "temperature-monitor"
name = "mobile_robot_controller"
name = "warehouse-navigation"
version (Required)
Project version following semantic versioning.
Type: String
Required: Yes
Format: MAJOR.MINOR.PATCH (semantic versioning)
Examples:
version = "0.1.0" # Initial development
version = "1.0.0" # First stable release
version = "2.3.1" # Mature project
description (Optional)
Human-readable description of your project.
Type: String Required: No Default: None
Examples:
description = "Temperature monitoring system with alerts"
description = "Autonomous mobile robot for warehouse operations"
authors (Optional)
Project authors or team names.
Type: Array of strings Required: No Default: Empty
Examples:
authors = ["Robotics Team"]
authors = ["John Doe <john@example.com>"]
authors = ["ACME Robotics Inc."]
horus new fills this in from your git user.name. Note the plural — a singular author key is reported as an unknown key and has no effect.
license (Optional)
Project license identifier.
Type: String
Required: No
Default: None
Common Values: Apache-2.0, MIT, GPL-3.0, BSD-3-Clause
Examples:
license = "Apache-2.0"
license = "MIT"
license = "Proprietary"
Build Mode and Language Detection
Neither of these is a horus.toml key. This section exists to say where they actually come from.
Build Mode
Build mode is not a manifest key. It is chosen per invocation on the command line:
horus run # debug (default)
horus run --release # optimized build
horus build --release
A mode key in horus.toml has no effect — horus check reports it as an unknown key.
Language Detection
There is no language key either. HORUS detects the project's languages from the build files that are present, and a project can detect as more than one at a time:
| Language | Detected from |
|---|---|
rust | Cargo.toml, .horus/Cargo.toml, or any .rs file in the project root or src/ |
python | pyproject.toml, .horus/pyproject.toml, setup.py, requirements.txt, or any .py file in the project root or src/ |
cpp | CMakeLists.txt, .horus/CMakeLists.txt, or any .cpp/.cc/.cxx/.hpp/.hh/.hxx file in the project root or src/ |
ros2 | package.xml |
Both language and languages are reported as unknown keys — remove them and let detection do the work.
Dependencies
HORUS supports multiple package sources in horus.toml:
| Source | Syntax | Example | Installed via |
|---|---|---|---|
| HORUS Registry | name = "version" (default source) | pid-controller = "1.0" | horus install or horus run |
| crates.io | name = { version = "...", source = "crates.io" } | serde = { version = "1", source = "crates.io" } | horus run |
| PyPI | name = { version = "...", source = "pypi" } | numpy = { version = "1.24", source = "pypi" } | horus run |
| System | name = { apt = "...", source = "system" } | eigen = { apt = "libeigen3-dev", source = "system" } | horus run |
| Git | name = { git = "..." } | sensors = { git = "https://github.com/robotics/sensors.git" } | horus run (clones automatically) |
| Local Path | name = { path = "..." } | my_driver = { path = "../driver" } | horus run |
Basic Dependencies
Type: TOML table, name = version-string or name = { ... }
Required: No
The dependency name is the table key. There is no name field and no array form.
Simple format (version string):
[dependencies]
horus = "*" # Newest version the registry has
pid-controller = "1.0" # HORUS registry, version 1.0
A bare version string resolves against the HORUS registry. Set source to pull from crates.io, PyPI, or the system package manager instead.
Accepted version strings:
| Spelling | Means | Where it works |
|---|---|---|
"*" | Any version — the newest available | Everywhere. This is what horus new and horus add write |
"1.0", "^1.2", ">=1.0, <2.0", "=1.2.3" | A semver requirement | Everywhere |
"latest" | The newest available | HORUS registry only |
horus add pkg@latest accepts latest, and the HORUS registry resolves it, so it looks
like a synonym for "*". It is not one outside the registry: .horus/Cargo.toml and
.horus/pyproject.toml are generated by copying the version string across, so
[dependencies]
serde = { version = "latest", source = "crates.io" }
becomes serde = "latest" in the generated Cargo manifest, and cargo answers:
failed to parse the version requirement `latest` for dependency `serde`
A PyPI dependency goes the same way: the generated pyproject.toml asks for
numpy==latest, a version that does not exist.
horus check reports this — an error for a crates.io or pypi dependency, whose
generated build file cannot be resolved, and a warning for a registry dependency, which
does resolve but is one spelling away from the rest of your manifest. Write "*".
Detailed format (version, features, source):
[dependencies]
serde = { version = "1.0", features = ["derive"], source = "crates.io" }
tokio = { version = "1", features = ["full", "rt-multi-thread"], source = "crates.io" }
Choosing a Source
The source field decides where a package is fetched from. Valid values are registry (the default), crates.io, pypi, system, path, and git.
Cargo (crates.io):
[dependencies]
rppal = { version = "0.14", source = "crates.io" } # Raspberry Pi GPIO
tokio-serial = { version = "5.4", source = "crates.io" } # Serial port
serde = { version = "1.0", features = ["derive"], source = "crates.io" }
tokio = { version = "1", features = ["full", "macros"], source = "crates.io" }
Pip (PyPI):
[dependencies]
numpy = { version = "1.24", source = "pypi" } # NumPy
opencv-python = { version = "4.8", source = "pypi" } # OpenCV
pyserial = { version = "3.5", source = "pypi" } # Serial port
System packages:
[dependencies]
eigen = { apt = "libeigen3-dev", cmake_package = "Eigen3", source = "system" }
Local path (custom driver):
[dependencies]
my_driver = { path = "../drivers/my_custom_driver" }
sensor_lib = { path = "./libs/sensor_lib" }
Git repository:
[dependencies]
# Basic git dependency
my_package = { git = "https://github.com/user/repo.git" }
# With specific branch
my_driver = { git = "https://github.com/user/driver.git", branch = "develop" }
# With specific tag
sensors = { git = "https://github.com/robotics/sensors.git", tag = "v1.2.0" }
# With specific commit
utils = { git = "https://github.com/user/utils.git", rev = "a1b2c3d4" }
Setting path or git implies the matching source, so source can be left out for those two.
Note:
cargo:andpip:prefixes are not manifest syntax. They appear only in the dependency strings HORUS harvests from your source imports; a key likecargo:rppalinhorus.tomlis read as a package name, not as a source selector. Use thesourcefield instead.
Dependency Fields
version (optional):
- Semantic version or version constraint
- Examples:
"1.0","^1.2",">=1.0,<2.0"
source (optional):
- Where the package is fetched from
- Values:
registry(default),crates.io,pypi,system,path,git
features (optional):
- Array of feature flags to enable
- Rust-specific (Cargo features)
optional (optional):
- Boolean; marks the dependency as optional
path (optional):
- Local filesystem path to a crate/package
- Implies
source = "path"
git (optional):
- Git repository URL for the package
- Must be a valid git URL (HTTPS or SSH)
- Implies
source = "git" - Resolved by the underlying toolchain — Cargo clones into
~/.cargo/gitfor Rust, pip handles Python. HORUS does not fetch git dependencies into its own cache
branch (optional, with git):
- Specific branch to checkout
- Example:
branch = "develop"
tag (optional, with git):
- Specific tag to checkout
- Example:
tag = "v1.0.0"
rev (optional, with git):
- Specific commit SHA to checkout
- Example:
rev = "a1b2c3d4"
apt and cmake_package (optional, with source = "system"):
- Apt package name to install, and the name
find_package()uses in CMake - Example:
{ apt = "libeigen3-dev", cmake_package = "Eigen3", source = "system" }
Resolution Order:
- HORUS registry (
https://api.horusrobotics.dev, override withHORUS_REGISTRY_URL) - Language-specific registry (crates.io for Rust, PyPI for Python)
Examples
Minimal:
[dependencies]
horus = "*"
With external packages:
[dependencies]
horus = "*"
serde = { version = "1", source = "crates.io" }
tokio = { version = "1", source = "crates.io" }
numpy = { version = "1.24", source = "pypi" } # Python
With versions and features:
[dependencies]
horus = "*"
serde = { version = "1", features = ["derive"], source = "crates.io" }
tokio = { version = "1", features = ["full"], source = "crates.io" }
eframe = { version = "0.29", source = "crates.io" }
egui = { version = "0.29", source = "crates.io" }
Ignore Patterns
ignore (Optional)
Exclude files, directories, and packages from HORUS processing.
Type: Table with optional files, directories, packages arrays
Required: No
Default: None
ignore.files
Exclude specific files from detection and execution.
Type: Array of glob patterns
Patterns: * (wildcard), **/ (recursive directories)
Examples:
[ignore]
files = [
"debug_*.py", # Ignore debug_test.py, debug_node.py
"test_*.rs", # Ignore all test files
"**/experiments/**", # Ignore files in any experiments/ directory
"scratch.rs", # Ignore specific file
]
ignore.directories
Exclude entire directories.
Type: Array of directory names or paths
Examples:
[ignore]
directories = ["old/", "experiments/", "tests/", "benchmarks/"]
ignore.packages
Prevent auto-installation of specific packages.
Type: Array of package names
Examples:
[ignore]
packages = [
"ipython", # Development shell
"jupyter", # Notebook environment
"pytest", # Testing framework
]
Use Case: Development-only packages that shouldn't be auto-installed in production.
Complete Ignore Example
[package]
name = "robot_controller"
version = "0.1.0"
[dependencies]
horus = "*"
serde = { version = "1", source = "crates.io" }
[ignore]
# Don't run debug files
files = ["debug_*.py", "test_*.rs", "**/temp/**"]
# Don't process these directories
directories = ["old_controllers/", "experiments/", "docs/"]
# Don't auto-install these packages
packages = ["ipython", "jupyter", "black"] # black: code formatter
See Also: Environment Management - Ignoring Files
The .horus/ Directory
The .horus/ directory is automatically managed by HORUS. You should never manually edit files inside it.
Structure
my_project/
├── horus.toml # Your configuration (edit this)
├── main.rs # Your code (edit this)
└── .horus/ # Auto-managed (don't touch)
├── packages/ # Symlinks to global cache
│ ├── horus -> ~/.cache/horus/horus@0.1.0/
│ └── serde -> ~/.cache/horus/cratesio_serde@1.0.228/
├── Cargo.toml # Generated for Rust projects
├── Cargo.lock # Cargo lock file
└── target/ # Rust build artifacts
Global Cache vs Local Workspace
Global Cache (~/.cache/horus/):
- Shared across all projects
- Downloaded once, used everywhere
- Saves disk space
Local Workspace (.horus/):
- Project-specific symlinks
- Isolated from other projects
- Auto-generated on
horus run
What's Inside .horus/
packages/:
- Symlinks to global cache packages
- Some packages may be installed directly (no symlink)
Cargo.toml (Rust projects):
- Auto-generated from
horus.tomldependencies - Uses path-based dependencies (no source copying)
- Never edit manually (regenerated on each run)
Cargo.lock (Rust projects):
- Cargo's dependency lock file
- Auto-managed
target/ (Rust projects):
- Cargo build artifacts
- Can be large (ignore in git)
bin/, lib/, include/:
- Compiled binaries and libraries
- Auto-created as needed
Git Configuration
horus new writes this .gitignore for you:
# HORUS environment (auto-managed by `horus run`)
.horus/packages/
.horus/bin/
.horus/lib/
.horus/include/
.horus/cache/
.horus/target/
.horus/Cargo.toml
.horus/Cargo.lock
*.log
# Rust
target/
Cargo.lock
The trailing block is language-specific — a Python project gets __pycache__/, *.py[cod], .pytest_cache/, *.egg-info/, dist/, and build/ instead. Note that the entries are per-subdirectory rather than a blanket .horus/, and that horus.toml is never ignored, so no negation is needed.
When .horus/ is Created
.horus/ is created automatically when you run:
horus runhorus buildhorus newhorus install <NAME>
You never need to create it manually.
Cleaning .horus/
Remove local environment:
rm -rf .horus/
Regenerate on next run:
horus run # Automatically recreates .horus/
See Also: Environment Management
Complete Examples
Single-Process Application
[package]
name = "temperature-monitor"
version = "0.1.0"
description = "Simple temperature monitoring system"
authors = ["Robotics Team"]
license = "Apache-2.0"
[dependencies]
horus = "*"
serde = { version = "1", features = ["derive"], source = "crates.io" }
[ignore]
files = ["debug_*.rs"]
packages = ["ipython"]
Multi-Process Application (Backend)
[package]
name = "robot-backend"
version = "1.0.0"
description = "Robot control backend with sensor processing"
authors = ["ACME Robotics"]
license = "Apache-2.0"
[dependencies]
horus = "*"
serde = { version = "1", features = ["derive"], source = "crates.io" }
tokio = { version = "1", features = ["full"], source = "crates.io" }
[ignore]
directories = ["tests/"]
packages = ["pytest"]
Multi-Process Application (GUI)
[package]
name = "robot-monitor"
version = "1.0.0"
description = "Real-time robot monitor and visualization"
authors = ["ACME Robotics"]
license = "Apache-2.0"
[dependencies]
horus = "*"
eframe = { version = "0.29", source = "crates.io" }
egui = { version = "0.29", source = "crates.io" }
serde = { version = "1", features = ["derive"], source = "crates.io" }
[ignore]
files = ["debug_*.rs"]
Note: HORUS uses a flat namespace (like ROS), so backend and GUI automatically share topics when using the same topic names. No configuration needed!
Python Project
[package]
name = "vision-processor"
version = "0.2.0"
description = "Computer vision processing node"
authors = ["Vision Team"]
license = "MIT"
[dependencies]
horus-robotics = { version = ">=0.2", source = "pypi" }
numpy = { source = "pypi" }
opencv-python = { source = "pypi" }
pillow = { source = "pypi" }
[ignore]
directories = ["notebooks/", "experiments/"]
packages = ["jupyter", "matplotlib"] # matplotlib: visualization only
The Python distribution is published on PyPI as horus-robotics and imported as horus. Spelling out source = "pypi" is the safe habit: HORUS recognizes well-known PyPI names (numpy, opencv-python, pillow, and several hundred more) and routes them to PyPI on its own, but anything it does not recognize stays on the HORUS registry and is left out of the generated pyproject.toml.
Hardware
[hardware] (Optional)
Declares the hardware nodes your robot has, so the driver for each one can be built from configuration instead of hand-written construction code.
Listing a driver here does not run it. Nothing in the CLI instantiates [hardware]
entries — your program has to ask for them:
use horus::prelude::*;
let mut scheduler = Scheduler::new();
for (_name, node) in horus::hardware::load()? { // reads [hardware] from horus.toml
scheduler.add(node).build()?;
}
A driver that is declared and never loaded simply does not run, and nothing reports it.
Every entry must be a table. Scalar shorthand is silently ignored — load() skips
any value that is not a table, so camera = "opencv" or lidar = true under
[hardware] does nothing at all:
# Does nothing — not a table
[hardware]
camera = "opencv"
# Correct
[hardware.camera]
use = "opencv"
use names the node type to create. It is the unified source key, and it replaces the
six legacy ones, which are still read as fallbacks in this order: terra, node, package,
exec, pip, crate. An entry with none of them is skipped with a warning
rather than failing the build.
[hardware.arm]
use = "ur5e-driver" # node type to instantiate
sim = true # substitute the sim stub under `horus run --sim`
# Everything below is a driver parameter — written inline, not in a sub-table
port = "/dev/ttyUSB0"
baudrate = 115200
topic_command = "arm/cmd"
topic_state = "arm/state"
| Reserved key | Type | Purpose |
|---|---|---|
use | string | Node type to instantiate — the key to prefer |
sim | bool | Substitute the simulated stub under horus run --sim |
args | array | Arguments passed to exec |
package | string | Legacy source key: registry package |
node | string | Legacy source key: node within a package |
crate | string | Legacy source key: Rust crate |
pip | string | Legacy source key: PyPI distribution |
exec | string | Legacy source key: run an external executable |
terra | string | Legacy source key: Terra driver shortname |
source | string | Where to resolve the package from |
simulated | bool | Parsed but never read — has no effect |
Driver parameters are the keys you write directly in the entry. The parameter map is
#[serde(flatten)], so every key not in the reserved list above becomes a parameter
under its own name. Writing a [hardware.arm.params] sub-table does not spread its
contents — it produces one parameter literally named params whose value is the whole
table, and the driver never sees port or baudrate.
This also means topic, topic_state and topic_command are ordinary parameters, not
reserved keys: they reach the driver only if that driver reads them by those names.
[drivers] and [sim-drivers] (Deprecated)
[drivers] is the former name of [hardware]. It is still read — load() falls back to
it when there is no [hardware] table — so existing projects keep working.
[sim-drivers] parses without complaint and is then never consulted by any code path. A
project still relying on it has silently lost its simulation override: horus run --sim keys off the sim flag on the entry itself, so a driver whose simulated
counterpart lives in [sim-drivers] now runs its real backend under --sim.
Move each override onto the entry it belongs to. Keeping the real and simulated
definition together is what makes --sim a flag rather than a second manifest to keep
in step.
horus check reports a [sim-drivers] table as a warning naming its replacement, so a
project still carrying one is told rather than left to discover it when a real motor
moves under --sim.
# Old — the [sim-drivers] half is now dead
[drivers]
lidar = "rplidar"
[sim-drivers]
lidar = "sim3d"
# New
[hardware.lidar]
use = "rplidar"
sim = true
Scripts
[scripts] (Optional)
Project-local commands, in the spirit of npm scripts or a justfile. Each entry maps a
name to a shell command, run with horus scripts <name>. The bare form horus <name> does not work — an unknown first argument is resolved as a plugin, never as a script, so it fails with clap's usage message.
[scripts]
calibrate = "python3 tools/calibrate.py"
park = "python3 tools/park_arm.py"
flash = "openocd -f board/stm32.cfg -c 'program firmware.elf verify reset exit'"
horus scripts calibrate # run it
horus scripts # list what is available
Scripts are the unit that [hooks] schedules, so a command worth running by hand is
usually worth naming here first.
Hooks
[hooks] (Optional)
Commands HORUS runs around its own lifecycle. Every phase has a pre_ and a post_
hook:
[hooks]
pre_build = ["fmt"]
post_build = ["sign-firmware"]
pre_run = ["check"]
post_run = ["park"] # runs even if the run crashed
pre_test = ["fmt", "lint"]
post_test = ["coverage-report"]
Each entry is either a built-in — fmt, lint, or check — or the name of a
[scripts] entry. A name that is neither is an error, not a silent skip.
post_run runs on both the success and the failure path. That is deliberate: the
moment you most need an arm parked, a bus released, or a motor controller de-energized
is the moment the run died unexpectedly. A teardown that only ran after clean exits
would be missing from exactly the case it exists for.
If a post_ hook fails after the command itself already failed, HORUS reports the
original failure and mentions the hook separately. Your crash is what you need to see,
not your cleanup script.
Pass --no-hooks to skip both halves for one invocation:
horus run --no-hooks
Capabilities
enable (Optional)
Optional subsystems to compile in. This is a top-level key, not a table:
enable = ["net"]
| Capability | Effect |
|---|---|
net (alias network) | Compiles in horus_net, enabling multi-machine transport. Equivalent to horus run --net. |
net is the only capability HORUS itself implements. It is special-cased by the
manifest generator, which turns it into a feature on the horus dependency — the
one place cargo accepts it. horus has exactly five features (default, macros,
telemetry, blackbox, net), and asking for --features net on your own crate is
rejected outright.
Every name that is not net/network is forwarded to cargo build --features <name>
against the crate HORUS generates for your code in .horus — a few renamed on the
way (gpu → cuda, py → python, opencv → opencv-backend, io-uring →
io-uring-net). Unless you declare that feature yourself under [rust.features], the
build fails with cargo complaining that no selected package contains it. There is no
CUDA capability anywhere in HORUS: enable = ["cuda"] does not build.
The two exceptions are sim and simulation, which expand to nothing and are accepted
as no-ops.
Capabilities cost build time, so they are opt-in. net in particular gates the whole
networking stack: without it, a distributed setup builds and runs but never leaves the
machine.
Networking
[network] (Optional)
Configures the transport once net is enabled. Enabling the capability makes the code
available; this table decides what it does.
HORUS applies this table by translating it into HORUS_NET_* variables on the child
process — and it does that only when horus run launches several executables
(horus run a.rs b.rs, or a manifest with multiple binaries). An ordinary
single-executable run never performs the translation, so [network] is parsed and then
has no effect.
Until that is fixed, set the variables in the environment for a single-binary robot:
HORUS_NET_ENABLED=1 HORUS_NET_IMPORT=auto horus run
Environment variables are the reliable path in both cases: the translation deliberately
does not overwrite a variable you set yourself, so an explicit HORUS_NET_* always wins
over the manifest.
[network]
enabled = true
secret = "shared-token" # peer filtering only — NOT authentication
import = ["robot-b/odom"] # topics to subscribe to from other machines
deny_export = ["camera/raw"] # topics that must never leave this machine
optimize = ["delta", "spatial"] # optimizers to enable (names, not topics)
[network.safety]
heartbeat_ms = 100 # how often peers announce themselves
missed_threshold = 3 # missed heartbeats before the link is considered lost
on_link_lost = "safe_state" # what to do then: "warn", "safe_state", or "stop"
secret filters which peers will talk to each other — a host whose secret does not match
is ignored. It is not authentication and not encryption: the value is hashed to four
bytes and used to reject strangers, so treat it as a way to keep two labs on one subnet
from joining each other's topics, not as a security boundary. Set it here as a literal
string, or leave it out of the manifest and export HORUS_NET_SECRET instead — there is
no env: indirection, so writing secret = "env:MY_VAR" makes the literal text
env:MY_VAR your secret.
Every key above also has an environment override, which is what deployment tooling
usually reaches for: HORUS_NET_ENABLED, HORUS_NET_SECRET, HORUS_NET_IMPORT,
HORUS_NET_DENY_EXPORT, HORUS_NET_OPTIMIZERS (note the plural — optimize in the
manifest), HORUS_NET_HEARTBEAT_MS, HORUS_NET_MISSED_THRESHOLD,
HORUS_NET_ON_LINK_LOST. HORUS_NET_PORT, HORUS_NET_MULTICAST and
HORUS_NET_PEER exist as well, but have no [network] counterpart and can only be
set in the environment.
deny_export is worth setting before you need it. A camera topic that is useful on the
robot and ruinous over a shared network is the common case, and the list is easier to
write while you still remember which topics those are.
optimize names optimizers, not topics. Exactly four are registered:
| Optimizer | What it does |
|---|---|
fusion | Coalesces multiple small updates into one datagram |
delta | Sends only what changed since the last message |
spatial | Suppresses sends to peers outside a radius — fixed at 15 m |
predict | Suppresses updates a receiver can extrapolate — fixed error threshold of 0.01 |
All are off by default. An unrecognised entry — a topic name, say — is not an error: it
prints [horus_net] Unknown optimizer: <name> to stderr at startup and installs nothing,
so optimize = ["lidar/points"] silently gives you no optimization at all.
The two thresholds are not configurable today. spatial_radius and
predict_threshold parse from [network.topic.*] and are read by nothing — the
optimizers are constructed with their defaults, and the with_radius() /
with_threshold() constructors that would honour them are called only from unit tests.
Setting either key changes nothing.
[network.safety] is what makes a dropped link a controlled event. With
on_link_lost = "safe_state", nodes that depend on a remote topic enter their safe state
rather than continuing to act on the last value they received — which, for a robot
holding a stale velocity command, is the difference between stopping and driving away.
The parser recognises three values — warn (the default), safe_state (safestate is
also accepted) and stop. Anything else silently becomes warn. So
on_link_lost = "safe" does not safe anything: it logs and keeps going, which is the
precise failure this setting exists to prevent. There is no error and no warning about
the unrecognised value, so check the spelling rather than assuming a link-loss drill
passed because nothing complained.
stop halts the whole scheduler — the blunt option, for when a missing peer means the
system has no business continuing at all.
Authenticating the networked e-stop
secret does not protect the e-stop channel. Networked e-stop (_horus.estop) is
authenticated separately, with an HMAC-SHA256 tag keyed by HORUS_ESTOP_KEY — a value
provisioned off the wire and never transmitted:
# Same value on every node in the fleet
export HORUS_ESTOP_KEY=$(openssl rand -hex 32)
If HORUS_ESTOP_KEY is unset, or the tag on an incoming e-stop is missing or does not
verify, the e-stop is rejected — a remote halt will not stop the robot. The key must
be identical on every node; a mismatch is indistinguishable from a forgery and is
refused the same way. HORUS logs the rejection once per process rather than on every
packet, so check that line early rather than waiting for it during an incident.
Local safety mechanisms — SafetyMonitor, watchdogs, on_link_lost = "safe_state" — are
unaffected and keep working with no key configured. It is specifically the remote
e-stop path that closes.
64 hex characters are used as raw key material. Anything else is treated as a passphrase and stretched with PBKDF2-HMAC-SHA256, which is slower to start and only as strong as the passphrase — every packet on the wire is an offline verifier for a guess, so prefer the random hex form.
Which hosts the replicator will listen to
The replicator binds 0.0.0.0, so the second half of the network posture is a source-
address filter. By default it accepts datagrams only from private space:
| Range | What it covers |
|---|---|
127.0.0.0/8 | loopback |
10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 | RFC 1918 private networks |
169.254.0.0/16 | RFC 3927 link-local |
100.64.0.0/10 | RFC 6598 CGNAT — also where mesh VPNs such as Tailscale allocate |
Zero-config replication between robots on a LAN therefore works untouched. A peer whose address falls outside those ranges is dropped without reaching the import guard — worth knowing before debugging a link that looks correctly configured but never delivers.
Widen it with HORUS_NET_ALLOW_PEERS, a comma-separated list of addresses and CIDR
blocks:
export HORUS_NET_ALLOW_PEERS="203.0.113.0/24,198.51.100.7"
An unparseable entry is reported and skipped rather than silently ignored, and if
every entry fails to parse the default is kept — a typo cannot quietly widen the
filter. Setting it to any (or * / 0.0.0.0/0) accepts UDP from any routable host,
including the public internet; HORUS warns when you do.
Note the two similarly named variables do different jobs: HORUS_NET_PEER adds a peer
to contact directly when multicast discovery cannot reach it, while
HORUS_NET_ALLOW_PEERS decides whose inbound packets are accepted at all.
This filter is a reduction in reach, not authentication — a hostile host on the same
LAN still passes it. It and HORUS_ESTOP_KEY are complementary, and a fleet that can
be reached from outside its own subnet wants both.
Robot Metadata
[robot] (Optional)
Describes the physical robot, for tools that need to know about it — the 3-D simulator, the visualizer, and anything that loads a URDF.
[robot]
name = "husky-a200"
description = "urdf/husky.urdf" # path to the URDF
simulator = "sim3d" # simulator `horus run --sim` launches
| Field | Type | Purpose |
|---|---|---|
name | string | Robot name, shown in the simulator and visualizer |
description | string | Path to the URDF describing the robot |
simulator | string | Simulator to launch for --sim; defaults to sim3d |
Workspaces
[workspace] (Optional)
Groups several HORUS projects that build and version together — the usual shape for a robot whose perception, planning, and control stacks live in one repository.
[workspace]
members = ["perception", "planning", "control"]
exclude = ["experiments/*"]
[workspace.dependencies]
horus-robotics = "0.2"
nalgebra = "0.33"
| Field | Type | Purpose |
|---|---|---|
members | array | Member project directories; globs are accepted |
exclude | array | Directories to leave out of an otherwise matching glob |
dependencies | table | Versions members can inherit, so they cannot drift apart |
Declaring a dependency in [workspace.dependencies] and inheriting it from each member
is what keeps two nodes in the same robot from linking two different versions of the
same message crate.
Simulation and Development Dependencies
[dev-dependencies] (Optional)
Dependencies needed to develop and test the project, but not to run it. They are
excluded from horus publish and horus deploy, which is the point: test harnesses do
not belong on the robot.
[dev-dependencies]
criterion = "0.5"
approx = "0.5"
[sim-dependencies] (Optional)
Simulation assets, fetched from the HORUS registry and cached under
.horus/packages/. They are installed on demand by horus sim3d, so a fresh clone
does not have to carry a warehouse of meshes.
[sim-dependencies]
warehouse-world = "1.2"
ur5e-robot = "0.4"
Package types are robot, world, sensor-preset, actuator-preset, and task.
C++ Build Configuration
[cpp] (Optional)
Overrides for C++ projects. HORUS detects a working toolchain on its own; this table is for the cases where the detected one is not the one you want.
[cpp]
compiler = "clang++"
toolchain = "armv7" # cross-compilation target
cmake_args = ["-DCMAKE_BUILD_TYPE=Release", "-DUSE_SIMD=ON"]
| Field | Type | Purpose |
|---|---|---|
compiler | string | Compiler to use instead of the detected one |
toolchain | string | Target architecture name, or a path to a custom .cmake toolchain file. Not a GNU triple — see below |
toolchain takes one of HORUS's own target names, not a compiler prefix:
| Value | Target |
|---|---|
aarch64, arm64, jetson, pi4, pi5 | 64-bit ARM |
armv7, arm, pi3, pi2 | 32-bit ARM |
x86_64, x64, amd64, intel | 64-bit x86 |
native, host, local | Build for this machine |
Anything else is treated as a path and must contain a / or end in .cmake. A GNU
triple such as arm-linux-gnueabihf is neither, so it fails the build outright:
Error: Failed to resolve toolchain 'arm-linux-gnueabihf': Unknown toolchain target:
'arm-linux-gnueabihf'
Supported targets: aarch64, arm64, jetson, pi4, pi5, armv7, arm, pi3, pi2, x86_64, native
Or provide a path to a custom .cmake toolchain file
| cmake_args | array | Arguments appended to the CMake invocation |
Rust Build Configuration
[rust] (Optional)
Cargo settings for the generated build.
.horus/Cargo.toml is generated from horus.toml and rewritten whenever
horus.toml is newer, so anything added to it by hand is lost on the next build.
[rust] is where those settings go instead — its tables are spliced into the
generated manifest.
[rust]
edition = "2024" # overrides [package].rust_edition
[rust.features]
default = ["fast"]
fast = []
[rust.profile.release]
lto = "fat"
codegen-units = 1
panic = "abort"
[rust.lints.rust]
unsafe_code = "forbid"
[rust.build-dependencies]
cc = "1"
[rust.patch."https://github.com/example/other.git"]
somecrate = { path = "/opt/somecrate" }
| Field | Type | Purpose |
|---|---|---|
edition | string | Rust edition. Beats [package].rust_edition; default 2021 |
features | table | Becomes [features] |
profile | table | Becomes [profile.*] — opt-level, lto, panic, and friends |
lints | table | Becomes [lints] |
build-dependencies | table | Becomes [build-dependencies] |
target | table | Becomes [target.*] for per-target settings |
patch | table | Merged with the [patch] tables HORUS emits for its own git sources |
There is deliberately no [rust.dependencies]. horus.toml already has a
[dependencies] table that horus cargo add round-trips through, and a second
channel would let the same crate be declared twice in ways cargo rejects.
[rust.patch] merges rather than replaces: you can add crates to a source HORUS
also patches, but not displace the entries it needs — without those, a generated
project cannot resolve horus_core. A collision is logged and HORUS's entry wins.
[rust] applies only when HORUS generates the manifest. A project with its own
root Cargo.toml is built from that file directly, so the section has no effect
— horus build warns when it finds one in that situation. horus run does not:
it builds from the root Cargo.toml without saying that [rust] was skipped.
Best Practices
Version Your Configuration
Always commit horus.toml to version control:
git add horus.toml
git commit -m "Update dependencies: add sensor-fusion package"
Use Semantic Versioning
Follow semver for your project version:
0.x.y- Initial development1.0.0- First stable releasex.y.z- Major.Minor.Patch
Pin Critical Dependencies
For production, consider pinning exact versions:
[dependencies]
critical-package = "=1.2.3" # Exact version
Document Your Configuration
Add comments to explain non-obvious choices:
[dependencies]
# Version 2.0 required for new path planning algorithm
motion-planner = "^2.0"
Keep It Minimal
Only specify what you need:
# Minimal — name and version are the only required keys
[package]
name = "my_robot"
version = "0.1.0"
[dependencies]
horus = "*"
Everything else in [package] is optional metadata. horus new fills in description, authors, and license for you; keep them if they are useful, trim them if they are not:
[package]
name = "my_robot"
version = "0.1.0"
description = "A robot"
authors = ["Me"]
license = "MIT"
[dependencies]
horus = "*"
Validation
Every command that reads horus.toml validates it. They differ only in what they do
about a problem:
| Command | On a key HORUS does not understand |
|---|---|
horus check | Reports it as an error and exits non-zero |
horus run, build, test, add, everything else | Prints a warning on stderr and carries on |
horus check is the gate, so it is the one that fails. The others warn rather than stop,
because a typo in [network] is not a reason to refuse to build in the middle of a
session — but it is never silent, which is what it used to be.
Findings are printed as file:line:column: message, the same format a compiler uses, so
an editor can jump to them. horus check --json carries the same set as a diagnostics
array with severity, code, file, line and column per entry.
Which tables are closed
A closed table has a fixed set of keys, and anything else in it is a mistake. An open table is a namespace you fill with your own names, so nothing in it is ever flagged.
| Closed — unknown keys are reported | Open — you name the keys |
|---|---|
[package], [workspace], [robot], [cpp], [rust], [hooks], [ignore], [network], [network.safety] | [dependencies], [dev-dependencies], [sim-dependencies], [hardware], [drivers], [sim-drivers], [scripts] |
The top level itself is closed: a [section] HORUS does not know is reported like any
other unknown key.
Keys That Do Nothing
$ horus check
> horus.toml
x horus.toml:4:1: unknown key `package.langauge` — HORUS detects the language
from your source files — remove this key. It has no effect.
x horus.toml:9:1: unknown key `network.sekret` — did you mean `secret`? This
key has no effect.
Fix: correct the spelling, or delete the key. HORUS never reads it, so leaving it in
means the setting you think you made was never made — the network.sekret case above
leaves a fleet unauthenticated while the manifest looks configured.
This was a warning in 0.2.x and is an error from 0.3.0 on.
Missing Required Fields
x ./horus.toml:1:1: missing field `name` in table [package]
The file is valid TOML — a required key is absent.
Add it, or run `horus new` to generate a complete manifest.
Fix: Add a [package] section with name and version, or run horus new.
Invalid TOML Syntax
x ./horus.toml:5:6: key with no value, expected `=`
Fix: Check TOML syntax (table headers, = assignments, quoted strings). The two
classes read differently on purpose: a missing key says the file is valid TOML, a
syntax error does not.
Invalid Version Format
x horus.toml:3:1: Version '1.0' is not valid semver (expected e.g., '0.1.0')
Fix: Use format "MAJOR.MINOR.PATCH".
What horus check Does Not Check
C++ sources. There is no C++ phase — the include paths come from the build, which is what
horus build is. check says so rather than reporting a project clean when its .cpp
files were never read:
Found 2 C++ file(s) — not syntax-checked here; `horus build` compiles them
Editor Support
horus.toml has a published JSON Schema, so an editor can autocomplete keys, show the
documentation for each one on hover, and underline a typo as you type instead of at build
time.
Stable URL (tracks the current release):
https://docs.horusrobotics.dev/schema/horus.toml.schema.json
In VS Code with Even Better TOML,
or any taplo-based setup, add this to .taplo.toml at the
root of your project:
[[schema]]
url = "https://docs.horusrobotics.dev/schema/horus.toml.schema.json"
include = ["**/horus.toml"]
Or point the editor at a local copy, which is what you want in an air-gapped setup:
horus schema -o horus.toml.schema.json
The schema is generated from the same Rust structs horus check validates against, by
horus schema, and CI fails if the published file and the generator disagree
(the_published_schema_matches_the_generator). So the editor and the CLI cannot disagree
about which keys exist — not because the file is regenerated on a schedule, but because a
change to the structs that is not carried into it is a red build.
A copy you vendored yourself is a different matter: nothing regenerates that one, and it
goes stale silently. Its $id names the URL of the current one, which is how you tell
them apart.
Migration Guide
From Cargo Projects
Before (Cargo.toml):
[package]
name = "my-robot"
version = "0.1.6"
edition = "2021"
[dependencies]
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
After (horus.toml):
[package]
name = "my-robot"
version = "0.1.0"
[dependencies]
horus = "*"
serde = { version = "1", features = ["derive"], source = "crates.io" }
tokio = { version = "1", features = ["full"], source = "crates.io" }
Then run:
horus run # HORUS generates Cargo.toml automatically
From Python Projects
Before (requirements.txt):
numpy==1.24.0
opencv-python>=4.5
pillow
After (horus.toml):
[package]
name = "vision-processor"
version = "0.1.0"
[dependencies]
horus-robotics = { version = ">=0.2", source = "pypi" }
numpy = { version = "1.24.0", source = "pypi" }
opencv-python = { version = ">=4.5", source = "pypi" }
pillow = { source = "pypi" }
Next Steps
- Environment Management - The auto-managed
.horus/environment - Package Management - Install and manage packages
- CLI Reference - All HORUS commands
- Topic - Understanding the IPC architecture