Host Integration Guide
Quick Start
import { parse, execute, createRuntimeContext } from '@rcrsr/rill';
const source = `
"Hello, World!" -> prompt() => $response
$response
`;
const ast = parse(source);
const ctx = createRuntimeContext({
functions: {
prompt: {
params: [{ name: 'text', type: { kind: 'string' } }],
fn: async (args) => {
return await callYourLLM(args.text);
},
},
},
});
const result = await execute(ast, ctx);
console.log(result.result);RuntimeOptions
The createRuntimeContext() function accepts these options:
| Option | Type | Description |
|---|---|---|
variables | Record<string, RillValue> | Initial variables accessible as $name |
functions | Record<string, RillFunction | RillFunctionSignature> | Custom functions callable as name() |
callbacks | Partial<RuntimeCallbacks> | I/O callbacks (e.g., onLog) |
observability | ObservabilityCallbacks | Execution monitoring hooks |
timeout | number | Per-call limit in ms for each named function call (host functions and built-ins). Expiry halts with catchable RILL-R012. It does not bound total script time |
autoExceptions | string[] | Regex patterns that halt execution |
signal | AbortSignal | Cancellation signal |
maxCallDepth | number | Maximum nested closure-call depth per call chain before a fatal RILL-R010 halt (default 1000). Also sets the cap on calls in flight across concurrent fan/filter/sort bodies: maxCallDepth + 10,000. |
maxCallStackDepth | number | Maximum stored call-stack trace frames (default 100); trims trace history, never halts execution — distinct from maxCallDepth above |
requireDescriptions | boolean | Require descriptions for all functions and parameters |
resolvers | Record<string, SchemeResolver> | undefined | Scheme-to-resolver map for use<scheme:...> imports |
configurations | { resolvers?: Record<string, unknown> } | undefined | Per-scheme config data passed to each resolver |
filterResolver | FilterResolver | undefined | Access control and transforms for extension method calls; see Dispatch-Boundary Policy |
checkerMode | 'strict' | 'permissive' | undefined | Type checker mode; default 'permissive' |
Host Function Contract
Host functions must follow these rules to ensure correct script behavior:
Immutability
Host functions must not mutate input arguments. rill values are immutable by design—modifying arguments breaks value semantics and causes unpredictable behavior.
// WRONG: Mutates input array
functions: {
addItem: {
params: [{ name: 'list', type: { kind: 'list' } }],
fn: (args) => {
const list = args.list as unknown[];
list.push('new'); // DON'T DO THIS
return list;
},
},
}
// CORRECT: Return new value
functions: {
addItem: {
params: [{ name: 'list', type: { kind: 'list' } }],
fn: (args) => {
const list = args.list as unknown[];
return [...list, 'new']; // Create new array
},
},
}Defensive Copies
For maximum safety, consider freezing values passed to host functions:
import { deepFreeze } from './utils'; // Your utility
functions: {
process: {
params: [{ name: 'input', type: { kind: 'string' } }],
fn: (args) => {
const frozen = deepFreeze(args.input);
return transform(frozen); // Any mutation throws
},
},
}Return Values
- Return new values instead of modifying inputs
- Return
RillValuetypes (string, number, boolean, array, object, orRillCallable) - Use empty string
''or empty array[]instead of returning nothing
Returning undefined, null, a bigint, a Symbol, a Date, a Map, a Set, a raw function, or any other non-plain class instance halts execution at the call boundary with RILL-R085, fatal and not recoverable via guard or ??. The same holds when one of these values is nested inside a returned array or plain object—the walk descends into every element and field. The error message names the host function and the offending path and JavaScript type, for example Host function 'fetchUser' returned an invalid value at .createdAt: Date.
Returning a non-finite number (NaN, Infinity, -Infinity)—including one nested inside a returned array or plain object—halts at the call boundary with a catchable #INVALID_INPUT halt, recoverable via guard and ??, unlike the shape violations above, which are fatal RILL-R085. The message names the host function, the offending path, and the value, for example Host function 'fetchRatio' returned a non-finite number at <root>: NaN.
# Error: #INVALID_INPUT — fetchRatio returned NaN
guard { fetchRatio() } => $result
$result ?? -1Value Types
Internal container types host code may encounter in observability callbacks or return values. See Value Types for RillOrdered, RillTuple, RillTypeValue, and closure introspection (.^input, .^output).
Value Conversion
toNative() converts a RillValue to a structured result suitable for host consumption. See Value Conversion for the conversion table, descriptor shapes, and NativeResult migration guide.
Custom Functions
Functions are called by name: functionName(arg1, arg2).
const ctx = createRuntimeContext({
functions: {
// Sync function
add: {
params: [
{ name: 'a', type: { kind: 'number' } },
{ name: 'b', type: { kind: 'number' } },
],
fn: (args) => args.a + args.b,
},
// Async function
fetch: {
params: [{ name: 'url', type: { kind: 'string' } }],
fn: async (args, ctx, location) => {
const response = await fetch(args.url);
return await response.text();
},
},
// Function with context access
getVar: {
params: [{ name: 'name', type: { kind: 'string' } }],
fn: (args, ctx) => {
return ctx.variables.get(args.name) ?? null;
},
},
// Function with location for error reporting
validate: {
params: [{ name: 'value', type: { kind: 'string' } }],
fn: (args, ctx, location) => {
if (!args.value) {
throw new Error(`Validation failed at line ${location?.line}`);
}
return args.value;
},
},
},
});Namespaced Functions
Use :: to organize functions into namespaces:
const ctx = createRuntimeContext({
functions: {
// Namespaced functions use :: separator
'math::add': {
params: [
{ name: 'a', type: { kind: 'number' } },
{ name: 'b', type: { kind: 'number' } },
],
fn: (args) => args.a + args.b,
},
'math::multiply': {
params: [
{ name: 'a', type: { kind: 'number' } },
{ name: 'b', type: { kind: 'number' } },
],
fn: (args) => args.a * args.b,
},
'str::upper': {
params: [{ name: 'text', type: { kind: 'string' } }],
fn: (args) => args.text.toUpperCase(),
},
'str::lower': {
params: [{ name: 'text', type: { kind: 'string' } }],
fn: (args) => args.text.toLowerCase(),
},
// Multi-level namespaces
'io::file::read': {
params: [{ name: 'path', type: { kind: 'string' } }],
fn: async (args) => fs.readFile(args.path, 'utf-8'),
},
'io::file::write': {
params: [
{ name: 'path', type: { kind: 'string' } },
{ name: 'content', type: { kind: 'string' } },
],
fn: async (args) => fs.writeFile(args.path, args.content),
},
},
});Scripts call namespaced functions with the same syntax:
math::add(1, 2) # 3
"hello" -> str::upper # "HELLO"
io::file::read("config.json")Namespaces help organize host APIs and avoid name collisions without requiring the $ variable prefix.
The ext::fn() calling pattern is retained for compatibility but is not recommended for new code. In strict checker mode, ext::fn() calls are flagged. Use use<ext:...> imports instead (see Host Resolver Registration).
Structured Output with generate()
LLM extensions expose generate(prompt, options) for schema-constrained structured output. The provider enforces the schema at the API level and returns a consistent dict.
use<ext:llm> => $llm
[name: "string", age: "number", active: "bool"] => $schema
$llm.generate("Extract user info from the following text: Alice, 30, active.", [
schema: $schema
]) => $result
$result.data.name # "Alice"
$result.data.age # 30
$result.data.active # truegenerate() returns data (the parsed dict) instead of content (free text). Use $result.raw to access the original JSON string.
Host Resolver Registration
Register resolvers to handle use<scheme:resource> import statements in scripts. The resolvers and configurations fields in RuntimeOptions control this. See Host Resolver Registration for setup instructions, built-in resolvers (moduleResolver, extResolver), and custom resolver examples.
CallableFn Signature
The fn property in RillFunction uses the CallableFn type:
type CallableFn = (
args: Record<string, RillValue>,
ctx: RuntimeContextLike,
location?: SourceLocation
) => RillValue | Promise<RillValue>;| Parameter | Description |
|---|---|
args | Named arguments keyed by parameter name (already validated against params) |
ctx | Runtime context with variables, pipeValue, and session metadata |
location | Source location of the call site (for error reporting) |
Untyped callables created via callable() (no params) still receive RillValue[].
Session Metadata in Host Functions
Host functions receive session metadata via ctx.metadata: Record<string, string> | undefined.
When running under rill-agent-harness, the runtime populates these keys:
| Key | Type | Description |
|---|---|---|
correlationId | string | Unique per-session UUID for distributed tracing |
sessionId | string | Session record ID |
agentName | string | Name of the running agent |
timeoutDeadline | string | Absolute deadline as Unix timestamp in ms |
Read metadata in a host function:
functions: {
trace: {
params: [{ name: 'msg', type: { kind: 'string' } }],
fn: (args, ctx) => {
const correlationId = ctx.metadata?.correlationId ?? 'unknown';
console.log(`[${correlationId}] ${args.msg}`);
return args.msg;
},
},
}ctx.metadata is undefined when the script runs outside the agent harness (e.g., direct execute() calls).
Dispatch-Boundary Policy
A script that resolves an extension through use<scheme:resource> can call every method on it. filterResolver lets the host decide, per method, whether the call is permitted and what happens to its input and output.
The filter runs at the single site every call syntax funnels through, so $kb.search(), kb::search(), a bare search(), and a method reached through a rebound variable all hit the same rule.
Configuring a Policy
Write the policy as data, resolve it once, and hand the resolver to the context:
import {
createRuntimeContext,
createConfigFilterResolver,
resolvePolicy,
extResolver,
} from '@rcrsr/rill';
const extensions = { kb, filter };
const policy = resolvePolicy(
{
kb: {
search: { access: 'allow', out: ['filter.redact'] },
delete: { access: 'deny' },
'*': { access: 'deny' },
},
},
new Map(Object.entries(extensions))
);
const ctx = createRuntimeContext({
filterResolver: createConfigFilterResolver(policy),
resolvers: { ext: extResolver },
configurations: { resolvers: { ext: extensions } },
});resolvePolicy turns the "extension.method" strings in in/out into the callables they name, once, at setup. A reference that names no mounted callable halts there with RILL-R087 rather than on the first call that would have used it. A "*" rule carrying in or out halts with RILL-R086: one transform signature cannot fit every method it would wrap.
Rule Matching
| Key | Meaning |
|---|---|
"search" | Exact method name |
"client.search" | A method on a nested sub-client |
"clients[0].purge" | A method reached through a list or tuple index |
"client()" | A callable returned by calling client |
"client().search" | A member of a value returned by calling client |
"client()[0]" | A list or tuple element of a value returned by calling client |
"()" | A callable returned by an extension whose root is itself a callable |
"*" | Default for methods on this extension with no exact rule |
An index in a key is the element’s zero-based position in its list or tuple, counting every element. For an extension value dict[clients: list[1, 2, dict[purge: <callable>]]], the key is "clients[2].purge", matching the [2] in $kb.clients[2].purge(). Keys built from a call result follow the same rule: the third element of the value client returns is "client()[2]".
Matching order, per call:
- Callable carries no extension identity — pass through. Script closures, built-ins, and functions registered through
functionsare not extension methods and are never policed. - Extension has no rules and no
"*"— pass through. - Extension is policed but the callable cannot be named as a method (the extension root is itself a callable) — deny.
- Exact rule for the method — use it.
- The extension’s
"*"rule — use it. - Otherwise pass through.
Step 6 is why a policy must declare "*" to fail closed. Listing delete as denied and nothing else leaves every other method on that extension allowed, including methods added to the extension later. Write the default first:
kb: {
'*': { access: 'deny' },
search: { access: 'allow' },
}Rules key on where a callable was resolved from, not on the path the script writes. Renaming the capture variable, rebinding a method to a second variable, or reaching it through a different call syntax does not move it out from under its rule.
Transforms
in rewrites the method’s first argument before it executes; out rewrites the return value after. Both are lists, applied in order, each transform receiving the previous one’s output.
Declare the parameter that needs sanitizing first. in never sees later arguments, so a sensitive value in the second position reaches the method untransformed.
in is not restricted to piped calls. $kb.search("q") is sanitized the same as "q" -> $kb.search(), because keying on the pipe would make dropping it a one-edit bypass. A zero-argument call has no first argument and is left alone; synthesizing one would change the call’s arity.
Transforms are dispatched internally, so a transform is not itself filtered on the way in. A transform that re-enters itself, directly or through a policed method whose own chain reaches back, halts with RILL-R089.
What a Denied Call Does
A denied call halts with RILL-R088, which is catchable:
guard { $kb.delete() } => $r
$r.! ? "not permitted" ! $rReach
The resolver is held outside the RuntimeContext. Host and extension functions are handed the context, so a resolver reachable there would be readable and writable by the code it governs.
Every callable reachable from a resolved use<> value is policed, including those nested in dicts, lists, and tuples. An extension whose value exceeds 10,000 members halts at resolution with RILL-R090 rather than leaving the remainder unbranded and therefore unpoliced.
When the resolver polices the calling extension, values returned from a branded call inherit that extension, including the output of out transforms. A method open that returns dict[purge: <callable>] therefore yields a purge addressable as "open().purge", and it is denied by "*" unless a rule allows it. Not every callable is branded:
- Script closures, built-ins, and callables registered through
functionskeep no extension identity. - A stream is skipped whole at call time, and the
valuean iterator yields is skipped, because those elements do not exist until consumed. A callable consumed from a stream is therefore not policed. An iterator’s own callable members, such asnext, are branded.
A callable that already carries a use<> identity keeps it, and the first call-time identity wins over later ones. The call-time walk has no member budget, so its cost is paid on every call to a branded callable whose extension the resolver polices. The walk is skipped when policesExtension(extension) returns false, and a resolver without that method keeps walking. The hint affects only call-time branding; use<> branding and the RILL-R090 budget are unchanged.
Derived brands are process-global and first-wins. A singleton sub-client returned by two extensions keeps the brand of whichever call ran first, so give each extension its own instance.
Because derived brands are process-global, two contexts that share extension instances but use different resolvers can disagree: a callable minted in the non-policing context stays unbranded when a host hands it to the policing one.
Writing Your Own Resolver
createConfigFilterResolver is one implementation. FilterResolver is the interface, and it receives the callable itself, so a resolver can key on callable.annotations or anything else it carries:
interface FilterResolver {
(
callable: RillCallable,
resolvedPath: string | undefined,
ctx: RuntimeContext
): Filter | null;
readonly policesExtension?: (extension: string) => boolean;
}policesExtension is an optional hint that lets the runtime skip call-time branding for extensions the resolver never polices. Its extension argument is the first resource segment, the same value as ExtensionIdentity.extension. createConfigFilterResolver returns true for an extension with rules or a "*" default and false otherwise. Returning false means callables returned from calls into that extension carry no identity, so the resolver cannot police them later; return false only when the resolver will never police them. The answer must not change from false to true during the life of a context. It runs on every branded call, so keep it a map lookup. Under the config resolver, getExtensionIdentity() is undefined for callables returned by unpoliced extensions.
Return null to pass the call through unfiltered. resolvedPath is the script-facing call path and is suitable for diagnostics only: the script author picks the variable name it is built from. Use getExtensionIdentity(callable) for an authorization key. Resolvers run on every dispatch, so keep them to map lookups on pre-resolved data.
Host Function Type Declarations
All host functions must declare parameter types and optional defaults using the RillFunction interface. The runtime validates arguments before calling your function, eliminating manual type checking.
Parameter Type Declarations
Declare parameter types in the params array:
const ctx = createRuntimeContext({
functions: {
repeat: {
params: [
{ name: 'str', type: { kind: 'string' } },
{ name: 'count', type: { kind: 'number' }, defaultValue: 1 },
],
fn: (args) => {
// args.str guaranteed to be string
// args.count guaranteed to be number (or default)
return args.str.repeat(args.count);
},
},
},
});Scripts call typed functions the same way:
repeat("hello", 3) # "hellohellohello"
repeat("hi") # "hi" (uses default count)Supported Types
The type field on RillParam is a TypeStructure object — a structural type descriptor. Set it to undefined to accept any type without validation.
Common leaf types:
TypeStructure value | Accepts |
|---|---|
{ kind: 'string' } | String parameter |
{ kind: 'number' } | Number parameter |
{ kind: 'bool' } | Boolean parameter |
{ kind: 'list' } | Any list |
{ kind: 'list', element: { kind: 'string' } } | List of strings |
{ kind: 'dict' } | Any dict |
{ kind: 'any' } or undefined | Any type (no validation) |
See Type System for the full TypeStructure discriminated union, including closure, tuple, ordered, and union variants.
Default Values
Parameters with default values are optional. The default applies when the argument is missing:
functions: {
greet: {
params: [
{ name: 'name', type: { kind: 'string' } },
{ name: 'greeting', type: { kind: 'string' }, defaultValue: 'Hello' },
],
fn: (args) => `${args.greeting}, ${args.name}!`,
},
}greet("Alice") # "Hello, Alice!"
greet("Bob", "Hi") # "Hi, Bob!"Field-Level Default Hydration
When a parameter type is dict, ordered, or tuple, the runtime fills missing fields with declared defaults before calling the host function. This happens at Stage 2.5 of marshalArgs, after top-level parameter defaults are applied and before type checking.
For tuple parameters, missing trailing elements are filled from the element type’s defaultValue:
functions: {
format: {
params: [
{
name: 'point',
type: {
kind: 'tuple',
elements: [
{ type: { kind: 'number' } },
{ type: { kind: 'string' }, defaultValue: 'unnamed' },
],
},
},
],
fn: (args) => {
// args.point is always a tuple with 2 elements
// If caller passed (42,), runtime fills element 1 with "unnamed"
const point = args.point as { __rill_tuple: true; entries: unknown[] };
return `${point.entries[1]}: ${point.entries[0]}`;
},
},
},format((42,)) # "unnamed: 42" -- trailing default filled
format((42, "home")) # "home: 42" -- both elements present, no fillThe same hydration applies to dict and ordered parameters: missing named fields with declared defaults are filled before invocation.
| Parameter type | Missing field condition | Result |
|---|---|---|
tuple | Trailing elements with defaultValue | Filled with deep copy of default |
tuple | Trailing elements without defaultValue | Left absent; type check at Stage 3 catches |
dict | Named fields with defaultValue | Filled with deep copy of default |
ordered | Named fields with defaultValue | Filled with deep copy of default |
Hydration is recursive: if a filled default itself has a structured type with defaults, those are also hydrated.
Two additional behaviors apply during recursive hydration:
- Nested synthesis — A missing named field with no explicit default is synthesized as an empty collection when all its children declare
defaultValue. The runtime seeds the empty collection and fills each child. - Explicit default hydration — When a field’s declared
defaultValueis itself a collection, the runtime hydrates it through the field’s nested type. Child defaults fill any fields the explicit default omits.
If any required child field lacks a defaultValue, the type check at Stage 3 catches the missing field and throws RUNTIME_TYPE_ERROR.
Type Mismatch Errors
When argument types don’t match, the runtime throws RuntimeError with code RUNTIME_TYPE_ERROR:
// Script: repeat(42, 3)
// Error: Function 'repeat' expects parameter 'str' (position 0) to be string, got number
Error details include:
- Function name
- Parameter name
- Parameter position
- Expected type
- Actual type received
RillFunction Interface
interface RillFunction {
readonly params: readonly RillParam[];
readonly fn: CallableFn;
readonly description?: string; // Human-readable function description
readonly returnType?: TypeStructure; // undefined = any return type
}
interface RillParam {
readonly name: string;
readonly type: TypeStructure | undefined; // undefined = any type
readonly defaultValue: RillValue | undefined; // undefined = required
readonly annotations: Record<string, RillValue>; // {} when no annotations
}Documentation Validation
Enable requireDescriptions to enforce documentation at registration time:
const ctx = createRuntimeContext({
requireDescriptions: true,
functions: {
greet: {
params: [
{ name: 'name', type: { kind: 'string' }, annotations: { description: 'Person to greet' } },
],
description: 'Generate a greeting message',
returnType: { kind: 'string' },
fn: (args) => `Hello, ${args.name}!`,
},
},
});Missing descriptions throw clear errors:
- Function:
Function 'name' requires description (requireDescriptions enabled) - Parameter:
Parameter 'x' of function 'name' requires description (requireDescriptions enabled)
Signature String Registration
Functions can be registered using a rill closure signature string instead of explicit RillParam[]. The runtime parses the signature at registration time and derives the parameter list from it.
const ctx = createRuntimeContext({
functions: {
greet: {
signature: '|name: string| :string',
fn: (args) => `Hello, ${args.name}!`,
},
},
});The runtime discriminates RillFunctionSignature from RillFunction by the presence of the signature field.
Signature strings support the full rill closure annotation syntax, including parameter annotations and defaults:
functions: {
repeat: {
signature: '|^("Times to repeat") count: number = 3, text: string| :string',
fn: (args) => String(args.text).repeat(args.count),
},
}The signature value must be a valid rill closure type annotation string. Invalid signatures throw at registration time.
interface RillFunctionSignature {
readonly signature: string; // annotated rill closure type signature
readonly fn: CallableFn;
}Manifest Generation
generateManifest(ctx) returns a valid rill file string: a dict literal of closure type signatures for all registered functions. The dict is the last expression and becomes the script’s result value.
import { generateManifest } from '@rcrsr/rill';
const manifest = generateManifest(ctx);
// Write to host.rill for static analysis or LLM context
Example output for a context with greet and repeat functions:
[
"greet": |name: string|:string,
"repeat": |count: number = 3, text: string|:string
]An empty function map produces:
[:]The manifest file format is valid rill. Host tools can pass it to static analysis tools, include it in LLM system prompts for code generation context, or serve it to IDE tooling for autocomplete.
Application Callables
Hosts can create first-class callable values that scripts can store, pass, and invoke.
import { callable, isCallable, isApplicationCallable } from '@rcrsr/rill';
// Create a callable
// Untyped callable: params is undefined, args is RillValue[] (no marshaling)
const greet = callable((args) => `Hello, ${args[0]}!`);
// Use in variables
const ctx = createRuntimeContext({
variables: {
greet: greet,
},
});
// Script can invoke: $greet("World") -> "Hello, World!"
callable() Function
function callable(fn: CallableFn, isProperty?: boolean): ApplicationCallable;| Parameter | Description |
|---|---|
fn | The function to wrap |
isProperty | If true, auto-invokes when accessed from dict |
Property-Style Callables
Property-style callables auto-invoke when accessed from a dict, enabling computed properties:
const ctx = createRuntimeContext({
variables: {
user: {
firstName: 'John',
lastName: 'Doe',
// Auto-invokes on access, receives bound dict
fullName: callable((args) => {
// Property-style callable: args[0] is the bound dict this callable was accessed from, not a positional argument
const dict = args[0] as Record<string, RillValue>;
return `${dict.firstName} ${dict.lastName}`;
}, true),
},
},
});
// Script: $user.fullName -> "John Doe"
Dict Callables
Callables stored in dicts can be invoked using method syntax:
const ctx = createRuntimeContext({
variables: {
math: {
add: callable((args) => {
// Untyped callable: params is undefined, args is RillValue[] (no marshaling)
const a = typeof args[0] === 'number' ? args[0] : 0;
const b = typeof args[1] === 'number' ? args[1] : 0;
return a + b;
}),
},
},
});
// Script: $math.add(1, 2) -> 3
Callable Kinds
| Kind | Type | Description |
|---|---|---|
script | ScriptCallable | Closures from rill source code |
runtime | RuntimeCallable | rill’s built-in functions |
application | ApplicationCallable | Host-provided callables |
Type Guards
import {
isCallable,
isScriptCallable,
isRuntimeCallable,
isApplicationCallable,
} from '@rcrsr/rill';
if (isCallable(value)) {
// value is RillCallable (any callable)
}
if (isApplicationCallable(value)) {
// value is ApplicationCallable (host-provided)
}
if (isScriptCallable(value)) {
// value is ScriptCallable (from rill source)
}Cancellation
Use AbortSignal to cancel long-running scripts:
import { RuntimeHaltSignal } from '@rcrsr/rill';
const controller = new AbortController();
const ctx = createRuntimeContext({
signal: controller.signal,
functions: {
longTask: {
params: [],
fn: async () => {
await new Promise((r) => setTimeout(r, 10000));
return 'done';
},
},
},
});
// Cancel after 1 second
setTimeout(() => controller.abort(), 1000);
try {
await execute(ast, ctx);
} catch (err) {
if (err instanceof RuntimeHaltSignal) {
console.log('Execution cancelled');
}
}RuntimeHaltSignal
Abort does not raise a catchable RuntimeError. It throws a non-catchable RuntimeHaltSignal (catchable: false) that propagates past guard and retry straight to the host. The signal carries an invalid RillValue tagged with the #DISPOSED atom, not an errorId or code — there is no HALT_ATOM_TO_ERROR_ID mapping for #DISPOSED, so the raw signal is what reaches the catch block.
import { RuntimeHaltSignal } from '@rcrsr/rill';
try {
await execute(ast, ctx);
} catch (err) {
if (err instanceof RuntimeHaltSignal) {
console.log('Execution cancelled');
}
}The simplest host-side discriminator is the controller itself, since the signal carries no error code to branch on:
if (controller.signal.aborted) {
console.log('Execution cancelled');
}Abort is checked at:
- Before each statement
- Before each function call
- Before each loop iteration
- In the stepper’s
step()method
A signal only changes when the code that aborts it runs: host timers, AbortSignal.timeout, I/O callbacks, and timeout<> timers all need the host event loop. During long evaluation the runtime periodically yields to the host event loop so those fire. Cancellation is noticed within roughly the yield interval plus the current step. The interval is not configurable. As a side effect, other host timers and I/O callbacks run between evaluation steps, and pass<async: true> bodies and host streams progress.
One synchronous built-in operation, such as a regex match or building a very large string or list, runs to completion and cannot be interrupted. To hard-bound those, run rill in a worker thread or child process the host can terminate.
Cancellation Contract for Host Functions
Host functions receive a ctx.signal (AbortSignal | undefined). Cooperative cancellation requires that each host function observes this signal and stops work when it fires.
functions: {
fetchData: {
params: [{ name: 'url', type: { kind: 'string' } }],
fn: async (args, ctx) => {
const response = await fetch(args.url as string, {
signal: ctx.signal, // propagate abort to the fetch
});
return await response.text();
},
},
}Required contract: Host functions must check ctx.signal.aborted or pass ctx.signal to any awaitable they call. Functions that ignore the signal will run to completion even after the runtime aborts, because rill uses cooperative (not forced) termination.
timeout<total:> and timeout<idle:> blocks in scripts create a scoped AbortController and chain its signal to ctx.signal via AbortSignal.any. When a timeout fires:
- The scoped controller aborts.
ctx.signalbecomes aborted for the duration of that body.- Host functions that observe
ctx.signalhalt naturally. - Host functions that do not observe
ctx.signalcomplete normally.
The timer also fires during CPU-bound script bodies, within roughly the yield interval plus the current step, except while one synchronous built-in operation runs.
The host-supplied signal from RuntimeOptions and the runtime’s internal scoped signals both chain together. Aborting the host controller aborts the entire execution; a scoped timeout abort affects only the body of that timeout block.
Virtual Clock Injection (nowMs)
The RuntimeOptions.nowMs field supplies a fixed millisecond timestamp for deterministic Date.now() behavior inside scripts and built-in datetime functions:
const ctx = createRuntimeContext({
nowMs: 1704067200000, // 2024-01-01T00:00:00.000Z
});When nowMs is set, calls to now() inside the script return a datetime constructed from that fixed timestamp instead of reading the system clock. Set nowMs in tests to produce deterministic now() output; the idle ticker’s setTimeout scheduling uses the real system clock regardless of nowMs.
Observability
Monitor execution with observability callbacks:
const ctx = createRuntimeContext({
observability: {
onStepStart: (event) => {
console.log(`Step ${event.index + 1}/${event.total}`);
},
onStepEnd: (event) => {
console.log(`Completed in ${event.durationMs}ms`);
},
onHostCall: (event) => {
console.log(`Calling ${event.name}(${event.args.join(', ')})`);
},
onFunctionReturn: (event) => {
console.log(`${event.name} returned: ${event.value}`);
},
onCapture: (event) => {
console.log(`Captured $${event.name} = ${event.value}`);
},
onError: (event) => {
console.error(`Error at step ${event.index}:`, event.error);
},
},
});Event Types
interface StepStartEvent {
index: number; // Statement index (0-based)
total: number; // Total statements
pipeValue: RillValue;
}
interface StepEndEvent {
index: number;
total: number;
value: RillValue;
durationMs: number;
}
interface HostCallEvent {
name: string;
args: RillValue[];
}
interface FunctionReturnEvent {
name: string;
value: RillValue;
durationMs: number;
}
interface CaptureEvent {
name: string;
value: RillValue;
}
interface ErrorEvent {
error: Error;
index?: number;
}Step-by-Step Execution
Use the stepper API for controlled execution:
import { parse, createRuntimeContext, createStepper } from '@rcrsr/rill';
const ast = parse(source);
const ctx = createRuntimeContext({ ... });
const stepper = createStepper(ast, ctx);
while (!stepper.done) {
const result = await stepper.step();
console.log(`Step ${result.index + 1}: ${result.value}`);
if (result.captured) {
console.log(`Captured: $${result.captured.name}`);
}
}
const final = stepper.getResult();
console.log('Final value:', final.result);ExecutionStepper Interface
interface ExecutionStepper {
readonly done: boolean;
readonly index: number;
readonly total: number;
readonly context: RuntimeContext;
step(): Promise<StepResult>;
getResult(): ExecutionResult;
}
interface StepResult {
value: RillValue;
done: boolean;
index: number;
total: number;
captured?: { name: string; value: RillValue };
}
interface ExecutionResult {
result: RillValue;
}Introspection
Discover available functions, access language documentation, and check runtime version. See Introspection for getFunctions(), getLanguageReference(), VERSION/VERSION_INFO, and getDocumentationCoverage().
I/O Callbacks
Handle script I/O through callbacks:
const ctx = createRuntimeContext({
callbacks: {
onLog: (value) => {
// Called when script uses .log method
console.log('[rill]', value);
},
},
});Timeouts
The timeout option is a per-call limit for each named function call, host functions and built-ins alike. Expiry halts with catchable RILL-R012. It does not bound total script time.
const ctx = createRuntimeContext({
timeout: 30000, // 30 seconds per call
functions: {
slowOperation: {
params: [],
fn: async () => {
// Halts with catchable RILL-R012 if this call exceeds 30s
await longRunningTask();
return 'done';
},
},
},
});For a whole-run deadline, pass a signal:
const ctx = createRuntimeContext({ signal: AbortSignal.timeout(60000) });
await execute(ast, ctx); // throws RuntimeHaltSignal at the next cancellation check after roughly 60 s
Auto-Exceptions
Halt execution when output matches patterns:
const ctx = createRuntimeContext({
autoExceptions: [
'error:.*', // Matches "error: something"
'FATAL', // Matches "FATAL" anywhere
],
functions: {
process: {
params: [{ name: 'input', type: { kind: 'string' } }],
fn: (args) => {
// If this returns "error: invalid input",
// execution halts with a non-catchable RuntimeHaltSignal (atom #R999)
return externalProcess(args.input);
},
},
},
});Initial Variables
Provide variables accessible in scripts:
const ctx = createRuntimeContext({
variables: {
config: {
apiUrl: 'https://api.example.com',
maxRetries: 3,
},
userId: 'user-123',
items: [1, 2, 3],
},
});
// Script can access: $config.apiUrl, $userId, $items
See Extension Backend Selection for backend selection strategy and configuration examples.
Error Handling
All rill errors extend RillError with structured information:
import { RuntimeError, ParseError, RuntimeHaltSignal } from '@rcrsr/rill';
try {
const ast = parse(source);
const result = await execute(ast, ctx);
} catch (err) {
if (err instanceof ParseError) {
console.log('Parse error:', err.message);
console.log('Location:', err.location);
} else if (err instanceof RuntimeError) {
console.log('Runtime error:', err.errorId);
console.log('Message:', err.message);
console.log('Location:', err.location);
console.log('Context:', err.context);
} else if (err instanceof RuntimeHaltSignal) {
// Host abort or auto-exception. A per-call timeout halts with catchable RILL-R012 instead.
console.log('Execution cancelled or auto-exception triggered');
}
}Error Codes
ParseError and RuntimeError carry an errorId from four ranges — RILL-L001–RILL-L005 (lexer), RILL-P001–RILL-P022 (parse), RILL-R001–RILL-R083 (runtime), RILL-C001–RILL-C004 (checker). Abort and auto-exception surface as a non-catchable RuntimeHaltSignal (atoms #DISPOSED and #R999) with no errorId. An unhandled error statement also halts as RuntimeHaltSignal internally, but the runtime converts it to a coded RuntimeError (errorId: RILL-R016) before it reaches the host. Runtime error sites migrated to the halt builders throw a fatal RuntimeHaltSignal (catchable: false) with the code on errorId, instead of a RuntimeError, when called directly outside execute() and createStepper(). Argument-binding errors from invokeCallable (RILL-R001, RILL-R044, RILL-R045) still throw RuntimeError, so hosts calling it directly handle both. This document does not maintain a per-code description table; see Error Reference for the complete, generated list of codes with causes and resolutions.
See Also
| Document | Description |
|---|---|
| Host API Reference | Complete TypeScript API reference and exports |
| Extensions | Reusable function packages |
| Modules | Module convention |
| Reference | Language specification |