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:
- Validation — Zod schema validation against
CustomSkillSchema - Database insertion — Creates row in the
skillstable with JSON-serialized handler - Cache update — Adds entry to
versionCachefor 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
versionCacheand 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
systemor other API keys' skills) - Removes the database row
- Invalidates all cache entries for that skill name
- Returns
trueon success, throwsSkillNotFoundErrororPermissionDeniedErroron 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
CustomSkillSchemawith name, version, I/O schemas, and handler source code - Register via
registerCustomSkill()insrc/lib/skills/custom.tsto persist to SQLite and cache in memory - Resolve versions flexibly using semver operators (
^,~,>,>=, exact,latest) throughskillRegistry.getSkill() - Execute safely through the sandbox in
src/lib/skills/sandbox.tswith timeout, rate limiting, and restricted globals - Manage lifecycle with
listCustomSkills()anddeleteCustomSkill()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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →