Skip to content

Rhai Mapper and Helper API

JacqOS exposes Rhai in two distinct execution contexts:

  • Mapper context turns raw observations into typed atoms. Mappers are the only place where the atom(...) host function is registered, because mappers are the structural boundary between external evidence and the semantic logic layer.
  • Helper context runs pure functions called from .dh rules via the helper.<name>(...) prefix. Helpers cannot construct atoms, cannot access observations, and have no awareness of the call site that invoked them.

Both contexts run in the same sandboxed Rhai engine with the same set of bundled language packages. They differ only in which JacqOS host functions are registered. This page documents every host function in each context, the entrypoints the shell expects, the bundled standard library, the resource budgets that apply, and the things that are deliberately excluded.

The portable contract that downstream tools verify is the canonical mapper export (mapper_output_digest), not the Rhai source. Two different Rhai implementations that produce the same canonical export converge on the same digest.

Every mapper file must export a top-level function named map_observation that accepts one observation and returns an array of atoms. The shell dispatches each observation to every loaded mapper file and unions the returned atoms into a single batch.

fn map_observation(obs) {
let body = parse_json(obs.payload);
if obs.kind == "slot.status" {
return [
atom("slot.id", body.slot_id),
atom("slot.state", body.state),
];
}
[]
}

If map_observation is not present in a file under mappings/, the file fails to load.

The observation map passed to map_observation exposes these fields:

FieldTypeDescription
obs.kindstringObservation classifier (e.g. "booking.request").
obs.payloadstringRaw payload, typically a JSON string. Already bounded by payload_limit before Rhai is invoked.
obs.refstringUnique observation reference. Automatically attached to every atom returned.
obs.timestampstringEvent-time label supplied by the observation producer or fixture. Native time helpers parse RFC3339 and unix:<millis> values; fixture defaults like fixture-line-1 are ordering labels only.
obs.sourcestringSource identifier (e.g. "webhook", "replay").

A mapper may also export a mapper_contract() function that declares which observation classes carry fallible-sensor or fallible-decider evidence and must be relayed through the reserved candidate. or proposal. namespace before the ontology can act on them. The relay-boundary validator reads this contract and rejects any rule that derives an accepted fact or executable intent directly from a relay-marked atom without going through the relay namespace first.

fn mapper_contract() {
#{
requires_relay: [
#{
observation_class: "llm.extraction_result",
predicate_prefixes: ["extraction.condition", "extraction.medication"],
relay_namespace: "candidate",
}
],
}
}

Allowed values for relay_namespace are "candidate" and "proposal". The flagship examples (jacqos-incident-response, jacqos-medical-intake, jacqos-chevy-offer-containment, jacqos-drive-thru-ordering) all declare a contract; the simpler examples (jacqos-appointment-booking, jacqos-price-watch, jacqos-smart-farm) omit it because they have no relay-namespaced atoms.

These host functions are registered in both mapper and helper context. They are deterministic: they parse caller-supplied values and never read the filesystem, network, environment, host clock, or host timezone.

parse_json(input: String) -> Dynamic

Parses a JSON string into a Rhai value (object map, array, scalar). Throws a runtime error tagged JsonParse when the input is not valid JSON. Walks the parsed tree and rejects any string longer than max_string_size, any array longer than max_array_size, or any object key longer than max_string_size before returning.

let body = parse_json(obs.payload);
let email = body.email;
let items = body.line_items;

Use parse_json when the payload is contractually JSON. Throwing fails the mapper invocation, which is what you want when malformed input should halt processing.

parse_json_opt(input: String) -> Dynamic

Same as parse_json but returns () (Rhai unit) instead of throwing when the input is not valid JSON. Use this when the payload may be either JSON or some other format and you want the mapper to handle both branches.

let body = parse_json_opt(obs.payload);
if body != () {
atoms.push(atom("parsed", "true"));
}

Time helpers let you derive freshness windows, deadlines, and calendar facts without giving Rhai access to an ambient clock.

