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: 'string' }],
      fn: async (args) => {
        return await callYourLLM(args[0]);
      },
    },
  },
});

const result = await execute(ast, ctx);
console.log(result.value);

RuntimeOptions

The createRuntimeContext() function accepts these options:

OptionTypeDescription
variablesRecord<string, RillValue>Initial variables accessible as $name
functionsRecord<string, HostFunctionDefinition>Custom functions callable as name()
callbacksPartial<RuntimeCallbacks>I/O callbacks (e.g., onLog)
observabilityObservabilityCallbacksExecution monitoring hooks
timeoutnumberTimeout in ms for async functions
autoExceptionsstring[]Regex patterns that halt execution
signalAbortSignalCancellation signal
requireDescriptionsbooleanRequire descriptions for all functions and parameters

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: 'list' }],
    fn: (args) => {
      const list = args[0] as unknown[];
      list.push('new');  // DON'T DO THIS
      return list;
    },
  },
}

// CORRECT: Return new value
functions: {
  addItem: {
    params: [{ name: 'list', type: 'list' }],
    fn: (args) => {
      const list = args[0] 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: 'string' }],
    fn: (args) => {
      const frozen = deepFreeze(args[0]);
      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)
  • Avoid returning null or undefined—use empty string '' or empty array [] instead

Custom Functions

Functions are called by name: functionName(arg1, arg2).

const ctx = createRuntimeContext({
  functions: {
    // Sync function
    add: {
      params: [
        { name: 'a', type: 'number' },
        { name: 'b', type: 'number' },
      ],
      fn: (args) => args[0] + args[1],
    },

    // Async function
    fetch: {
      params: [{ name: 'url', type: 'string' }],
      fn: async (args, ctx, location) => {
        const response = await fetch(args[0]);
        return await response.text();
      },
    },

    // Function with context access
    getVar: {
      params: [{ name: 'name', type: 'string' }],
      fn: (args, ctx) => {
        return ctx.variables.get(args[0]) ?? null;
      },
    },

    // Function with location for error reporting
    validate: {
      params: [{ name: 'value', type: 'string' }],
      fn: (args, ctx, location) => {
        if (!args[0]) {
          throw new Error(`Validation failed at line ${location?.line}`);
        }
        return args[0];
      },
    },
  },
});

Namespaced Functions

Use :: to organize functions into namespaces:

const ctx = createRuntimeContext({
  functions: {
    // Namespaced functions use :: separator
    'math::add': {
      params: [
        { name: 'a', type: 'number' },
        { name: 'b', type: 'number' },
      ],
      fn: (args) => args[0] + args[1],
    },
    'math::multiply': {
      params: [
        { name: 'a', type: 'number' },
        { name: 'b', type: 'number' },
      ],
      fn: (args) => args[0] * args[1],
    },
    'str::upper': {
      params: [{ name: 'text', type: 'string' }],
      fn: (args) => args[0].toUpperCase(),
    },
    'str::lower': {
      params: [{ name: 'text', type: 'string' }],
      fn: (args) => args[0].toLowerCase(),
    },

    // Multi-level namespaces
    'io::file::read': {
      params: [{ name: 'path', type: 'string' }],
      fn: async (args) => fs.readFile(args[0], 'utf-8'),
    },
    'io::file::write': {
      params: [
        { name: 'path', type: 'string' },
        { name: 'content', type: 'string' },
      ],
      fn: async (args) => fs.writeFile(args[0], args[1]),
    },
  },
});

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.

CallableFn Signature

The fn property in HostFunctionDefinition uses the CallableFn type:

type CallableFn = (
  args: RillValue[],
  ctx: RuntimeContext,
  location?: SourceLocation
) => RillValue | Promise<RillValue>;
ParameterDescription
argsPositional arguments passed to the function (already validated against params)
ctxRuntime context with variables, pipeValue, etc.
locationSource location of the call site (for error reporting)

Host Function Type Declarations

All host functions must declare parameter types and optional defaults using the HostFunctionDefinition 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: 'string' },
        { name: 'count', type: 'number', defaultValue: 1 },
      ],
      fn: (args) => {
        // args[0] guaranteed to be string
        // args[1] guaranteed to be number (or default)
        return args[0].repeat(args[1]);
      },
    },
  },
});

Scripts call typed functions the same way:

repeat("hello", 3)        # "hellohellohello"
repeat("hi")              # "hi" (uses default count)

Supported Types

