How to Extend OmniRoute with Custom Skills: A Complete Developer Guide

Developers extend OmniRoute by registering custom skills through the SkillRegistry API (src/lib/skills/registry.ts), which validates, caches, and executes versioned skill handlers in a secure sandbox environment.

The OmniRoute framework provides a Skill framework that enables developers to add custom business logic without modifying core code. Skills are stored in SQLite, cached in memory for performance, and executed through a secure sandbox. This article covers the complete workflow for creating, registering, and invoking custom skills based on the OmniRoute source code.

Understanding the OmniRoute Skill Architecture

Core Components

OmniRoute's skill system is built on three primary components:

  • SkillRegistry (src/lib/skills/registry.ts) — A singleton that caches skills in memory, resolves versions, and handles database persistence
  • Custom Skill Helpers (src/lib/skills/custom.ts) — Public API methods for registration, validation, listing, and deletion
  • Sandbox (src/lib/skills/sandbox.ts) — An isolated execution environment for running skill handlers securely

Skill Ownership and Visibility

The registry distinguishes between global-owner skills and API-key-specific skills:

Owner Type Examples Visibility
system Built-in platform skills All API keys
skillsmp / skillssh Shared marketplace skills All API keys
Custom API key ID Developer-created skills Only that API key

This ownership model ensures appropriate scoping while allowing shared functionality through designated global owners.

Step 1: Define a Custom Skill Schema

All skills must conform to SkillCreateInputSchema, re-exported as CustomSkillSchema from src/lib/skills/schemas.ts. The schema requires:

// src/lib/skills/custom.ts
const payload = {
  name: "weather-summary",           // Unique identifier
  version: "1.0.0",                  // Semantic version
  description: "Summarize current weather",
  schema: {
    input: { location: "string" },   // Input validation schema
    output: { summary: "string" },   // Output shape declaration
  },
  handler: `
    export async function run({ input }) {
      const resp = await fetch(
        \`https://api.weather.com/v3/wx/conditions/current?location=\${input.location}\`
      );
      const data = await resp.json();
      return { summary: \`It is \${data.temperature}°C and \${data.narrative}.\` };
    }
  `,
  apiKeyId: "my-api-key",            // Owner identifier
  enabled: true,
};

The handler is stored as a string and executed in the sandbox. It must export a run function that receives an object with input, context, and tools properties.

Step 2: Register the Skill with skillRegistry

Use registerCustomSkill from src/lib/skills/custom.ts to persist and cache the skill:

// Server-side registration
import { registerCustomSkill } from "@/lib/skills/custom";

await registerCustomSkill(payload);

This performs three operations as implemented in src/lib/skills/registry.ts:

  1. Validation — Zod schema validation against CustomSkillSchema
  2. Database insertion — Creates row in the skills table with JSON-serialized handler
  3. Cache update — Adds entry to versionCache for immediate availability

The registry's versionCache is a Map keyed by {name}@{resolvedVersion} for O(1) lookups.

Step 3: Invoke the Skill via API

Custom skills are accessible through the REST API layer. The route pattern follows /api/v1/skills/run/[...slug]:

// Client-side invocation
const response = await fetch("/api/v1/skills/run/weather-summary@1.0.0", {
  method: "POST",
  headers: { 
    "Authorization": `Bearer ${MY_TOKEN}`,
    "Content-Type": "application/json"
  },
  body: JSON.stringify({ 
    input: { location: "NYC" } 
  })
});

const result = await response.json();
// { summary: "It is 22°C and partly cloudy with a chance of rain." }

The API route resolves the skill using skillRegistry.getSkill(name, versionConstraint) and executes it in the sandbox.

Version Resolution and Caching Behavior

Semantic Version Constraints

The registry supports expressive version matching through resolveVersion in src/lib/skills/registry.ts:

Operator Meaning Example Match
^ Compatible with major version ^1.2.3 matches 1.3.0, not 2.0.0
~ Approximately equivalent ~1.2.3 matches 1.2.5, not 1.3.0
> >= Greater than (or equal) >=1.0.0 matches 2.0.0
< <= Less than (or equal) <2.0.0 excludes 2.0.0
Exact Specific version only 1.2.3 matches only 1.2.3
latest Highest version available Resolves to max semver

Pass constraints to getSkill:

import { skillRegistry } from "@/lib/skills/registry";

// Get exact version
const skill = skillRegistry.getSkill("weather-summary", "1.0.0");

// Get latest 1.x compatible version
const skill = skillRegistry.getSkill("weather-summary", "^1.0.0");

// Get absolute latest
const skill = skillRegistry.getSkill("weather-summary", "latest");

Cache Invalidation

The registry implements time-based cache invalidation with a default TTL of 60 seconds. After TTL expiry:

  • The registry reloads skills from the database
  • versionCache and internal lookup maps are rebuilt
  • Active executions complete with cached handlers; new requests use fresh data

Managing Custom Skills

List All Skills for an API Key

import { listCustomSkills } from "@/lib/skills/custom";

const skills = listCustomSkills("my-api-key");
// Returns: [{ id, name, version, description, enabled, createdAt, updatedAt }, ...]

Returns metadata for all skills owned by the specified API key, excluding handler source code.

