How to Build Custom Skills with the OmniRoute Skills Framework Registry

OmniRoute's Skills subsystem lets you register reusable, versioned functions by implementing a TypeScript handler, recording metadata in the SQLite-backed Skill Registry, and invoking them through the Skill Executor with automatic validation, timeout enforcement, and execution logging.

OmniRoute (diegosouzapw/OmniRoute) ships with a production-ready Skills framework that transforms arbitrary JavaScript functions into discoverable, audited tools available to any LLM request. The architecture centers on the Skill Registry (src/lib/skills/registry.ts), which persists definitions to SQLite while maintaining an in-memory cache for high-performance lookups.

The Three Components of a Skill

Every custom skill in OmniRoute consists of three coordinated pieces: the stored definition, the runtime handler, and the execution engine.

Skill Registry (src/lib/skills/registry.ts)

The registry is a singleton that manages the skills table in SQLite. It stores the name, version, input/output schema, handler identifier, and enablement flag. When the server starts, it loads all records into memory and provides lookup helpers to resolve name@version strings into runnable definitions.

Skill Handler (User Implementation)

The handler is the actual JavaScript function that implements your business logic. It must conform to the SkillHandler interface defined in src/lib/skills/types.ts, accepting validated input and a context object containing apiKeyId, sessionId, and optional provider or model information. Handlers can live anywhere in your codebase.

Skill Executor (src/lib/skills/executor.ts)

The executor orchestrates the runtime. It validates requests against Zod schemas from src/lib/skills/schemas.ts, locates the registered handler, enforces timeouts and retries, and writes execution results to the skill_executions table. It exposes registerHandler(), execute(), listExecutions(), and getExecution() methods.

Step-by-Step: Creating a Custom Skill

The following walkthrough creates a weather_lookup skill that accepts a location and units, then returns mock weather data.

Step 1: Implement the Handler Function

Create a TypeScript file that exports a function matching the SkillHandler signature. Input validation is performed automatically by the executor before your handler receives the data.

// src/lib/skills/customWeather.ts
import { SkillHandler } from "./types";

export const weatherLookupHandler: SkillHandler = async (
  input,
  { apiKeyId, sessionId }
) => {
  // Input is already validated by Zod; expected shape:
  // { location: string, units?: "metric" | "imperial" }
  const { location, units = "metric" } = input as {
    location: string;
    units?: "metric" | "imperial";
  };

  // Replace with a real API call in production
  const fakeData = {
    location,
    temperature: units === "metric" ? 22 : 71,
    units,
    description: "Partly cloudy",
  };
  return fakeData;
};

Step 2: Register the Handler with the Executor

During application initialization (e.g., in src/lib/skills/init.ts), bind the handler to a unique name. This name acts as the bridge between the registry definition and the implementation.

// src/lib/skills/init.ts
import { skillExecutor } from "./executor";
import { weatherLookupHandler } from "./customWeather";

skillExecutor.registerHandler("weather_lookup", weatherLookupHandler);

Step 3: Define the Skill in the Registry

You can create the skill record via the REST API (POST /api/skills) or programmatically using skillRegistry.register(). The programmatic approach is useful for migrations or bootstrap scripts.

// src/lib/skills/createWeatherSkill.ts
import { skillRegistry } from "./registry";

await skillRegistry.register({
  name: "weather_lookup",
  version: "1.0.0",
  description: "Fetch current weather for a location",
  schema: {
    input: { location: "string", units: "string?" },
    output: { location: "string", temperature: "number", units: "string", description: "string" },
  },
  handler: "weather_lookup",          // Must match the name passed to registerHandler()
  enabled: true,
  apiKeyId: "system",                // Use "system" for global skills; specific API-key IDs for scoped skills
  mode: "on",
  sourceProvider: "local",
});

Step 4: Execute and Monitor

Invoke the skill from any request pipeline using skillExecutor.execute(). Pass the skill identifier (optionally including version with @), the input payload, and execution metadata.

import { skillExecutor } from "./executor";

const result = await skillExecutor.execute(
  "weather_lookup@1.0.0",                // name@version or just name for latest
  { location: "Berlin", units: "metric" },
  { apiKeyId: "user-123", sessionId: "sess-456" }
);

console.log(result.output);
/* → { location: "Berlin", temperature: 22, units: "metric", description: "Partly cloudy" } */

To audit or debug, query the execution history using the executor's built-in methods:

const recent = await skillExecutor.listExecutions("user-123", 10);
console.log(recent.map((e) => ({
  id: e.id,
  status: e.status,
  durationMs: e.durationMs,
})));

Type Safety and Schema Validation

All inputs and outputs are strictly type-checked using Zod schemas defined in src/lib/skills/schemas.ts, with TypeScript interfaces exported from src/lib/skills/types.ts. The executor automatically validates the input payload against the stored schema before invoking your handler, ensuring that only correctly shaped data reaches your business logic.

Summary

  • Skill Registry (src/lib/skills/registry.ts) persists skill metadata to SQLite and caches it in memory.
  • Skill Executor (src/lib/skills/executor.ts) validates inputs, runs handlers, enforces timeouts, and logs to skill_executions.
  • Handlers are registered once via skillExecutor.registerHandler(name, fn) and can reside in any file.
  • Definitions are created via the REST API or skillRegistry.register(), linking a name to a handler and schema.
  • Execution supports versioning (name@version), automatic validation, and detailed audit logging.

Frequently Asked Questions

Where are skill definitions stored in OmniRoute?

Skill definitions are stored in the skills table of the SQLite database managed by the Skill Registry (src/lib/skills/registry.ts). The registry maintains an in-memory cache of these records to avoid disk lookups during high-throughput execution.

How does versioning work when I build custom skills?

When registering a skill, you specify a version string (e.g., "1.0.0"). When invoking skillExecutor.execute(), you can reference a specific version using the name@version syntax, or omit the version to use the latest enabled definition. This allows multiple versions of the same skill to coexist for backward compatibility.

Can I disable a skill without deleting its code?

Yes. Each skill record includes an enabled boolean flag. Setting enabled: false in the registry (via the REST API or skillRegistry.register()) prevents the executor from invoking that skill, effectively taking it offline while preserving the definition and execution history.

Where should I place my custom handler implementations?

Handlers can live anywhere in your codebase, but the convention in OmniRoute is to place them in src/lib/skills/ or in domain-specific subdirectories. The built-in skills in src/lib/skills/builtins.ts demonstrate this pattern. The only requirement is that you call skillExecutor.registerHandler() with the implementation before attempting to execute skills that reference it.

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 →