Host Integration Guide

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:

OptionTypeDescription
variablesRecord<string, RillValue>Initial variables accessible as $name
functionsRecord<string, RillFunction | RillFunctionSignature>Custom functions callable as name()
callbacksPartial<RuntimeCallbacks>I/O callbacks (e.g., onLog)
observabilityObservabilityCallbacksExecution monitoring hooks
timeoutnumberPer-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
autoExceptionsstring[]Regex patterns that halt execution
signalAbortSignalCancellation signal
maxCallDepthnumberMaximum 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.
maxCallStackDepthnumberMaximum stored call-stack trace frames (default 100); trims trace history, never halts execution — distinct from maxCallDepth above
requireDescriptionsbooleanRequire descriptions for all functions and parameters
resolversRecord<string, SchemeResolver> | undefinedScheme-to-resolver map for use<scheme:...> imports
configurations{ resolvers?: Record<string, unknown> } | undefinedPer-scheme config data passed to each resolver
filterResolverFilterResolver | undefinedAccess control and transforms for extension method calls; see Dispatch-Boundary Policy
checkerMode'strict' | 'permissive' | undefinedType 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 RillValue types (string, number, boolean, array, object, or RillCallable)
  • 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 ?? -1

Value 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  # true

generate() 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>;
ParameterDescription
argsNamed arguments keyed by parameter name (already validated against params)
ctxRuntime context with variables, pipeValue, and session metadata
locationSource 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:

KeyTypeDescription
correlationIdstringUnique per-session UUID for distributed tracing
sessionIdstringSession record ID
agentNamestringName of the running agent
timeoutDeadlinestringAbsolute 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

KeyMeaning
"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:

  1. Callable carries no extension identity — pass through. Script closures, built-ins, and functions registered through functions are not extension methods and are never policed.
  2. Extension has no rules and no "*" — pass through.
  3. Extension is policed but the callable cannot be named as a method (the extension root is itself a callable) — deny.
  4. Exact rule for the method — use it.
  5. The extension’s "*" rule — use it.
  6. 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" ! $r

Reach

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 functions keep no extension identity.
  • A stream is skipped whole at call time, and the value an 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 as next, 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 valueAccepts
{ 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 undefinedAny 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 fill

The same hydration applies to dict and ordered parameters: missing named fields with declared defaults are filled before invocation.

Parameter typeMissing field conditionResult
tupleTrailing elements with defaultValueFilled with deep copy of default
tupleTrailing elements without defaultValueLeft absent; type check at Stage 3 catches
dictNamed fields with defaultValueFilled with deep copy of default
orderedNamed fields with defaultValueFilled 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 defaultValue is 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;
ParameterDescription
fnThe function to wrap
isPropertyIf 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

KindTypeDescription
scriptScriptCallableClosures from rill source code
runtimeRuntimeCallablerill’s built-in functions
applicationApplicationCallableHost-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:

  1. The scoped controller aborts.
  2. ctx.signal becomes aborted for the duration of that body.
  3. Host functions that observe ctx.signal halt naturally.
  4. Host functions that do not observe ctx.signal complete 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

DocumentDescription
Host API ReferenceComplete TypeScript API reference and exports
ExtensionsReusable function packages
ModulesModule convention
ReferenceLanguage specification