Delete a Custom Skill

import { deleteCustomSkill } from "@/lib/skills/custom";

await deleteCustomSkill("skill-uuid-here", "my-api-key");

This operation:

  • Validates ownership (prevents deletion of system or other API keys' skills)
  • Removes the database row
  • Invalidates all cache entries for that skill name
  • Returns true on success, throws SkillNotFoundError or PermissionDeniedError on failure

Secure Execution in the Sandbox

The src/lib/skills/sandbox.ts module provides isolated execution:

// Simplified internal flow from src/lib/skills/sandbox.ts
export async function executeSkill(
  skill: Skill,
  input: unknown,
  context: ExecutionContext
): Promise<unknown> {
  // 1. Create isolated VM context with limited globals
  const sandbox = createSandbox({
    console: createRestrictedConsole(),
    fetch: createRateLimitedFetch(skill.apiKeyId),
    Buffer,
    TextEncoder,
    TextDecoder,
  });

  // 2. Compile handler with strict mode enabled
  const script = new VM.Script(
    `"use strict";\n${skill.handler}`,
    { timeout: 30000 } // 30 second execution limit
  );

  // 3. Execute and return result
  const runFn = script.runInNewContext(sandbox);
  return await runFn({ input, context, tools: sandbox.tools });
}

Security features include:

  • No access to process, require, or file system
  • Timeout enforcement (configurable, default 30s)
  • Fetch rate limiting per API key
  • Memory limits on VM context size

Complete Custom Skill Example

Here's a production-ready custom skill that integrates with an external CRM:

// Custom skill: sync-lead-to-crm
const crmSyncSkill = {
  name: "sync-lead-to-crm",
  version: "1.2.0",
  description: "Create or update lead in external CRM system",
  schema: {
    input: {
      email: "string",
      company: "string?",
      score: "number?"
    },
    output: {
      success: "boolean",
      crmId: "string?",
      error: "string?"
    }
  },
  handler: `
    export async function run({ input, context, tools }) {
      const { email, company, score } = input;
      
      // Rate-limited fetch provided by sandbox
      const response = await fetch(context.env.CRM_API_URL + "/leads", {
        method: "POST",
        headers: {
          "Authorization": \`Bearer \${context.env.CRM_API_KEY}\`,
          "Content-Type": "application/json"
        },
        body: JSON.stringify({
          email,
          company: company || null,
          lead_score: score || 0,
          source: "omniroute-automation",
          metadata: { executionId: context.executionId }
        })
      });
      
      if (!response.ok) {
        const error = await response.text();
        return { success: false, error: \`CRM error: \${error}\` };
      }
      
      const data = await response.json();
      return { success: true, crmId: data.id };
    }
  `,
  apiKeyId: "org_abc123",
  enabled: true
};

// Registration
await registerCustomSkill(crmSyncSkill);

// Invocation with context variables
await fetch("/api/v1/skills/run/sync-lead-to-crm@^1.0.0", {
  method: "POST",
  headers: { "Authorization": "Bearer token_here" },
  body: JSON.stringify({
    input: { 
      email: "contact@example.com", 
      company: "Acme Inc",
      score: 85 
    },
    context: {
      env: {
        CRM_API_URL: "https://api.salesforce.com",
        CRM_API_KEY: process.env.CRM_API_KEY // Injected server-side
      }
    }
  })
});

Summary

  • Define skills using CustomSkillSchema with name, version, I/O schemas, and handler source code
  • Register via registerCustomSkill() in src/lib/skills/custom.ts to persist to SQLite and cache in memory
  • Resolve versions flexibly using semver operators (^, ~, >, >=, exact, latest) through skillRegistry.getSkill()
  • Execute safely through the sandbox in src/lib/skills/sandbox.ts with timeout, rate limiting, and restricted globals
  • Manage lifecycle with listCustomSkills() and deleteCustomSkill() for API-key-scoped operations

Frequently Asked Questions

What is the performance overhead of custom skill execution?

Custom skills execute in a VM sandbox with ~5-10ms initialization overhead per call. The versionCache in src/lib/skills/registry.ts eliminates database lookups for skill resolution, making repeated calls to the same skill version as fast as direct function invocation. Cache TTL defaults to 60 seconds, balancing freshness with performance.

Can I share custom skills across multiple API keys?

Direct sharing between arbitrary API keys is not supported for security isolation. However, skills registered with global owners (system, skillsmp, skillssh) are visible to all API keys. Enterprise deployments can request skillsmp status through the platform administration interface for approved shared skills.

How do I debug a custom skill that fails in production?

Enable detailed logging by setting DEBUG=omniroute:sandbox in environment variables. The sandbox in src/lib/skills/sandbox.ts captures console.log output and returns it in the error response when NODE_ENV=development. For production, use the context.executionId passed to handlers to correlate with platform logs containing full stack traces.

What happens if I register a skill with the same name and version?

The registry enforces unique constraints on {name, version, apiKeyId}. Attempting to register a duplicate throws DuplicateSkillError from src/lib/skills/registry.ts. Use updateCustomSkill() (if available in your OmniRoute version) or deregister first with deleteCustomSkill() followed by re-registration with incremented version.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →