For AI agents: a documentation index is available at the root level at /llms.txt and /llms-full.txt. Append /llms.txt to any URL for a page-level index, or .md for the markdown version of any page.
ModelsChatRankingsDocs
DocsAPI ReferenceClient SDKsAgent SDKCookbookChangelog
DocsAPI ReferenceClient SDKsAgent SDKCookbookChangelog
    • Overview
    • Usage for Agents
  • TypeScript SDK
    • Overview
      • Overview
      • Working with Items
      • API Reference
      • Dynamic Parameters
      • Next Turn Params
      • Stop Conditions
      • Streaming
      • Text Generation
      • Message Formats
      • Tools
      • Tool Approval & State
        • Weather Tool
        • Skills Loader
  • DevTools
    • Overview
    • Migrating to @openrouter/agent
LogoLogo
ModelsChatRankingsDocs
On this page
  • Overview
  • Prerequisites
  • Basic Skills Tool
  • Usage
  • Example Skill File
  • Extended: Multi-Skill Loader
  • Extended: Skill with Options
  • Skill Discovery Tool
  • Complete Example
  • Key Patterns
  • 1. Idempotency
  • 2. Graceful Fallbacks
  • 3. Context Preservation
  • 4. Clear Markers
  • See Also
Call ModelExamples

Skills Loader

A complete implementation of a skills system similar to Claude Code, demonstrating the power of nextTurnParams for context injection.

Was this page helpful?
Previous

DevTools

SDK Development Tools for telemetry capture and visualization
Next
Built with

Overview

This example shows how to build encapsulated, self-managing tools that inject domain-specific context into conversations. When a skill is loaded, it automatically enriches subsequent turns with specialized instructions.

Prerequisites

$pnpm add @openrouter/sdk zod

Create a skills directory:

$mkdir -p ~/.claude/skills/pdf-processing
$mkdir -p ~/.claude/skills/data-analysis
$mkdir -p ~/.claude/skills/code-review

Basic Skills Tool

1import { OpenRouter, tool } from '@openrouter/agent';
2import { readFileSync, existsSync, readdirSync } from 'fs';
3import path from 'path';
4import { z } from 'zod';
5
6const openrouter = new OpenRouter({
7 apiKey: process.env.OPENROUTER_API_KEY,
8});
9
10const SKILLS_DIR = path.join(process.env.HOME || '~', '.claude', 'skills');
11
12// List available skills
13const listAvailableSkills = (): string[] => {
14 if (!existsSync(SKILLS_DIR)) return [];
15 return readdirSync(SKILLS_DIR, { withFileTypes: true })
16 .filter((dirent) => dirent.isDirectory())
17 .filter((dirent) => existsSync(path.join(SKILLS_DIR, dirent.name, 'SKILL.md')))
18 .map((dirent) => dirent.name);
19};
20
21const skillsTool = tool({
22 name: 'Skill',
23 description: `Load a specialized skill to enhance the assistant's capabilities.
24Available skills: ${listAvailableSkills().join(', ') || 'none configured'}
25Each skill provides domain-specific instructions and capabilities.`,
26
27 inputSchema: z.object({
28 type: z.string().describe("The skill type to load (e.g., 'pdf-processing')"),
29 }),
30
31 outputSchema: z.string(),
32
33 // This is where the magic happens - modify context for next turn
34 nextTurnParams: {
35 input: (params, context) => {
36 // Prevent duplicate skill loading
37 const skillMarker = `[Skill: ${params.type}]`;
38 if (JSON.stringify(context.input).includes(skillMarker)) {
39 return context.input;
40 }
41
42 // Load the skill's instructions
43 const skillPath = path.join(SKILLS_DIR, params.type, 'SKILL.md');
44 if (!existsSync(skillPath)) {
45 return context.input;
46 }
47
48 const skill = readFileSync(skillPath, 'utf-8');
49 const skillDir = path.join(SKILLS_DIR, params.type);
50
51 // Inject skill context into the conversation
52 const currentInput = Array.isArray(context.input) ? context.input : [context.input];
53
54 return [
55 ...currentInput,
56 {
57 role: 'user',
58 content: `${skillMarker}
59Base directory for this skill: ${skillDir}
60
61${skill}`,
62 },
63 ];
64 },
65 },
66
67 execute: async (params, context) => {
68 const skillMarker = `[Skill: ${params.type}]`;
69
70 // Check if already loaded
71 if (JSON.stringify(context?.turnRequest?.input || []).includes(skillMarker)) {
72 return `Skill ${params.type} is already loaded`;
73 }
74
75 const skillPath = path.join(SKILLS_DIR, params.type, 'SKILL.md');
76 if (!existsSync(skillPath)) {
77 const available = listAvailableSkills();
78 return `Skill "${params.type}" not found. Available skills: ${available.join(', ') || 'none'}`;
79 }
80
81 return `Launching skill ${params.type}`;
82 },
83});

Usage

1const result = openrouter.callModel({
2 model: 'anthropic/claude-sonnet-4.5',
3 input: 'I need to process a PDF and extract tables from it',
4 tools: [skillsTool],
5});
6
7const text = await result.getText();
8// The model will call the Skill tool, loading pdf-processing context
9// Subsequent responses will have access to the skill's instructions

Example Skill File

Create ~/.claude/skills/pdf-processing/SKILL.md:

1# PDF Processing Skill
2
3You are now equipped with PDF processing capabilities.
4
5## Available Tools
6When processing PDFs, you have access to:
7- `extract_text`: Extract all text from a PDF
8- `extract_tables`: Extract tables as structured data
9- `extract_images`: Extract embedded images
10- `split_pdf`: Split PDF into individual pages
11
12## Best Practices
131. Always check PDF file size before processing
142. For large PDFs (>50 pages), process in chunks
153. OCR may be needed for scanned documents
164. Tables may span multiple pages - handle accordingly
17
18## Output Formats
19- Text: Plain text or markdown
20- Tables: JSON, CSV, or markdown tables
21- Images: PNG with sequential naming
22
23## Error Handling
24- If a PDF is encrypted, request the password
25- If OCR fails, suggest alternative approaches
26- Report page numbers for any extraction errors

Extended: Multi-Skill Loader

Load multiple skills in a single call:

1const multiSkillLoader = tool({
2 name: 'load_skills',
3 description: 'Load multiple skills at once for complex tasks',
4
5 inputSchema: z.object({
6 skills: z.array(z.string()).describe('Array of skill names to load'),
7 }),
8
9 outputSchema: z.object({
10 loaded: z.array(z.string()),
11 failed: z.array(
12 z.object({
13 name: z.string(),
14 reason: z.string(),
15 })
16 ),
17 }),
18
19 nextTurnParams: {
20 input: (params, context) => {
21 let newInput = Array.isArray(context.input) ? context.input : [context.input];
22
23 for (const skillName of params.skills) {
24 const skillMarker = `[Skill: ${skillName}]`;
25
26 // Skip if already loaded
27 if (JSON.stringify(newInput).includes(skillMarker)) {
28 continue;
29 }
30
31 const skillPath = path.join(SKILLS_DIR, skillName, 'SKILL.md');
32 if (!existsSync(skillPath)) {
33 continue;
34 }
35
36 const skillContent = readFileSync(skillPath, 'utf-8');
37 const skillDir = path.join(SKILLS_DIR, skillName);
38
39 newInput = [
40 ...newInput,
41 {
42 role: 'user',
43 content: `${skillMarker}
44Base directory: ${skillDir}
45
46${skillContent}`,
47 },
48 ];
49 }
50
51 return newInput;
52 },
53 },
54
55 execute: async ({ skills }) => {
56 const loaded: string[] = [];
57 const failed: Array<{ name: string; reason: string }> = [];
58
59 for (const skill of skills) {
60 const skillPath = path.join(SKILLS_DIR, skill, 'SKILL.md');
61 if (existsSync(skillPath)) {
62 loaded.push(skill);
63 } else {
64 failed.push({ name: skill, reason: 'Skill not found' });
65 }
66 }
67
68 return { loaded, failed };
69 },
70});
71
72// Usage
73const result = openrouter.callModel({
74 model: 'anthropic/claude-sonnet-4.5',
75 input: 'I need to analyze a PDF report and create visualizations',
76 tools: [multiSkillLoader],
77});
78// Model might call: load_skills({ skills: ['pdf-processing', 'data-analysis'] })

Extended: Skill with Options

Skills that accept configuration:

