Developing Extensions

Developing Extensions

Quick Start

Create a simple extension with a factory function:

import { createRuntimeContext, hoistExtension } from '@rcrsr/rill';
import type { ExtensionResult } from '@rcrsr/rill';

function createGreetExtension(config: { prefix: string }): ExtensionResult {
  return {
    greet: {
      params: [{ name: 'name', type: 'string' }],
      fn: (args) => `${config.prefix} ${args[0]}!`,
      description: 'Generate greeting',
      returnType: 'string',
    },
  };
}

const ext = createGreetExtension({ prefix: 'Hello' });
const { functions, dispose } = hoistExtension('app', ext);
const ctx = createRuntimeContext({ functions });

// Script: app::greet("World")

Extension Contract

Every extension exports a factory function returning ExtensionResult:

import type { ExtensionResult, HostFunctionDefinition } from '@rcrsr/rill';

function createMyExtension(config: MyConfig): ExtensionResult {
  // Validate config eagerly (throw on invalid)
  // Return host function definitions + optional dispose
  return {
    greet: {
      params: [{ name: 'name', type: 'string' }],
      fn: (args) => `Hello, ${args[0]}!`,
      description: 'Generate greeting',
      returnType: 'string',
    },
    dispose: () => {
      // Cleanup resources (connections, processes, etc.)
    },
  };
}

ExtensionResult Type

type ExtensionResult = Record<string, HostFunctionDefinition> & {
  dispose?: () => void | Promise<void>;
};

Each key (except dispose) maps a function name to a HostFunctionDefinition. The runtime registers these as callable host functions.

ExtensionFactory Type

type ExtensionFactory<TConfig> = (config: TConfig) => ExtensionResult;

Factory functions accept typed configuration and return an isolated extension instance.

Namespace Prefixing

Use prefixFunctions() to add a namespace prefix to all functions in an extension:

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

const ext = createMyExtension({ apiKey: 'sk-...' });
const prefixed = prefixFunctions('ai', ext);
// { "ai::greet": ..., dispose: ... }

Scripts call prefixed functions with :: syntax:

ai::greet("World")    # "Hello, World!"

Namespace Rules

  • Non-empty string
  • Alphanumeric characters, underscores, and hyphens only (/^[a-zA-Z0-9_-]+$/)
  • Invalid namespaces throw RuntimeError with code RUNTIME_TYPE_ERROR

Behavior

  • Prefixes every key except dispose with namespace::
  • Preserves the dispose method on the returned object
  • Returns a new ExtensionResult (does not mutate the original)

Extension Events

Extensions emit structured diagnostic events through emitExtensionEvent():

import { emitExtensionEvent, type RuntimeContextLike } from '@rcrsr/rill';

function myFunction(args: RillValue[], ctx: RuntimeContextLike) {
  const start = Date.now();
  const result = doWork(args[0]);

  emitExtensionEvent(ctx, {
    event: 'my-ext:operation',
    subsystem: 'extension:my-ext',
    duration: Date.now() - start,
  });

  return result;
}

ExtensionEvent Interface

interface ExtensionEvent {
  event: string;         // Semantic event name (e.g., "claude-code:prompt")
  subsystem: string;     // Extension identifier (pattern: "extension:{namespace}")
  timestamp?: string;    // ISO 8601 (auto-added by emitExtensionEvent if omitted)
  [key: string]: unknown; // Extensible context fields
}

Receiving Events

Subscribe via the onLogEvent callback:

const ctx = createRuntimeContext({
  callbacks: {
    onLogEvent: (event) => {
      console.log(`[${event.subsystem}] ${event.event}`, event);
    },
  },
  functions,
});

Lifecycle Management

Extensions that manage external resources (processes, connections, timers) must implement dispose():

function createPooledExtension(config: PoolConfig): ExtensionResult {
  const pool = createConnectionPool(config);

  return {
    query: {
      params: [{ name: 'sql', type: 'string' }],
      fn: async (args) => pool.query(args[0]),
      description: 'Execute SQL query',
      returnType: 'any',
    },
    dispose: () => {
      pool.close();
    },
  };
}

// Usage
const ext = createPooledExtension({ maxConnections: 10 });
const { functions, dispose } = hoistExtension('db', ext);
const ctx = createRuntimeContext({ functions });

try {
  const result = await execute(ast, ctx);
} finally {
  dispose?.();
}

Dispose Guidelines

  • dispose() may be sync or async
  • Must be idempotent (safe to call multiple times)
  • Should not throw — log warnings for cleanup failures
  • Always call dispose() in a finally block

Package Structure

Extensions follow this layout:

packages/ext/my-extension/
├── src/
│   ├── index.ts        # Public exports
│   ├── types.ts         # Type definitions
│   └── factory.ts       # Factory function
├── tests/
├── package.json
├── tsconfig.json
└── vitest.config.ts

package.json

{
  "name": "@rcrsr/rill-ext-my-extension",
  "version": "0.1.0",
  "type": "module",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "peerDependencies": {
    "@rcrsr/rill": "workspace:^"
  },
  "devDependencies": {
    "@rcrsr/rill": "workspace:^"
  },
  "publishConfig": {
    "access": "public"
  }
}