Supported input formats:

  • RFC3339 timestamps with an explicit zone, such as 2026-04-01T12:00:00Z or 2026-04-01T14:00:00+02:00
  • Unix millisecond labels in the form unix:<millis>, such as unix:1775044800000

Fixture defaults such as fixture-line-1 are ordering labels. Time helpers reject them instead of guessing.

FunctionReturnsUse it for
time_unix_ms(input)intConvert RFC3339 or unix:<millis> to Unix milliseconds.
time_diff_ms(later, earlier)intCompute later - earlier in milliseconds.
time_add_ms(input, delta_ms)stringCreate a deterministic unix:<millis> timestamp.
time_within_ms(event, anchor, window_ms)boolCheck that event is not after anchor and is within a non-negative window.
time_local_date(input, offset_minutes)stringGet YYYY-MM-DD using a fixed UTC offset.
time_local_weekday(input, offset_minutes)intGet local weekday using a fixed UTC offset, Monday 1 through Sunday 7.
fn map_observation(obs) {
let body = parse_json(obs.payload);
let fresh = time_within_ms(body.evidence_at, obs.timestamp, 300000);
[
atom("evidence.id", body.id),
atom("evidence.event_ms", time_unix_ms(body.evidence_at)),
atom("evidence.fresh_at_request", fresh),
]
}

For replay-safe freshness, always compare against another observation’s event time or a payload time you mapped explicitly. Do not ask “is this fresh now?” inside a mapper or helper; there is no now() function by design.

The mapper context registers one additional host function.

atom(predicate: String, value: Dynamic) -> RuntimeAtom

Constructs an atom that carries the current observation’s ref, the supplied predicate, and the supplied value. The observation reference is attached implicitly from the invocation context — you never pass it.

atoms.push(atom("booking.email", body.email));

atom(...) errors if it is called outside map_observation (for example, from a top-level statement or from a helper file). The error message is mapper invocation is missing observation context.

The value is later canonicalized through a JSON projection. Allowed value types are JSON-representable scalars, arrays, and object maps. Rhai-only values (closures, opaque handles, Fn references) trigger an UnsupportedAtomValue error after map_observation returns.

