A module is a typed Rust worker that the harness auto-discovers from a
*.worker.rs file. You write the worker, drop the file in a module root, and
rebuild — the build script finds it, registers it, and the harness runs its
triggers. No central registry to edit, no YAML.
This guide is for a Rust developer new to hex. It is code-forward: every symbol
below exists in system/harness/.
-
One file → one module. A module is a single
*.worker.rsfile that exposes exactly one entry point:pub fn worker() -> hex::worker::Worker
-
Single responsibility. A worker bundles a
nameplus one or more(trigger, handler)pairs. Keep each file focused on one job (a backup, a memory-maintenance schedule, one integration). Multiple triggers in one worker are fine when they serve the same responsibility. -
Author through the
hexlib, never iii directly. You build workers with thehex::workerAPI (Worker,Ctx,Event). The harness maps your declarative triggers onto the underlying iii engine at startup (TriggerSpec::to_register_input). You never touchiii_sdkfrom a module.
build.rs recursively globs every file ending in .worker.rs under each
discovery root. For each match it derives a Rust module identifier:
- Strip the
.worker.rssuffix from the path relative to the root. - Replace each
/,., and-with_.
Examples:
| File (relative to root) | Module ident |
|---|---|
backup.worker.rs |
backup |
memory_maintenance.worker.rs |
memory_maintenance |
trading/kalshi.worker.rs |
trading_kalshi |
The ident must be a valid Rust identifier: it must start with an ASCII letter,
contain only [A-Za-z0-9_], and be non-empty. A file that does not yield a
valid ident panics the build (loud by design — S6). If two files map to the
same ident, the build also panics:
hex module: ident collision on 'trading_kalshi' (from '...') — rename the file
build.rs globs from two roots:
- Core (always):
system/harness/src/modules/— in-repo, shipped to everyone who installs hex-foundation. - Personal overlay (only under
--features personal):$HEX_DIR/.hex/modules/(falls back to$HOME/hex/.hex/modules/ifHEX_DIRis unset) — your machine only, never in the foundation repo.
Both roots are globbed recursively, so subdirectories like
trading/kalshi.worker.rs are found. An absent root contributes nothing (the
foundation builds clean with no personal overlay present).
Codegen writes $OUT_DIR/hex_modules.rs, containing:
- a
#[path = "..."] pub mod <ident>;line per discovered file, pub fn module_registry() -> Vec<crate::worker::Worker>— calls every module'sworker(),pub fn module_paths() -> Vec<(String, &'static str)>— maps each worker'snameto its source file path (driveshex module list's source column).
workers/mod.rs::registry() is built from module_registry(). It also asserts
worker names are unique at runtime — two files with distinct idents but the
same Worker::new(name) will abort.
There is no runtime add command. You install a module by dropping its
*.worker.rs file in a root and rebuilding the harness. The build script
re-runs whenever a *.worker.rs file or a root directory changes (per-file
cargo:rerun-if-changed triggers), regenerates hex_modules.rs, and the new
binary ships the module.
This is deliberate: modules are typed Rust compiled into the harness, not plugins loaded at runtime. You get compile-time checking of every trigger and handler, and there is no dynamic-loading attack surface. The price is a rebuild per change — cheap, and it keeps the registry honest.
A minimal, copy-pasteable worker. Save as
system/harness/src/modules/hello.worker.rs:
//! `hex-hello` — a tiny example worker that emits an event on a schedule.
use hex::worker::{ctx::Ctx, event::Event, Result, Worker};
/// Cron expression: top of every hour (7-field: sec min hour dom mon dow year).
const CRON_HOURLY: &str = "0 0 * * * * *";
/// The handler. Signature is fixed:
/// Fn(Event, Ctx) -> Result<()> + Send + Sync + 'static
fn say_hello(_e: Event, ctx: Ctx) -> Result<()> {
ctx.emit("hello.tick", serde_json::json!({ "msg": "hi from hex" }))?;
Ok(())
}
/// The required entry point. Build a `Worker`, chain `.on_*` triggers.
pub fn worker() -> Worker {
Worker::new("hex-hello").on_cron(CRON_HOURLY, say_hello)
}Notes:
- The import line
use hex::worker::{ctx::Ctx, event::Event, Result, Worker};is the standard set; it matches the shipped workers. Result<T>here ishex::worker::Result<T>=std::result::Result<T, anyhow::Error>. Use?freely.- A handler can be a plain
fn(as above) or any closure satisfyingFn(Event, Ctx) -> Result<()> + Send + Sync + 'static.
system/harness/src/modules/backup.worker.rs — the entire file:
use hex::worker::{ctx::Ctx, event::Event, Result, Worker};
/// Cron expression for the nightly backup job — 04:00 daily.
pub const CRON_DAILY: &str = "0 0 4 * * * *";
/// Argv for the backup job.
pub const ARGV_BACKUP: &[&str] = &["hex", "backup"];
fn run_backup(_e: Event, ctx: Ctx) -> Result<()> {
let argv: Vec<String> = ARGV_BACKUP.iter().map(|s| s.to_string()).collect();
ctx.run(&argv).map(|_| ())
}
/// Build the `hex-backup` worker.
pub fn worker() -> Worker {
Worker::new("hex-backup").on_cron(CRON_DAILY, run_backup)
}For a multi-trigger worker, see
system/harness/src/modules/memory_maintenance.worker.rs, which chains four
.on_cron(...) calls (index / quick-consolidate / parse-transcripts every 15
min, full consolidation at 03:00), each shelling out via ctx.run(...).
Each .on_* builder pushes one (TriggerSpec, Handler) pair and returns
Self, so you chain them. All four take a handler with the same
Fn(Event, Ctx) -> Result<()> + Send + Sync + 'static signature.
| Builder | Fires when | Use for |
|---|---|---|
.on_cron(expr, f) |
the cron schedule matches | periodic jobs (backups, maintenance, reports) |
.on_event(event, f) |
a named hex event is emitted | reacting to domain events (boi.spec.complete, …) |
.on_state(scope, key, f) |
scope/key state changes |
reacting to a specific KV entry changing |
.on_queue(queue, f) |
a job lands on a named queue | durable background work |
pub fn worker() -> Worker {
Worker::new("hex-example")
.on_cron("0 0 3 * * * *", nightly) // 03:00 daily
.on_event("boi.spec.complete", on_complete) // named event
.on_state("landings", "today", on_landing) // state change
.on_queue("ingest", drain_ingest) // queue job
}Cron format. Expressions are 6- or 7-field: sec min hour dom mon dow [year]. 0 */15 * * * * * = every 15 minutes; 0 0 4 * * * * = 04:00 daily.
on_event under the hood. A named event maps to a state trigger with
scope="events", key=<event> — the same convention ops::emit uses when it
writes an event envelope. You don't need to know this to use it, but it's why
.on_event("x", ...) and .on_state("events", "x", ...) are equivalent.
Ctx is the handle passed as the second handler argument. Three capabilities:
ctx.emit("hello.tick", serde_json::json!({ "spec_id": "S123" }))?;Writes a {event, producer, ts, data} envelope to hex state, where other
workers' .on_event(...) triggers pick it up. (During the harness shutdown
drain window the runtime transparently diverts emits to a durable outbox and
replays them on next init — at-most-once; nothing for you to handle.)
let out = ctx.run(&["hex".to_string(), "backup".to_string()])?;run(argv: &[String]) spawns argv[0] with the rest as args and returns
std::process::Output. A non-zero exit is an Err carrying the exit code
and a stderr (or stdout) tail — so failing crons land in telemetry with a real
reason instead of a silent ok. Empty argv is also an error.
ctx.state() returns a StateHandle. Scope and key are your choice; values are
arbitrary serde_json::Value.
let st = ctx.state();
st.set("counters", "ticks", serde_json::json!(1))?;
match st.get("counters", "ticks")? {
Some(v) => { /* present */ }
None => { /* absent — get returns Ok(None), not an error */ }
}
st.delete("counters", "ticks")?;StateHandle methods:
get(scope, key) -> Result<Option<Value>>—Ok(None)when the key is absent.set(scope, key, value: Value) -> Result<()>delete(scope, key) -> Result<()>
The first handler argument is Event, a typed view over the
{event, producer, ts, data} envelope:
fn on_complete(e: Event, _ctx: Ctx) -> Result<()> {
let _name = e.event(); // -> &str, e.g. "boi.spec.complete"
let _producer = e.producer(); // -> &str, who emitted it
let _ts = e.ts(); // -> &str, ISO timestamp
let _raw = e.envelope(); // -> &serde_json::Value, full envelope
let data = e.data(); // -> Data<'_>
let spec_id = data.str("spec_id")?; // typed string, Err if missing / not a string
let _whole = data.raw(); // -> &serde_json::Value, the data object
println!("spec {spec_id} done");
Ok(())
}Data accessors:
str(k) -> Result<&str>— errors if the key is missing or not a string.raw() -> &Value— the underlyingdataobject for anything more complex.
Cron and most periodic handlers ignore the event (fn run(_e: Event, ...)).
# 1. Drop the file in a module root.
# core: system/harness/src/modules/hello.worker.rs
# personal: $HEX_DIR/.hex/modules/hello.worker.rs (needs --features personal)
# 2. Rebuild the harness (this is the "install").
cargo build --release # core only
cargo build --release --features personal # to also pick up the overlay
# 3. Confirm it registered. `list` shows name, [source], and triggers.
hex module list
# hex-hello [core] cron(0 0 * * * * *)
# hex-backup [core] cron(0 0 4 * * * *)
# 4. Inspect one worker.
hex module status hex-hello
# name: hex-hello
# source: core
# file: .../system/harness/src/modules/hello.worker.rs
# triggers: cron(0 0 * * * * *)The [source] tag is derived from the file path: core for
/src/modules/, personal for /.hex/modules/.
Inspect any state your worker reads or writes from the shell:
hex state get counters ticks # prints JSON; empty + exit 1 if absent
hex state set counters ticks 1 # value is a JSON literal
hex state delete counters tickshex follows a base/overlay model. Modules pick their layer by where the file lives:
| Core | Personal | |
|---|---|---|
| Location | system/harness/src/modules/ (in-repo) |
$HEX_DIR/.hex/modules/ (overlay) |
| Shipped to | everyone who installs hex-foundation | your machine only |
| Build flag | always globbed | only under --features personal |
hex module status source |
core |
personal |
Put a module in core when it belongs in the shipped system (the canonical
backup and memory-maintenance workers live here). Put it in personal when
it's specific to your machine, secrets, or accounts — it stays out of the
foundation repo entirely and is compiled in only when you build
--features personal. The two roots share the same convention, idents, and
collision rules; they differ only in visibility and the build flag.