Key conventions:

  • Declare @rcrsr/rill as a peerDependency (not dependency)
  • Package name follows @rcrsr/rill-ext-{name} pattern
  • ESM-only ("type": "module")

tsconfig.json

{
  "extends": "../tsconfig.ext.json",
  "compilerOptions": {
    "rootDir": "./src",
    "outDir": "./dist"
  },
  "references": [{ "path": "../../core" }],
  "include": ["src/**/*"],
  "exclude": ["src/**/*.test.ts"]
}

Writing an Extension

1. Validate Configuration Eagerly

Throw errors synchronously in the factory — not during function execution:

function createMyExtension(config: MyConfig): ExtensionResult {
  // Validate at creation time
  if (!config.apiKey) {
    throw new Error('apiKey is required');
  }

  // Binary/tool existence check
  try {
    which.sync(config.binaryPath ?? 'mytool');
  } catch {
    throw new Error(`Binary not found: ${config.binaryPath}`);
  }

  return { /* functions */ };
}

2. Validate Function Arguments

Use rill’s parameter type system for automatic validation:

return {
  send: {
    params: [
      { name: 'message', type: 'string' },
      { name: 'options', type: 'dict', defaultValue: {} },
    ],
    fn: async (args) => {
      const message = args[0] as string;
      // Runtime guarantees message is a string
      // Additional domain validation here
      if (message.trim().length === 0) {
        throw new RuntimeError('RILL-R004', 'message cannot be empty');
      }
      return await doSend(message);
    },
    description: 'Send a message',
    returnType: 'dict',
  },
};

3. Emit Events for Observability

Emit events on success and failure for each operation:

fn: async (args, ctx) => {
  const start = Date.now();
  try {
    const result = await operation(args[0]);
    emitExtensionEvent(ctx, {
      event: 'my-ext:send',
      subsystem: 'extension:my-ext',
      duration: Date.now() - start,
    });
    return result;
  } catch (error) {
    emitExtensionEvent(ctx, {
      event: 'my-ext:error',
      subsystem: 'extension:my-ext',
      error: error instanceof Error ? error.message : 'Unknown',
      duration: Date.now() - start,
    });
    throw error;
  }
},

4. Track Resources for Cleanup

When spawning processes or opening connections, track them for dispose():

function createMyExtension(): ExtensionResult {
  const activeProcesses = new Set<() => void>();

  return {
    run: {
      params: [{ name: 'cmd', type: 'string' }],
      fn: async (args) => {
        const proc = spawn(args[0]);
        const cleanup = () => proc.kill();
        activeProcesses.add(cleanup);

        try {
          const result = await proc.exitCode;
          return result;
        } finally {
          activeProcesses.delete(cleanup);
          cleanup();
        }
      },
      description: 'Run command',
      returnType: 'string',
    },
    dispose: () => {
      for (const cleanup of activeProcesses) {
        try { cleanup(); } catch { /* log warning */ }
      }
      activeProcesses.clear();
    },
  };
}

5. Map SDK Errors to RuntimeError

Extensions that wrap third-party SDKs map errors to RuntimeError with consistent messages:

function mapSDKError(error: unknown, namespace: string): RuntimeError {
  if (error instanceof Error) {
    const message = error.message;

    // HTTP 401 authentication failure
    if (message.includes('401') || message.toLowerCase().includes('unauthorized')) {
      return new RuntimeError('RILL-R004', `${namespace}: authentication failed (401)`);
    }

    // Rate limit (429)
    if (message.includes('429') || message.toLowerCase().includes('rate limit')) {
      return new RuntimeError('RILL-R004', `${namespace}: rate limit exceeded`);
    }

    // Timeout/AbortError
    if (error.name === 'AbortError' || message.toLowerCase().includes('timeout')) {
      return new RuntimeError('RILL-R004', `${namespace}: request timeout`);
    }

    // Generic error with SDK message
    return new RuntimeError('RILL-R004', `${namespace}: ${message}`);
  }

  return new RuntimeError('RILL-R004', `${namespace}: unknown error`);
}

// Use in host function implementation
fn: async (args, ctx) => {
  try {
    const result = await sdkClient.operation(args[0]);
    return result;
  } catch (error) {
    const rillError = mapSDKError(error, 'myext');
    emitExtensionEvent(ctx, {
      event: 'myext:error',
      subsystem: 'extension:myext',
      error: rillError.message,
    });
    throw rillError;
  }
},

Examples: The qdrant, pinecone, and chroma extensions show this pattern for vector database operations. Each maps SDK-specific errors (collection not found, dimension mismatch, authentication) to consistent RuntimeError messages with namespace prefixes.

API Reference

Core Exports

// Extension types
export type { ExtensionResult, ExtensionFactory, ExtensionEvent, HoistedExtension };

// Extension utilities
export { prefixFunctions, hoistExtension, emitExtensionEvent };

See Also