Each top-level fn <name>(...) in a file under helpers/*.rhai becomes a callable helper. From .dh rules, invoke it with the helper. prefix:

rule normalized_change(p, n, pct) :-
price_observation(p, old, new),
pct = helper.abs_pct_change(old, new).

Arguments are converted from the engine’s canonical value type before the call and the return value must be JSON-serializable.

Helper context has the shared JSON and native time helpers documented above. Those functions are pure transformations of caller-supplied values.

Helpers cannot call atom(...). The atom host function is registered only when the engine runs in mapper mode, and even if it were available the implicit observation context would be missing and the call would error. This boundary is intentional: helpers are pure transformations of values; they have no awareness of the observation that triggered the rule that called them.

These are the helpers shipped with jacqos-price-watch, used end-to-end:

helpers/abs_pct_change.rhai
fn abs_pct_change(old_price, new_price) {
if old_price == 0.0 {
return 0.0;
}
let delta = if new_price >= old_price {
new_price - old_price
} else {
old_price - new_price
};
let pct = (delta / old_price) * 100.0;
round(pct * 100.0) / 100.0
}
helpers/default_source.rhai
fn default_price_source(product_id) {
if product_id == "prod-100" {
"vendor-a"
} else if product_id == "prod-200" {
"vendor-b"
} else if product_id == "prod-300" {
"vendor-c"
} else {
"vendor-a"
}
}

abs_pct_change uses round from the bundled math package; default_price_source uses only language-level conditionals. Neither calls a JacqOS host function — typical for helpers.

Every Rhai engine the shell builds — mapper, helper, and deterministic LLM provider — registers the same six packages from upstream Rhai. These functions are available alongside the JacqOS host functions in any context.

PackageProvides
CorePackageType coercion (to_int, to_float, to_string, to_bool), type_of, integer/float arithmetic and comparison.
LogicPackageBoolean operators and ordering across primitive types.
BasicArrayPackagepush, pop, len, contains, map, filter, reduce, indexing, iteration, join.
BasicMapPackageObject map literal #{ ... }, contains (key membership), keys, values, indexing.
BasicMathPackageabs, round, floor, ceiling, sqrt, pow, min, max, trig, PI, E.
MoreStringPackageto_lower, to_upper, trim, trim_start, trim_end, contains, index_of, replace, split, sub_string, len, is_empty, char and byte indexing.

Idiomatic patterns from the examples include:

// Iterate an array (BasicArrayPackage)
for dependency in body.depends_on {
atoms.push(atom("service.depends_on", dependency));
}
// Membership-test an object map key (BasicMapPackage)
if body.contains("production_system") {
if body.production_system == true {
atoms.push(atom("service.production_system", service_id));
}
}
// Type coercion (CorePackage)
let succeeded = to_string(body.succeeded);

Method-style and function-style calls are interchangeable in Rhai (s.replace(".", "") and replace(s, ".", "") resolve identically). The string and array packages expose both.

The Rhai sandbox is intentionally narrow. None of the following are exposed in mapper or helper context:

  • No I/O. No filesystem access, no network, no shell escape, no environment variable reads.
  • No LLM access. Mappers and helpers cannot invoke models. LLM calls happen through the llm.complete effect capability and surface back as observations.
  • No ambient clock or host timezone. No now(), no Date.now(), no system clock, and no host timezone database. Native time helpers parse explicit timestamps only. Use obs.timestamp or payload fields as event-time evidence.
  • No randomness. No rand(), no UUID generation. Determinism is required for replay.
  • No module imports. set_max_modules(0) is set on every engine; import and export are unavailable.
  • No dynamic code. The following Rhai symbols are explicitly disabled and emit a parse error if used: debug, eval, Fn, call, curry, import, export, is_def_fn, is_def_var, print.
  • No fact reads. Mappers cannot query derived facts. Mappers are the only stage allowed to construct atoms; the ontology decides what facts derive from those atoms.
  • No effect emission. Mappers cannot derive intents. Intents are the exclusive output of .dh derivation rules.
  • No atom(...) from helpers. As documented above, atom is mapper-only.
  • No mutation of obs. The observation passed to map_observation is read-only evidence.

For genuinely hard work that exceeds Rhai’s reach — complex binary parsing, performance-critical computation — the escape hatch is a pre-compiled Wasm helper, not an extension to the Rhai surface.

Mappers process raw external data and must be protected against malicious or oversized payloads. Every mapper invocation runs under the limits below.

LimitDefaultOverride
max_operations100,000[mapper_budgets.<name>] max_operations = N
max_string_size1 MB[mapper_budgets.<name>] max_string_size = "2MB" (suffix string or integer bytes)
max_array_size10,000[mapper_budgets.<name>] max_array_size = N
payload_limit10 MB[mapper_budgets.<name>] payload_limit = "20MB"
timeout5 s[mapper_budgets.<name>] timeout = "10s" (duration string or integer seconds)

The <name> is the mapper file’s stem — a budget under [mapper_budgets.inbound] applies to mappings/inbound.rhai.

Every shipped example overrides the defaults to the same uniform baseline:

[mapper_budgets.inbound]
max_operations = 150000
max_string_size = "2MB"
max_array_size = 12000
payload_limit = "12MB"
timeout = "10s"

Treat these numbers as the V1 baseline for production mappers. Lower them only after measurement.

LimitMechanismError surfaced
payload_limitChecked before Rhai is invoked.Mapper invocation is skipped with a payload-too-large error.
max_operationsCharged per Rhai bytecode step by the engine’s own counter.OperationLimitExceeded.
max_string_size / max_array_sizeEnforced both during execution and during JSON tree walks performed by parse_json / parse_json_opt and during atom value canonicalization.Runtime error from the offending operation.
timeoutEnforced via Rhai’s on_progress callback, which injects a sentinel string when wall-clock elapsed exceeds timeout. Fires only at bytecode-step boundaries, not inside a single host call.Timeout.

Helper budgets are not configurable in V1. Every Rhai helper compiles and runs under MapperResourceLimits::default() (the defaults from the table above). There is no [helper_budgets] section in jacqos.toml. Wasm helpers run under a separate hard-coded fuel budget.

Mappers can be split across files. Each file handles different observation kinds:

mappings/
inbound.rhai # Webhook and API observations
providers.rhai # Provider-specific parsing
[paths]
mappings = ["mappings/*.rhai"]

All mapper files load together. The shell dispatches each observation to every mapper; atoms from every mapper merge into one batch.

Two different mapper implementations that produce the same canonical export converge on the same mapper_output_digest. This means you can:

  • Refactor mapper code without changing semantics.
  • Have different teams maintain different mapper implementations against the same digest.
  • Verify that a refactor did not alter observable atom output.
Terminal window
$ jacqos verify
Checking mapper exports...
inbound.rhai digest: sha256:a1b2c3...
providers.rhai digest: sha256:d4e5f6...
Mapper output stable.
ChangeWhat happensSpeed
Mapper code changeRegenerate atoms from observation log, rerun derivation.Proportional to observation count.
Helper code changeNew evaluator digest, rebuild derived state.Sub-250ms for small corpora.
New observationMapper runs on new observation only, incremental evaluation.Milliseconds.

The shell reports which reload path it chose so you can tell whether you hit the fast path or triggered a full rebuild.

The following is the complete mapper from examples/jacqos-incident-response/mappings/inbound.rhai. It exercises every public mapper-context feature: the relay contract, JSON parsing, conditional key membership, array iteration, and atom(...) for both unconditional and conditional atoms.

fn mapper_contract() {
#{
requires_relay: [
#{
observation_class: "llm.remediation_decision_result",
predicate_prefixes: ["proposal."],
relay_namespace: "proposal",
}
],
}
}
fn map_observation(obs) {
let body = parse_json(obs.payload);
if obs.kind == "topology.update" {
let atoms = [];
let service_id = body.service_id;
atoms.push(atom("service.id", service_id));
if body.contains("depends_on") {
for dependency in body.depends_on {
atoms.push(atom("service.depends_on", dependency));
}
}
if body.contains("production_system") {
if body.production_system == true {
atoms.push(atom("service.production_system", service_id));
}
}
if body.contains("admin_access") {
if body.admin_access == true {
atoms.push(atom("service.admin_access", service_id));
}
}
if body.contains("is_primary_db") {
if body.is_primary_db == true {
atoms.push(atom("service.primary_db", service_id));
}
}
if body.contains("replica_synced") {
if body.replica_synced == true {
atoms.push(atom("service.replica_synced", service_id));
}
}
return atoms;
}
if obs.kind == "telemetry.alert" {
return [
atom("health.service", body.service_id),
atom("health.status", body.status),
atom("health.seq", body.seq),
];
}
if obs.kind == "llm.remediation_decision_result" {
return [
atom("proposal.id", body.decision_id),
atom("proposal.root_service", body.root_service),
atom("proposal.target_service", body.target_service),
atom("proposal.action", body.action),
atom("proposal.seq", body.seq),
];
}
if obs.kind == "effect.receipt" {
let atoms = [];
atoms.push(atom("effect.kind", body.kind));
atoms.push(atom("effect.root_service", body.root_service));
atoms.push(atom("effect.result", body.result));
if body.contains("target_service") {
atoms.push(atom("effect.target_service", body.target_service));
}
return atoms;
}
[]
}

The proposal.* atoms emitted from llm.remediation_decision_result flow into the relay namespace declared by mapper_contract(). The ontology then has to ratify them via an explicit decision relation before any intent.* rule can fire — the relay boundary the contract declares is what enforces that.