1const configurableSkillLoader = tool({
2 name: 'configure_skill',
3 description: 'Load a skill with custom configuration options',
4
5 inputSchema: z.object({
6 skillName: z.string(),
7 options: z
8 .object({
9 verbosity: z.enum(['minimal', 'normal', 'detailed']).default('normal'),
10 strictMode: z.boolean().default(false),
11 outputFormat: z.enum(['json', 'markdown', 'plain']).default('markdown'),
12 })
13 .optional(),
14 }),
15
16 outputSchema: z.object({
17 status: z.enum(['loaded', 'already_loaded', 'not_found']),
18 message: z.string(),
19 configuration: z.record(z.unknown()).optional(),
20 }),
21
22 nextTurnParams: {
23 input: (params, context) => {
24 const skillMarker = `[Skill: ${params.skillName}]`;
25 if (JSON.stringify(context.input).includes(skillMarker)) {
26 return context.input;
27 }
28
29 const skillPath = path.join(SKILLS_DIR, params.skillName, 'SKILL.md');
30 if (!existsSync(skillPath)) {
31 return context.input;
32 }
33
34 const skillContent = readFileSync(skillPath, 'utf-8');
35 const options = params.options || {};
36
37 // Build configuration header
38 const configHeader = `
39## Skill Configuration
40- Verbosity: ${options.verbosity || 'normal'}
41- Strict Mode: ${options.strictMode || false}
42- Output Format: ${options.outputFormat || 'markdown'}
43`;
44
45 const currentInput = Array.isArray(context.input) ? context.input : [context.input];
46
47 return [
48 ...currentInput,
49 {
50 role: 'user',
51 content: `${skillMarker}
52${configHeader}
53
54${skillContent}`,
55 },
56 ];
57 },
58
59 // Adjust model behavior based on skill
60 temperature: (params, context) => {
61 // Lower temperature for strict mode
62 if (params.options?.strictMode) {
63 return 0.3;
64 }
65 return context.temperature;
66 },
67 },
68
69 execute: async ({ skillName, options }) => {
70 const skillPath = path.join(SKILLS_DIR, skillName, 'SKILL.md');
71
72 if (!existsSync(skillPath)) {
73 return {
74 status: 'not_found' as const,
75 message: `Skill "${skillName}" not found`,
76 };
77 }
78
79 return {
80 status: 'loaded' as const,
81 message: `Skill "${skillName}" loaded with configuration`,
82 configuration: options || {},
83 };
84 },
85});

Skill Discovery Tool

List and describe available skills:

1const skillDiscoveryTool = tool({
2 name: 'list_skills',
3 description: 'List all available skills with their descriptions',
4
5 inputSchema: z.object({
6 category: z.string().optional().describe('Filter by category'),
7 }),
8
9 outputSchema: z.object({
10 skills: z.array(
11 z.object({
12 name: z.string(),
13 description: z.string(),
14 hasConfig: z.boolean(),
15 })
16 ),
17 totalCount: z.number(),
18 }),
19
20 execute: async ({ category }) => {
21 const availableSkills = listAvailableSkills();
22 const skills = [];
23
24 for (const skillName of availableSkills) {
25 const skillPath = path.join(SKILLS_DIR, skillName, 'SKILL.md');
26 const content = readFileSync(skillPath, 'utf-8');
27
28 // Extract first paragraph as description
29 const lines = content.split('\n').filter((l) => l.trim());
30 const description = lines.find((l) => !l.startsWith('#')) || 'No description';
31
32 // Check for config file
33 const configPath = path.join(SKILLS_DIR, skillName, 'config.json');
34 const hasConfig = existsSync(configPath);
35
36 skills.push({
37 name: skillName,
38 description: description.slice(0, 100),
39 hasConfig,
40 });
41 }
42
43 return {
44 skills,
45 totalCount: skills.length,
46 };
47 },
48});

Complete Example

Putting it all together:

1import { OpenRouter, tool, stepCountIs } from '@openrouter/agent';
2import { readFileSync, existsSync, readdirSync } from 'fs';
3import path from 'path';
4import { z } from 'zod';
5
6const openrouter = new OpenRouter({
7 apiKey: process.env.OPENROUTER_API_KEY,
8});
9
10const SKILLS_DIR = path.join(process.env.HOME || '~', '.claude', 'skills');
11
12// ... (include skillsTool, multiSkillLoader, skillDiscoveryTool from above)
13
14// Use all skill tools together
15const result = openrouter.callModel({
16 model: 'anthropic/claude-sonnet-4.5',
17 input: `I have a complex task:
181. First, show me what skills are available
192. Load the appropriate skills for PDF analysis
203. Then help me extract and analyze data from report.pdf`,
21 tools: [skillDiscoveryTool, skillsTool, multiSkillLoader],
22 stopWhen: stepCountIs(10),
23});
24
25const text = await result.getText();
26console.log(text);

Key Patterns

1. Idempotency

Always check if a skill is already loaded:

1nextTurnParams: {
2 input: (params, context) => {
3 const marker = `[Skill: ${params.type}]`;
4 if (JSON.stringify(context.input).includes(marker)) {
5 return context.input; // Don't add again
6 }
7 // ... add skill
8 },
9},

2. Graceful Fallbacks

Handle missing skills gracefully:

1execute: async (params) => {
2 if (!existsSync(skillPath)) {
3 return `Skill not found. Available: ${listAvailableSkills().join(', ')}`;
4 }
5 // ...
6},

3. Context Preservation

Always preserve existing input:

1nextTurnParams: {
2 input: (params, context) => {
3 const currentInput = Array.isArray(context.input)
4 ? context.input
5 : [context.input];
6 return [...currentInput, newMessage]; // Append, don't replace
7 },
8},

4. Clear Markers

Use unique markers to identify injected content:

1const skillMarker = `[Skill: ${params.type}]`;
2// Makes detection reliable and content clearly labeled

See Also

  • nextTurnParams Guide - Context injection patterns
  • Dynamic Parameters - Adaptive behavior
  • Tools - Multi-turn orchestration