Typerill ValueValidation
'string'Stringtypeof value === 'string'
'number'Numbertypeof value === 'number'
'bool'Booleantypeof value === 'boolean'
'list'ListArray.isArray(value)
'dict'DictisDict(value)

Default Values

Parameters with default values are optional. The default applies when the argument is missing:

functions: {
  greet: {
    params: [
      { name: 'name', type: 'string' },
      { name: 'greeting', type: 'string', defaultValue: 'Hello' },
    ],
    fn: (args) => `${args[1]}, ${args[0]}!`,
  },
}
greet("Alice")            # "Hello, Alice!"
greet("Bob", "Hi")        # "Hi, Bob!"

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

HostFunctionDefinition Interface

interface HostFunctionDefinition {
  params: HostFunctionParam[];
  fn: CallableFn;
  description?: string;                              // Human-readable function description
  returnType?: 'string' | 'number' | 'bool' | 'list' | 'dict' | 'any';  // Default: 'any'
}

interface HostFunctionParam {
  name: string;
  type: 'string' | 'number' | 'bool' | 'list' | 'dict';
  defaultValue?: RillValue;
  description?: string;                              // Human-readable parameter description
}

Documentation Validation

Enable requireDescriptions to enforce documentation at registration time:

const ctx = createRuntimeContext({
  requireDescriptions: true,
  functions: {
    greet: {
      params: [
        { name: 'name', type: 'string', description: 'Person to greet' },
      ],
      description: 'Generate a greeting message',
      returnType: 'string',
      fn: (args) => `Hello, ${args[0]}!`,
    },
  },
});

Missing descriptions throw clear errors:

  • Function: Function 'name' requires description (requireDescriptions enabled)
  • Parameter: Parameter 'x' of function 'name' requires description (requireDescriptions enabled)

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
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) => {
        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) => {
        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:

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 AbortError) {
    console.log('Execution cancelled');
  }
}

AbortError

import { AbortError } from '@rcrsr/rill';

try {
  await execute(ast, ctx);
} catch (err) {
  if (err instanceof AbortError) {
    console.log(err.code);    // 'RUNTIME_ABORTED'
    console.log(err.message); // 'Execution aborted'
  }
}

Abort is checked at:

  • Before each statement
  • Before each function call
  • Before each loop iteration
  • In the stepper’s step() method

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`);
    },
    onFunctionCall: (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 FunctionCallEvent {
  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.value);
console.log('Variables:', final.variables);

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 {
  value: RillValue;
  variables: Record<string, RillValue>;
}

Introspection

Discover available functions, access language documentation, and check runtime version at runtime.

getFunctions()

Enumerate all callable functions registered in the runtime context:

import { createRuntimeContext, getFunctions } from '@rcrsr/rill';

const ctx = createRuntimeContext({
  functions: {
    greet: {
      params: [
        { name: 'name', type: 'string', description: 'Person to greet' },
      ],
      description: 'Generate a greeting message',
      fn: (args) => `Hello, ${args[0]}!`,
    },
  },
});

const functions = getFunctions(ctx);
// [
//   {
//     name: 'greet',
//     description: 'Generate a greeting message',
//     params: [{ name: 'name', type: 'string', description: 'Person to greet', defaultValue: undefined }]
//   },
//   ... built-in functions
// ]

Returns FunctionMetadata[] combining:

  1. Host functions (with full parameter metadata)
  2. Built-in functions
  3. Script closures (reads ^(doc: "...") annotation for description)

getLanguageReference()

Access the bundled rill language reference for LLM prompt context:

import { getLanguageReference } from '@rcrsr/rill';

const reference = getLanguageReference();
// Returns complete language reference text (syntax, operators, types, etc.)

// Use in LLM system prompts:
const systemPrompt = `You are a rill script assistant.

${reference}

Help the user write rill scripts.`;

VERSION and VERSION_INFO

Access runtime version information for logging, diagnostics, or version checks:

import { VERSION, VERSION_INFO } from '@rcrsr/rill';

// VERSION: Semver string for display
console.log(`Running rill ${VERSION}`);  // "Running rill 0.5.0"

// VERSION_INFO: Structured components for programmatic comparison
if (VERSION_INFO.major === 0 && VERSION_INFO.minor < 4) {
  console.warn('rill version too old, upgrade required');
}

// Log full version info
console.log('Runtime:', {
  version: VERSION,
  major: VERSION_INFO.major,
  minor: VERSION_INFO.minor,
  patch: VERSION_INFO.patch,
  prerelease: VERSION_INFO.prerelease,
});

VERSION Constant:

  • Type: string
  • Format: Semver (e.g., "0.5.0", "1.0.0-beta.1")
  • Use: Display in logs, error messages, diagnostics

VERSION_INFO Constant:

  • Type: VersionInfo
  • Fields:
    • major: number - Major version (breaking changes)
    • minor: number - Minor version (new features)
    • patch: number - Patch version (bug fixes)
    • prerelease?: string - Prerelease tag if present
  • Use: Programmatic version comparison, compatibility checks

Version Comparison Example:

import { VERSION_INFO } from '@rcrsr/rill';

function checkCompatibility(): boolean {
  const required = { major: 0, minor: 4, patch: 0 };

  if (VERSION_INFO.major !== required.major) {
    return false; // Breaking change
  }

  if (VERSION_INFO.minor < required.minor) {
    return false; // Missing features
  }

  return true;
}

if (!checkCompatibility()) {
  throw new Error(`Requires rill >= 0.4.0, found ${VERSION}`);
}

getDocumentationCoverage()

Analyze documentation coverage of functions in a runtime context:

import { createRuntimeContext, getDocumentationCoverage } from '@rcrsr/rill';

const ctx = createRuntimeContext({
  functions: {
    documented: {
      params: [{ name: 'x', type: 'string', description: 'Input value' }],
      description: 'A documented function',
      fn: (args) => args[0],
    },
    undocumented: {
      params: [{ name: 'x', type: 'string' }],
      fn: (args) => args[0],
    },
  },
});

const result = getDocumentationCoverage(ctx);
// { total: 2, documented: 1, percentage: 50 }

A function counts as documented when:

  • Has non-empty description (after trim)
  • All parameters have non-empty descriptions (after trim)

Empty context returns { total: 0, documented: 0, percentage: 100 }.

Introspection Types

interface FunctionMetadata {
  readonly name: string;        // Function name (e.g., "math::add")
  readonly description: string; // Human-readable description
  readonly params: readonly ParamMetadata[];
  readonly returnType: string;  // Return type (default: 'any')
}

interface ParamMetadata {
  readonly name: string;                    // Parameter name
  readonly type: string;                    // Type constraint (e.g., "string")
  readonly description: string;             // Parameter description
  readonly defaultValue: RillValue | undefined; // Default if optional
}

interface DocumentationCoverageResult {
  readonly total: number;       // Total function count
  readonly documented: number;  // Functions with complete documentation
  readonly percentage: number;  // Percentage (0-100), rounded to 2 decimals
}

interface VersionInfo {
  readonly major: number;        // Major version (breaking changes)
  readonly minor: number;        // Minor version (new features)
  readonly patch: number;        // Patch version (bug fixes)
  readonly prerelease?: string;  // Prerelease tag if present
}

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

Set a timeout for async operations:

const ctx = createRuntimeContext({
  timeout: 30000, // 30 seconds
  functions: {
    slowOperation: {
      params: [],
      fn: async () => {
        // Will throw TimeoutError if exceeds 30s
        await longRunningTask();
        return 'done';
      },
    },
  },
});

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: 'string' }],
      fn: (args) => {
        // If this returns "error: invalid input",
        // execution halts with AutoExceptionError
        return externalProcess(args[0]);
      },
    },
  },
});

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, AbortError, TimeoutError } 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.code);
    console.log('Message:', err.message);
    console.log('Location:', err.location);
    console.log('Context:', err.context);
  } else if (err instanceof AbortError) {
    console.log('Execution cancelled');
  } else if (err instanceof TimeoutError) {
    console.log('Operation timed out');
  }
}

Error Codes

CodeDescription
PARSE_UNEXPECTED_TOKENUnexpected token in source
PARSE_INVALID_SYNTAXInvalid syntax
PARSE_INVALID_TYPEInvalid type annotation
RUNTIME_UNDEFINED_VARIABLEVariable not defined
RUNTIME_UNDEFINED_FUNCTIONFunction not defined
RUNTIME_UNDEFINED_METHODMethod not defined (built-in only)
RUNTIME_TYPE_ERRORType mismatch (includes host function parameter validation)
RUNTIME_TIMEOUTOperation timed out
RUNTIME_ABORTEDExecution cancelled
RUNTIME_INVALID_PATTERNInvalid regex pattern
RUNTIME_AUTO_EXCEPTIONAuto-exception triggered
RUNTIME_ASSERTION_FAILEDAssertion failed (condition false)
RUNTIME_ERROR_RAISEDError statement executed

See Also